test: DLEQ proof for blinded-signature key equality - #95
Conversation
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
left a comment
There was a problem hiding this comment.
✅ LGTM — Approved
Clean, self-contained test. No production code changed. DLEQ math is correct.
Verified
- Proof/verify algebra:
s*G - e*A = r*Gands*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/v2point/scalar ops consistent withtweak.goandec_ops.go.
Minor notes (non-blocking)
-
mustPrivKeyFromSeednear-duplicate (dleq_test.go:65):tweak_test.go:96already hasmustPrivKeyFromSeedWithPrefixwhich does the same core thing (SHA-256 seed →PrivKeyFromBytes). Could reuse it with a don't-care prefix, but not worth blocking over. -
SetBytesoverflow ignored (dleq_test.go:82,dleq_test.go:116):ModNScalar.SetBytessilently 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. -
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
left a comment
There was a problem hiding this comment.
✅ LGTM — Approved
Clean, self-contained test. No production code changed. DLEQ math is correct.
Verified
- Proof/verify algebra:
s*G - e*A = r*Gands*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/v2point/scalar ops consistent withtweak.goandec_ops.go.
Minor notes (non-blocking)
-
mustPrivKeyFromSeednear-duplicate (dleq_test.go:65):tweak_test.go:96already hasmustPrivKeyFromSeedWithPrefixwhich does the same core thing (SHA-256 seed →PrivKeyFromBytes). Could reuse it with a don't-care prefix, but not worth blocking over. -
SetBytesoverflow ignored (dleq_test.go:82,dleq_test.go:116):ModNScalar.SetBytessilently 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. -
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
left a comment
There was a problem hiding this comment.
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-script — y % 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
left a comment
There was a problem hiding this comment.
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-script — y % 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
left a comment
There was a problem hiding this comment.
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_0beforeOP_ECMUL/OP_ECADDmatchesCurveSecp256k1 = 0(ec_ops.go:22, popped inpopCurveID,ec_ops.go:82). ✓ - Point negation via
n − e.popInGroupScalar(ec_ops.go:125) enforces0 ≤ k < n, so the script implicitly requires0 < e < n:e = 0→n − e = nrejects,e ≥ n→n − e < 0rejects. The test'srequire.NotEqual(t, 0, e.Sign(), …)guards the valid path against the astronomically unlikelye = 0. ✓ - SEC1 compressed reconstruction.
x 33 OP_NUM2BIN → OP_REVERSEBYTES → 32 OP_RIGHTgivesbe32(x)for anyx ∈ [0, p)becauseNUM2BIN(33)always fits (worst case: minimal encoding needs 33 bytes with a0x00sign byte, which becomes the leading0x00after reverse and gets sliced away byRIGHT 32). Prefix byte viay mod 2 + 2is fine —yon the stack comes frompushECCoord(ec_ops.go:143) so it's a canonical non-negative BigNum andOP_MOD's "sign follows dividend" (opcode.go:2414) can't produce a negative. ✓ - Digest → scalar. Confirmed the off-chain
dleqScriptChallenge(BE reverse →SetBytesas BE = LE reading of digest) matches in-scriptSHA256 → CAT 0x00 → BIN2NUM(sign-magnitude LE with0x00forcing positive). The0x00byte is load-bearing: without it a digest with top bit set would decode negative andMOD nwould produce a negative remainder. Nicely handled. ✓ - Soundness of the "different secret" case. The proof
(e2, s2)is self-consistent fora2, C2but the script's preimage still commits to the baked-inA, C', sos2*G + (n−e2)*A ≠ R1unlesse2*(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)
dleq_test.go:68—r.SetBytes(&nonce)discards the reduction-occurred return. Cosmetic; fine for a test with fixed seeds.- Script suite has no "wrong A" test because
Ais 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. - Neither test hits the extreme
e = n−1/ large-sboundary. The deterministic seeds produce arbitrary values in-range, so the code paths that only fire one ≥ nore = 0(from the argument above) are covered only by construction, not by direct assertion. At.Runthat forces those witnesses would document the guard explicitly. Optional. - Two independently-implemented challenge helpers (
dleqChallengeindleq_test.goreads BE,dleqScriptChallengeindleq_script_test.goreads LE). Intentional and each is self-consistent, but if someone ever moves code between files that difference will silently break things — the comment ondleqScriptChallengealready flags it; consider a matching comment ondleqChallengepointing 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.
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
alinks:A = a*G(the public key)C' = a*B'(the signature over Alice's blinded messageB')If accepted, the
ainA = a*Gequals theainC' = 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 GoProve/verify helpers plus the test, built on the existing
btcec/v2point/scalar primitives already used intweak.go(ScalarBaseMultNonConst,ScalarMultNonConst,AddNonConst,ModNScalar).2.
pkg/arkade/dleq_script_test.go— verification inside an Arkade ScriptRuns 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:R1 = s*G - e*AandR2 = s*B' - e*C'withOP_ECMUL/OP_ECADD, using(n-e)*Pfor the subtraction (there is no point-negation opcode);(x, y)coordinates (OP_NUM2BIN/OP_REVERSEBYTES/OP_RIGHT/OP_CAT), which also bindsA/C'between the EC equations and the hash;OP_SHA256+OP_BIN2NUM+OP_MOD n, appending a synthesized0x00byte so the little-endian digest is never read as negative;e.Test cases
dleq_test.godleq_script_test.goAC'/ different secretseThe "different secret" case in the script test supplies a fully self-consistent
(e, s)for a differenta; it is rejected purely by the in-script hash binding to the script'sAandC', demonstrating soundness rather than blanket acceptance.Test plan
All subtests pass;
go vet ./...andgofmtare clean.