Skip to content

feat(sdk): Merkle exclusion proofs for the freezelist - #68

Open
iamalwaysuncomfortable wants to merge 3 commits into
feat/shield-swap-updatefrom
feat/merkle-exclusion-proofs
Open

feat(sdk): Merkle exclusion proofs for the freezelist#68
iamalwaysuncomfortable wants to merge 3 commits into
feat/shield-swap-updatefrom
feat/merkle-exclusion-proofs

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

Ports the wasm SDK's SealanceMerkleTree to Python as a program-agnostic MerkleExclusionProof, adds get_freeze_list to 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 ShieldSwap methods is a follow-up — no client signatures or plumbing changed here, which is why the devnode test assembles mint inputs itself.

Why now

shield_swap.aleo already requires these proofs on testnet: mint (signer, recipient, withdrawal), collect (owner, withdrawal), and claim_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, SealanceMerkleTree emits proofs the contract rejects. Each fix was checked against verify_merkle_non_inclusion in the deployed bytecode:

Defect Consequence Fix
getSiblingPath pads with while (level < depth) 15 siblings at every depth below the maximum, 16 only at depth 15; the struct is [field; 16] pad to max_depth + 1
getLeafIndices brackets with <= a frozen address yields a proof that fails the verifier's strict inequality, after the caller paid to prove and broadcast raise instead
maxNumLeaves = 2 ** (depth - 1) rejects valid lists at half the contract's own cap 2 ** max_depth

Two 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_index mapping is address-keyed. The domain-separator selection is correct as written.

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.

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.hash reproduces the on-chain empty root exactly. to_fields_raw() does not, and neither does hashing a bare list[Field].

Evidence

  • Unitverify_merkle_non_inclusion is transcribed from amm-v3 as 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.
  • Liveget_freeze_list served-root matches the on-chain freeze_list_root[1u8] for shield_swap_freezelist, test_usad_freezelist, and test_usdcx_freezelist.
  • Devnode — the network's 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 a mint lands with real proofs.

test_placeholder_proof_is_rejected is 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 above 0field, making it a genuine non-inclusion proof for an empty tree. What stops it is the finalize's assert_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_amm fixture too, so both fixtures now read the required amount out of the rejection and retry.

Verification

suite result
core SDK unit 942 passed
shield-swap-sdk unit 237 passed, 8 skipped
test_devnode_merkle 7 passed
test_devnode_lifecycle (pre-existing) 11 passed
pyright 0 errors
codegen staleness gate exit 0

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.
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.

1 participant