feat(sdk): Merkle exclusion proofs for the freezelist - #68
Open
iamalwaysuncomfortable wants to merge 3 commits into
Open
feat(sdk): Merkle exclusion proofs for the freezelist#68iamalwaysuncomfortable wants to merge 3 commits into
iamalwaysuncomfortable wants to merge 3 commits into
Conversation
Port the wasm SDK's SealanceMerkleTree to Python as a program-agnostic MerkleExclusionProof, and add get_freeze_list to both network clients. Three deviations from the reference were fixed, each verified against the deployed shield_swap.aleo verifier: - getSiblingPath pads with `while (level < depth)`, which yields 15 siblings for every tree below the maximum and 16 only at depth 15. The struct is [field; 16], so a short path is rejected. Paths now fill max_depth + 1. - getLeafIndices brackets with `<=`, handing back indices whose proof fails the verifier's strict inequality — but only after the caller has paid to prove and broadcast. A member now raises instead. - maxNumLeaves was 2**(depth-1), half the contract's own cap. Now 2**depth. Leaf deduplication and the domain-separator selection are left as the reference has them: deduping client-side could produce a root disagreeing with the chain's, and the tree served by the endpoint is authoritative anyway. max_depth is the single configurable knob, mirroring SealanceMerkleTree.maxTreeDepth: capacity is 2**max_depth and a proof carries max_depth + 1 siblings, so the two cannot drift apart. Tests transcribe verify_merkle_non_inclusion from amm-v3 as an independent oracle that hashes via its own Poseidon4 calls, and assert it accepts every generated proof across all three verifier cases and every padding shape. The empty-tree root is pinned to shield_swap_freezelist.aleo's live root.
Rebuilt both network extensions. Core SDK 942 pass, shield-swap-sdk 237 pass, 8 skipped. Devnode binary updated separately to v0.2.3 (prebuilt release; the source build needs libclang for rocksdb-sys).
Deploys shield_swap.aleo, shield_swap_freezelist.aleo, their multisig cores, and two plain ARC-20s onto a devnode — all fetched live from the node API rather than from repo fixtures, since the deployed programs are the authoritative statement of what the verifier accepts and a vendored copy can drift silently. Baked administrator literals are repointed to a genesis account so the stack can be configured locally. The freeze list is then populated and a mint runs, which is the transition requiring three separate non-inclusion proofs. Nothing on chain recomputes the root — the manager supplies it — so the test asserts the root the contract stores is the one MerkleExclusionProof computes, then proves against it. test_placeholder_proof_is_rejected is the control. The all-zero literal the SDK ships today actually clears the circuit: two depth-1 all-zero paths reconstruct the empty-tree root and every real address sorts above 0field, so it is a genuine non-inclusion proof for an empty tree. What stops it is the finalize's assert_valid_freeze_list_root once the list has entries. Without that control a passing mint would be evidence about the list, not the proofs. Also fixes devnode deployment fees. The devnode bundles its own snarkVM, which stopped agreeing with the bindings on deployment pricing at devnode 0.2.3 / snarkVM 4.9.0 — deployments were short by ~2.5% and rejected. This broke the existing devnode_amm fixture too. A rejected base fee names the required amount, so both fixtures now retry at that figure rather than carrying a guessed margin. No shield-swap-sdk signatures or plumbing changed: mint inputs are assembled directly in the test because the shipped method hardcodes the placeholder proof and takes no parameter to override it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports the wasm SDK's
SealanceMerkleTreeto Python as a program-agnosticMerkleExclusionProof, addsget_freeze_listto both network clients, and proves the result against the deployed AMM on a devnode.Scope is deliberately the library plus the endpoint. Wiring proofs into the
ShieldSwapmethods is a follow-up — no client signatures or plumbing changed here, which is why the devnode test assemblesmintinputs itself.Why now
shield_swap.aleoalready requires these proofs on testnet:mint(signer, recipient, withdrawal),collect(owner, withdrawal), andclaim_swap_output(signer). The SDK satisfies them with_core.py's all-zero placeholder, which works only because all three live freezelists are still empty. The first address added to any of them breaks every mint, collect, and claim the SDK issues.Defects fixed in the reference implementation
Ported verbatim,
SealanceMerkleTreeemits proofs the contract rejects. Each fix was checked againstverify_merkle_non_inclusionin the deployed bytecode:getSiblingPathpads withwhile (level < depth)[field; 16]max_depth + 1getLeafIndicesbrackets with<=maxNumLeaves = 2 ** (depth - 1)2 ** max_depthTwo further observations were left alone as deviations from a reference we don't control. Client-side leaf deduplication could produce a root disagreeing with the chain's, which is worse than matching the reference, and is unreachable anyway — the served tree is authoritative and the contract's
freeze_list_indexmapping is address-keyed. The domain-separator selection is correct as written.max_depthis the single configurable knob, mirroringSealanceMerkleTree.maxTreeDepth: capacity is2 ** max_depthand a proof carriesmax_depth + 1siblings, so the two cannot drift apart.Verified, not assumed
Both halves of the algorithm were checked against the live chain before any code was written:
Address.to_field()is bit-identical to the TypeScript bech32m little-endian decode, matching on the reference's own docstring examples. No bech32 dependency needed.Plaintext.from_string("[a,b,c]").to_fields()+Poseidon4.hashreproduces the on-chain empty root exactly.to_fields_raw()does not, and neither does hashing a barelist[Field].Evidence
verify_merkle_non_inclusionis transcribed fromamm-v3as an oracle that hashes via its own Poseidon4 calls, so it can disagree with the implementation. It accepts every generated proof across all three verifier cases and every padding shape from 0 to 9 members. 52 tests.get_freeze_listserved-root matches the on-chainfreeze_list_root[1u8]forshield_swap_freezelist,test_usad_freezelist, andtest_usdcx_freezelist.shield_swap.aleo, freezelist, multisig cores, and two plain ARC-20s are fetched from the node API (not repo fixtures — the deployed programs are authoritative), deployed, the freezelist populated, and amintlands with real proofs.test_placeholder_proof_is_rejectedis the control. The all-zero literal actually clears the circuit — two depth-1 all-zero paths reconstruct the empty-tree root and every real address sorts above0field, making it a genuine non-inclusion proof for an empty tree. What stops it is the finalize'sassert_valid_freeze_list_root. Without that control, a passing mint would be evidence about the freezelist rather than about the proofs.Also here
snarkVM v4.8.1 → v4.9.0, and the devnode binary to v0.2.3. That combination broke devnode deployments: the devnode bundles its own snarkVM and stopped agreeing with the bindings on pricing, leaving deployments ~2.5% short. This broke the existing
devnode_ammfixture too, so both fixtures now read the required amount out of the rejection and retry.Verification
test_devnode_merkletest_devnode_lifecycle(pre-existing)