[Feat] Add freezelist read and Merkle exclusion proof construction - #125
[Feat] Add freezelist read and Merkle exclusion proof construction#125iamalwaysuncomfortable wants to merge 6 commits into
Conversation
Compliance-gated Shield Swap transitions (mint, collect, claim_swap_output) take Merkle non-inclusion proofs against a freezelist. Until now the SDK only shipped the canonical empty-tree witness, which works while every list is empty and stops working the moment one is populated. veil-core gains `getFreezeList`, a plain REST read of a program's freezelist. The endpoint returns the whole tree — leaf row first, root last — so a proof needs no hashing to assemble, only indexing. Core stays free of any SDK dependency and of any policy: a program that tracks no list answers 404 and the transport raises, which is deliberately distinct from an empty list's two-leaf tree. veil-aleo-sdk gains an actions folder with `buildExclusionProof`, wrapping SealanceMerkleTree and closing three gaps in it: - sibling arrays are cut at width 16, matching the deployed [field; 16] struct, rather than the library's documented 15 - the bracket lookup is a binary search over the sorted leaf row instead of a linear scan - an address that is itself on the list raises FrozenAddressError rather than returning a proof the verifier is certain to reject after the fee is paid `prepareFreezeList` exposes the decode boundary. Decoding a full 32768-leaf tree parses 65535 decimal strings at roughly 5.8ms, and a single mint proves three addresses against one list, so callers cutting several proofs decode once and pass the result. Verified against the deployed contracts: the empty-list root computed here matches the constant shield_swap_freezelist.aleo writes in initialize, and on an empty list buildExclusionProof reproduces the existing EMPTY_MERKLE_PROOFS witness byte for byte. No consumer is wired up yet — the DEX actions keep their current behaviour.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Every existing devnode suite mints against an empty freezelist, where the canonical all-zero witness is accepted. Nothing exercised a populated list, so buildExclusionProof had never been checked against the verifier that actually runs on chain. This adds a devnode fixture that deploys the DEPLOYED TESTNET BYTECODE rather than compiling this repo's Leo sources, so the Merkle verifier under test is byte-for-byte the network's. It mirrors six programs — the two multisig cores, the freezelist, shield_swap, and the two plain ARC-20s (test_arc20_usdc, test_arc20_eth) whose amm_token_program equals their underlying_program and so settle directly with no router. The deployed constructors name Provable's deployer, whose key no local chain holds: shield_swap_freezelist asserts it as program_owner and again in initialize, shield_swap writes it into admin[true], and each token gates minting on it. The fixture repoints exactly those occurrences at the devnode's funded genesis account and asserts the occurrence count per program, so an upstream redeploy that moves an authority fails loudly instead of producing a stack nobody can drive. No other instruction is touched. The suite freezes three generated addresses, then: - asserts the client-computed root matches freeze_list_root on chain, across three successive updates — update_freeze_list asserts the supplied old root equals the stored one, so the chain validates every intermediate root - asserts freeze_list_index and freeze_list track each entry - mints with a witness built by buildExclusionProof against the resulting four-leaf tree, and asserts the transaction is accepted - asserts a listed address raises FrozenAddressError rather than yielding a witness Acceptance of that mint is the real assertion: the AMM recomputed the root from the supplied path, matched it against the freezelist mapping, and cleared the bracket and adjacency checks. No shield-swap-sdk signatures or plumbing changed — the fixture drives the existing actions and injects proofs through the ProofProvider seam already there.
|
Added the devnode coverage on the same branch, since it validates exactly what this PR introduces. Deployed bytecode, not this repo's sources
Bytecode is cached under the OS temp dir, so only the first run needs network. The one modification, and whyThe deployed constructors name Provable's deployer, whose key no local chain holds:
The fixture repoints exactly those at the devnode's funded genesis account and asserts the occurrence count per program, so an upstream redeploy that moves or adds an authority fails loudly rather than yielding a stack nobody can drive. Nothing else is touched. What the suite provesThree generated addresses are frozen, then:
Gated behind NotesNo One thing I left alone deliberately: |
There was a problem hiding this comment.
Pull request overview
Adds the missing SDK building blocks for compliance-gated Shield Swap transitions that require Merkle non-inclusion proofs against a program freezelist, without yet wiring this into DEX actions (so runtime behavior stays unchanged for now).
Changes:
- Add
veil-corepublic action + HTTP transport routing forgetFreezeList(reads/programs/{program}/compliance/freeze-list) and corresponding tests. - Add
veil-aleo-sdk/provable-sdk utilities to prepare a served freezelist and construct ABI-shaped[MerkleProof; 2]exclusion proofs (including rejecting listed addresses), with unit tests. - Add a devnode integration fixture + e2e test that deploys deployed testnet bytecode locally and validates proofs against a populated list.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shield-swap/test/integration/devnodeNetworkStack.ts | New devnode fixture that mirrors deployed program sources locally and provides helpers for freezing addresses + mint setup. |
| packages/shield-swap/test/integration/devnodeFreezelistProof.e2e.test.ts | New e2e coverage proving exclusion proofs against a populated freezelist using deployed bytecode on a devnode. |
| packages/provable-sdk/src/actions/buildExclusionProof.ts | New proof construction (prepareFreezeList, buildExclusionProof) and FrozenAddressError to match deployed verifier expectations. |
| packages/provable-sdk/src/index.ts | Export the new exclusion-proof utilities and types from the package entrypoint. |
| packages/provable-sdk/test/actions/buildExclusionProof.test.ts | Unit tests validating bracketing behavior, slot layout/width, empty-tree witness equivalence, and listed-address rejection. |
| packages/core/src/actions/public/getFreezeList.ts | New getFreezeList action returning the flat tree as string[] (leaf row first, root last). |
| packages/core/src/clients/decorators/public.ts | Expose getFreezeList on the public client actions decorator. |
| packages/core/src/index.ts | Export getFreezeList from the core package entrypoint. |
| packages/core/src/transports/http.ts | Route getFreezeList to the compliance endpoint in the HTTP transport URL mapper. |
| packages/core/test/actions/public/getFreezeList.test.ts | Tests for the new getFreezeList action behavior and error surfacing (404 vs empty list). |
| packages/core/test/transports/http.test.ts | Test ensuring HTTP transport routes getFreezeList to the expected REST path. |
Suppressed comments (1)
packages/shield-swap/test/integration/devnodeNetworkStack.ts:95
fetchProgramalways prefers the on-disk cache when present. If the upstream programs are redeployed, the fixture may keep exercising an old snapshot and never notice the change (including authority-count assertions) unless the user manually clears the temp dir. Consider adding an explicit opt-in refresh flag to bypass the cache.
async function fetchProgram(programId: string): Promise<string> {
mkdirSync(CACHE_DIR, { recursive: true })
const cached = join(CACHE_DIR, programId)
if (existsSync(cached)) return readFileSync(cached, 'utf-8')
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| const CACHE_DIR = join(tmpdir(), 'veil-network-programs') | ||
|
|
||
| /** True when the deployed bytecode is cached or the network is reachable. */ |
| [TOKEN_B]: [['aleo1axurgcdhztu8m23ttzju38qzchtzs8kyk7nga9n58zyrmnxzmuqqf6wqdc', 1]], | ||
| } | ||
|
|
||
| const CACHE_DIR = join(tmpdir(), 'veil-network-programs') |
The freezelist contract stores whatever root it is handed and never recomputes one, so the freeze sequence proves only that roots were passed forward consistently — not that they were computed correctly. Until now the sole check of correctness was a single devnode mint against a depth-2 tree, where the verifier's climb loop runs one iteration and the node-layer hashing above level one goes untested. This adds a TypeScript mirror of calculate_merkle_root_and_depth and verify_merkle_non_inclusion from amm-v3 src/main.leo, and runs every proof buildExclusionProof cuts back through it. Hashing a path with the contract's own rules must land on the root the tree was built with — the check nothing else performs. Covered across lists of 1, 2, 3, 5, 7, 8, 9 and 15 addresses (depths 1 to 4), with every non-listed address in a fixed twenty-address pool as a subject, so the bracketed and above-all branches are both exercised. The pool is fixed rather than generated so a failure reproduces. The negative cases give the mirror teeth: a tampered node sibling, a reordered leaf pair, and the leaf/node domain separators must all diverge from the real root. A further case reconstructs the proof SealanceMerkleTree returns for a listed address and shows the exact assertion it fails — root and depth agree, and the range check compares the address against its own leaf. The devnode suite moves from three frozen addresses to five, so the tree is depth 3 and the climb loop runs twice on chain. That suite is what makes the mirror trustworthy: it confirms the mirror agrees with the deployed verifier on a populated list.
Correction to my earlier commentI claimed the freeze sequence validated root computation. It doesn't, and I've fixed the claim.
What actually validated it was the mint: That left a genuine gap: one mint against a depth-2 tree, where the climb loop runs a single iteration and the node-layer hashing and index-bit ordering above level one were never exercised. Mirror of the on-chain verifier
Coverage, in ~400 ms:
The pool is fixed rather than generated so a failure reproduces. Negative cases, so the mirror has teeth — without these it could pass vacuously:
That last one is the defect this PR guards against, now demonstrated against the contract's own logic rather than asserted. Devnode deepenedThree frozen addresses → five, so the tree is depth 3 and the climb loop runs twice on chain. The proof now asserts slots 0–3 carry the path and slot 4 terminates it. The division of labour: the devnode suite proves the mirror is faithful to the deployed verifier on one populated list; the mirror then proves breadth across depths and branches that would be impractical to drive on chain. Full suite: 1509 passed / 234 skipped. |
The devnode suite only exercised addresses absent from the freezelist. The frozen case was asserted client-side, where buildExclusionProof throws, and never reached the contract — so nothing showed that the client-side refusal corresponds to a real on-chain failure. Non-inclusion is asserted in the transition body rather than finalize, so the two failure modes land in different places and both are now covered: - a mint naming a frozen recipient, carrying the unsatisfiable witness SealanceMerkleTree hands back for a listed address, cannot be executed at all. The constraint system rejects it locally, so a frozen party never produces a transaction to broadcast. - a mint carrying the all-zero empty-list witness proves fine — it rebuilds the empty tree's root and clears the range checks — and is then rejected in finalize, where the root is neither current nor the previous one the grace window holds open. This is precisely what stops working once a list is populated, and the reason this work exists. Also corrects the depth assertion, which the run caught. Five addresses pad to eight leaves, so three 0field pads precede them and a subject sorting below every real entry legitimately brackets against a pad — slot 0 being 0field is not evidence of an empty witness. Depth is now derived the way the verifier derives it: the first 0field at slot 2 or beyond terminates the path.
…ction
buildExclusionProof was a standalone function, so a caller had to read the list
through the client and then thread the tree into a separate call. This adds the
extend() decorator the rest of the SDK uses, pairing core's getFreezeList with
local proof construction so a program id and an address are enough:
const client = publicClient.extend(
freezelistActions({ program: 'shield_swap_freezelist.aleo' }),
)
const proofs = await client.getExclusionProof({ address: account.address })
A per-call `program` overrides the decorator's default, which matters because
each compliance-gated program keeps its own list — an AMM and every ARC-22
wrapper it settles through are separate reads. Omitting both raises an
actionable error rather than reading an empty program id.
`getFreezeListTree` returns the decoded PreparedFreezeList so several proofs
share one read: a position mint proves a signer, a recipient and a withdrawal
address against the same list, and decoding a large tree costs milliseconds
against microseconds to cut a path.
The actions deliberately hold no cache. A list reshapes whenever its address
count crosses a power of two, changing both root and depth and voiding every
outstanding proof, so a cache has to be keyed on the root and revalidated
rather than held for a client's lifetime. That policy belongs with the DEX
wiring, not in a low-level SDK.
The decorator carried a default program with a per-call override. That encodes
a wrong model: compliance-gated operations routinely span several lists, so no
single one belongs to the client. Minting a position against a wrapped pair
proves the parties against the AMM's freezelist and the sender against each
wrapper's — internal.ts already resolves both SHIELD_SWAP_FREEZELIST and
route.wrapperProgram within one call.
`program` is now required on every action and the config is gone, which also
drops the factory: `freezelistActions` is a plain decorator taking the client,
matching core's `publicActions`.
publicClient.extend(freezelistActions)
await client.getExclusionProof({
program: 'shield_swap_freezelist.aleo',
address: account.address,
})
Requiring the program also removes the missing-program error path — the type
now rules it out.
Compliance-gated Shield Swap transitions —
mint,collect,claim_swap_output— take Merkle non-inclusion proofs against a freezelist. The SDK currently only ships the canonical empty-tree witness. That works while every list is empty, and stops working the moment one is populated.This adds the two pieces needed to build real proofs. Nothing is wired into the DEX actions yet, so behaviour is unchanged.
veil-core—getFreezeListA plain REST read of
/programs/{program}/compliance/freeze-list. The endpoint returns the whole tree, leaf row first and root last, which means a proof can be assembled by indexing alone with no hashing on the client.Core keeps no SDK dependency and no policy. A program that tracks no freezelist answers 404 and the transport raises — deliberately distinct from an empty list, which returns a two-leaf tree. Collapsing those two into one answer would lose information the caller needs.
The response is returned unparsed as
string[]; converting tobigintis the consumer's choice.veil-aleo-sdk—actions/buildExclusionProofWraps
SealanceMerkleTreefrom@provablehq/sdkand closes three gaps against the deployed ABI:MerkleProof.siblings: [field; 16]FrozenAddressErrorThe third is the one that costs money.
getLeafIndicesmatches with<=, so an address that is on the list comes back bracketed by its own leaf. The contract then evaluatesassert(value < proofs[1].siblings[0])against the value itself, and the caller learns they are frozen only when the transaction is rejected — after paying the fee.prepareFreezeListexposes the decode boundary. Decoding a full 32 768-leaf tree parses 65 535 decimal strings at roughly 5.8 ms, against ~1.2 µs to actually cut a path. A singlemintproves three addresses against one list, so callers cutting several proofs decode once and pass the result. It also carries the root, which is the natural cache key — the contract asserts the root changes on every list update.Verification against the deployed contracts
shield_swap_freezelist.aleowrites ininitialize(3642…853field), and matches what testnet serves today.buildExclusionProofreproduces the existingEMPTY_MERKLE_PROOFSwitness byte for byte — so switching the DEX over later is provably behaviour-preserving while lists stay empty.SealanceMerkleTree.convertAddressToFieldwas checked against the nativeAddress.toField()cast across random addresses; the contract comparesaccount as field, so the two must agree.Not in this PR
MerkleProofInputis declared here rather than shared withshield-swap/src/utils/proofs.ts. The shapes are structurally identical and therefore assignable; unifying them means deciding where the type lives, which is better settled when the DEX is actually wired up.