Skip to content

test: DLEQ proof for blinded-signature key equality - #95

Open
Kukks wants to merge 2 commits into
masterfrom
test/dleq-proof
Open

test: DLEQ proof for blinded-signature key equality#95
Kukks wants to merge 2 commits into
masterfrom
test/dleq-proof

Conversation

@Kukks

@Kukks Kukks commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds self-contained Go tests for a DLEQ (Discrete Log Equality) proof — the Cashu/BDHKE construction used to prove that a blinded signature was produced with a known public key, without revealing the secret.

Bob proves the same secret a links:

  • A = a*G (the public key)
  • C' = a*B' (the signature over Alice's blinded message B')
Bob:   r random; R1 = r*G; R2 = r*B'; e = H(R1,R2,A,C'); s = r + e*a  →  (e, s)
Alice: R1 = s*G - e*A; R2 = s*B' - e*C'; accept iff e == H(R1,R2,A,C')

If accepted, the a in A = a*G equals the a in C' = a*B'.

What's included

Two complementary tests, no production code is modified — all logic lives in the test files:

1. pkg/arkade/dleq_test.go — reference prove/verify in Go

Prove/verify helpers plus the test, built on the existing btcec/v2 point/scalar primitives already used in tweak.go (ScalarBaseMultNonConst, ScalarMultNonConst, AddNonConst, ModNScalar).

2. pkg/arkade/dleq_script_test.go — verification inside an Arkade Script

Runs the DLEQ verifier as an actual Arkade Script through the engine. The public instance (G, A, B', C') is baked into the locking script; the proof (e, s) is supplied as the witness. The script:

  • recomputes R1 = s*G - e*A and R2 = s*B' - e*C' with OP_ECMUL/OP_ECADD, using (n-e)*P for the subtraction (there is no point-negation opcode);
  • reconstructs the SEC1 compressed points from their affine (x, y) coordinates (OP_NUM2BIN/OP_REVERSEBYTES/OP_RIGHT/OP_CAT), which also binds A/C' between the EC equations and the hash;
  • derives the Fiat-Shamir challenge with OP_SHA256 + OP_BIN2NUM + OP_MOD n, appending a synthesized 0x00 byte so the little-endian digest is never read as negative;
  • asserts the recomputed challenge equals the witness e.

Test cases

Case dleq_test.go dleq_script_test.go
Valid proof ✅ accept ✅ accept
Wrong A ❌ reject
Wrong C' / different secret ❌ reject ❌ reject
Tampered s ❌ reject ❌ reject
Tampered e ❌ reject ❌ reject

The "different secret" case in the script test supplies a fully self-consistent (e, s) for a different a; it is rejected purely by the in-script hash binding to the script's A and C', demonstrating soundness rather than blanket acceptance.

Test plan

cd pkg/arkade && go test . -run 'TestDLEQProof|TestDLEQScript' -v

All subtests pass; go vet ./... and gofmt are clean.

Adds a self-contained test exercising a Cashu/BDHKE-style DLEQ (Discrete
Log Equality) proof: Bob proves the same secret a links A = a*G and
C-prime = a*B-prime without revealing a.

Covers the valid round-trip plus four soundness cases (wrong A, wrong
C-prime, tampered s, tampered e). All proof/verify helpers live in the
test file; no production code is touched.

@ghost ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — Approved

Clean, self-contained test. No production code changed. DLEQ math is correct.

Verified

  • Proof/verify algebra: s*G - e*A = r*G and s*B' - e*C' = r*B' — checked manually, implementation matches.
  • Test coverage: 5 subtests covering valid proof + 4 independent failure modes (wrong A, wrong C', tampered s, tampered e). Good discrimination.
  • No cross-repo impact: test-only file, no API/type/proto changes.
  • Existing primitives reused: btcec/v2 point/scalar ops consistent with tweak.go and ec_ops.go.

Minor notes (non-blocking)

  1. mustPrivKeyFromSeed near-duplicate (dleq_test.go:65): tweak_test.go:96 already has mustPrivKeyFromSeedWithPrefix which does the same core thing (SHA-256 seed → PrivKeyFromBytes). Could reuse it with a don't-care prefix, but not worth blocking over.

  2. SetBytes overflow ignored (dleq_test.go:82, dleq_test.go:116): ModNScalar.SetBytes silently reduces mod N when the 32-byte hash ≥ curve order. Standard for Schnorr-like challenge hashing and acceptable for test code — just noting for awareness.

  3. Deterministic nonce (dleq_test.go:77): r = H(a || B' || C') is fine for reproducible tests. In any future production DLEQ prover, this would need RFC 6979-style derivation or a CSPRNG. The comment already calls this out — good.

@ghost ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — Approved

Clean, self-contained test. No production code changed. DLEQ math is correct.

Verified

  • Proof/verify algebra: s*G - e*A = r*G and s*B' - e*C' = r*B' — checked manually, implementation matches.
  • Test coverage: 5 subtests covering valid proof + 4 independent failure modes (wrong A, wrong C', tampered s, tampered e). Good discrimination.
  • No cross-repo impact: test-only file, no API/type/proto changes.
  • Existing primitives reused: btcec/v2 point/scalar ops consistent with tweak.go and ec_ops.go.

Minor notes (non-blocking)

  1. mustPrivKeyFromSeed near-duplicate (dleq_test.go:65): tweak_test.go:96 already has mustPrivKeyFromSeedWithPrefix which does the same core thing (SHA-256 seed → PrivKeyFromBytes). Could reuse it with a don't-care prefix, but not worth blocking over.

  2. SetBytes overflow ignored (dleq_test.go:82, dleq_test.go:116): ModNScalar.SetBytes silently reduces mod N when the 32-byte hash ≥ curve order. Standard for Schnorr-like challenge hashing and acceptable for test code — just noting for awareness.

  3. Deterministic nonce (dleq_test.go:77): r = H(a || B' || C') is fine for reproducible tests. In any future production DLEQ prover, this would need RFC 6979-style derivation or a CSPRNG. The comment already calls this out — good.

Adds an end-to-end test that checks a Cashu/BDHKE-style DLEQ proof entirely
in Arkade Script. The public instance (G, A, B', C') is baked into the
locking script and the proof (e, s) is supplied as the witness.

The script recomputes R1 = s*G - e*A and R2 = s*B' - e*C' with OP_ECMUL/
OP_ECADD (using (n-e)*P for the subtraction), reconstructs the SEC1
compressed points from their affine coordinates, derives the Fiat-Shamir
challenge with OP_SHA256/OP_BIN2NUM/OP_MOD, and asserts it matches e.

Covers the valid proof plus tampered-s, tampered-e, and a fully
self-consistent proof for a different secret (rejected by the in-script
hash binding to A and C').

@ghost ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — new commit 0a13dca2 (DLEQ script test)

Traced the full script construction in dleq_script_test.go opcode-by-opcode. Verified:

Stack depth tracking — The builder's depth variable and the pick helper correctly compute OP_PICK offsets for the three base items (e=0, s=1, n-e=2) throughout the entire script. Every scalarMul, ecAdd, serializeCompressed, alt-stack shuttle, and hash computation maintains correct depth.

DLEQ math — Standard Schnorr-like proof of discrete log equality. The (n-e) trick for point negation (no OP_ECNEG opcode) is correct: (n-e)·P ≡ -e·P (mod n).

Point serialization in-scripty % 2 + 2 → prefix byte, NUM2BIN(x, 33) → REVERSEBYTES → RIGHT(32) → big-endian 32-byte X. Matches SEC1 compressed encoding. EC coordinates from OP_ECMUL are field elements (always non-negative), so parity check is safe.

Fiat-Shamir challenge consistency — The off-chain dleqScriptChallenge reverses the SHA256 digest bytes to match the script's LE→BIN2NUM interpretation, then reduces mod n. The script appends 0x00 before BIN2NUM to force positive sign-magnitude. Both produce the same scalar. Verified that the byte-reversal in dleqScriptChallenge is the correct transform (not the same as dleqChallenge in the pure-Go test, which uses BE — intentionally different).

Negative test coverage — Tampered s, tampered e, and a self-consistent proof for a different secret (which fails due to the in-script hash binding to A and C'). Good discrimination.

No production code changed. Both files are _test.go in the same package, sharing helpers correctly.

Clean. Approving the new commit.

🤖 Reviewed by Arkana

@ghost ghost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — new commit 0a13dca (DLEQ script test)

Traced the full script construction in dleq_script_test.go opcode-by-opcode. Verified:

Stack depth tracking — The builder's depth variable and the pick helper correctly compute OP_PICK offsets for the three base items (e=0, s=1, n-e=2) throughout the entire script. Every scalarMul, ecAdd, serializeCompressed, alt-stack shuttle, and hash computation maintains correct depth.

DLEQ math — Standard Schnorr-like proof of discrete log equality. The (n-e) trick for point negation (no OP_ECNEG opcode) is correct: (n-e)*P = -e*P (mod n).

Point serialization in-scripty % 2 + 2 -> prefix byte, NUM2BIN(x, 33) -> REVERSEBYTES -> RIGHT(32) -> big-endian 32-byte X. Matches SEC1 compressed encoding. EC coordinates from OP_ECMUL are field elements (always non-negative), so parity check is safe.

Fiat-Shamir challenge consistency — The off-chain dleqScriptChallenge reverses the SHA256 digest bytes to match the script's LE BIN2NUM interpretation, then reduces mod n. The script appends 0x00 before BIN2NUM to force positive sign-magnitude. Both produce the same scalar. Verified that the byte-reversal in dleqScriptChallenge is the correct transform (not the same as dleqChallenge in the pure-Go test, which uses BE — intentionally different).

Negative test coverage — Tampered s, tampered e, and a self-consistent proof for a different secret (which fails due to the in-script hash binding to A and C'). Good discrimination.

No production code changed. Both files are _test.go in the same package, sharing helpers correctly.

Clean. Approving the new commit.

🤖 Reviewed by Arkana

@arkana-ai-bot arkana-ai-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test-only PR — two new files, no production code, no ABI/proto/interface surface changed. Not protocol-critical.

Correctness

Walked through the on-chain arithmetic against pkg/arkade/ec_ops.go and the byte-surgery opcodes in pkg/arkade/opcode.go:

  • Curve id. OP_0 before OP_ECMUL/OP_ECADD matches CurveSecp256k1 = 0 (ec_ops.go:22, popped in popCurveID, ec_ops.go:82). ✓
  • Point negation via n − e. popInGroupScalar (ec_ops.go:125) enforces 0 ≤ k < n, so the script implicitly requires 0 < e < n: e = 0n − e = n rejects, e ≥ nn − e < 0 rejects. The test's require.NotEqual(t, 0, e.Sign(), …) guards the valid path against the astronomically unlikely e = 0. ✓
  • SEC1 compressed reconstruction. x 33 OP_NUM2BIN → OP_REVERSEBYTES → 32 OP_RIGHT gives be32(x) for any x ∈ [0, p) because NUM2BIN(33) always fits (worst case: minimal encoding needs 33 bytes with a 0x00 sign byte, which becomes the leading 0x00 after reverse and gets sliced away by RIGHT 32). Prefix byte via y mod 2 + 2 is fine — y on the stack comes from pushECCoord (ec_ops.go:143) so it's a canonical non-negative BigNum and OP_MOD's "sign follows dividend" (opcode.go:2414) can't produce a negative. ✓
  • Digest → scalar. Confirmed the off-chain dleqScriptChallenge (BE reverse → SetBytes as BE = LE reading of digest) matches in-script SHA256 → CAT 0x00 → BIN2NUM (sign-magnitude LE with 0x00 forcing positive). The 0x00 byte is load-bearing: without it a digest with top bit set would decode negative and MOD n would produce a negative remainder. Nicely handled. ✓
  • Soundness of the "different secret" case. The proof (e2, s2) is self-consistent for a2, C2 but the script's preimage still commits to the baked-in A, C', so s2*G + (n−e2)*A ≠ R1 unless e2*(a2 − a) ≡ 0 mod n, i.e. never. Rejection is by hash binding, not by any accidental range check — this is the right thing to demonstrate. ✓

Reference-side dleq_test.go is a straightforward Cashu/BDHKE implementation on btcec/v2 primitives already used by tweak.go; dleqVerify uses proper scalar negation via ModNScalar.Negate, so no (n−e) bookkeeping needed there.

Nits (non-blocking)

  1. dleq_test.go:68r.SetBytes(&nonce) discards the reduction-occurred return. Cosmetic; fine for a test with fixed seeds.
  2. Script suite has no "wrong A" test because A is baked into the locking script — as the PR body notes, the "different secret" case covers the equivalent soundness property. Worth an inline comment near that subtest so a future reader doesn't add a redundant one.
  3. Neither test hits the extreme e = n−1 / large-s boundary. The deterministic seeds produce arbitrary values in-range, so the code paths that only fire on e ≥ n or e = 0 (from the argument above) are covered only by construction, not by direct assertion. A t.Run that forces those witnesses would document the guard explicitly. Optional.
  4. Two independently-implemented challenge helpers (dleqChallenge in dleq_test.go reads BE, dleqScriptChallenge in dleq_script_test.go reads LE). Intentional and each is self-consistent, but if someone ever moves code between files that difference will silently break things — the comment on dleqScriptChallenge already flags it; consider a matching comment on dleqChallenge pointing at the other convention.

Coverage / cross-repo

Only pkg/arkade/dleq_{,script_}test.go added. Grepped the SDK clones for any consumer surface that could be affected — none, since this is package-internal test code. Danger passed.

LGTM as documentation/regression coverage for the EC + byte-surgery opcode stack. If any of this pattern later migrates into production (e.g. a VTXO covenant that verifies a DLEQ), that PR will need protocol-critical review on its own — the script here is not a drop-in template because it hard-codes the public instance and has no anti-malleability / sighash binding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants