Skip to content

[Feat] Add freezelist read and Merkle exclusion proof construction - #125

Open
iamalwaysuncomfortable wants to merge 6 commits into
mainfrom
feat/freezelist-exclusion-proofs
Open

[Feat] Add freezelist read and Merkle exclusion proof construction#125
iamalwaysuncomfortable wants to merge 6 commits into
mainfrom
feat/freezelist-exclusion-proofs

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

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

A 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 to bigint is the consumer's choice.

veil-aleo-sdkactions/buildExclusionProof

Wraps SealanceMerkleTree from @provablehq/sdk and closes three gaps against the deployed ABI:

Gap Library behaviour Here
Sibling width Defaults and documents 15 Cuts at 16, matching MerkleProof.siblings: [field; 16]
Bracket lookup Linear scan of the leaf row Binary search — the row is sorted
Listed address Returns a proof that cannot verify Raises FrozenAddressError

The third is the one that costs money. getLeafIndices matches with <=, so an address that is on the list comes back bracketed by its own leaf. The contract then evaluates assert(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.

prepareFreezeList exposes 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 single mint proves 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

  • The empty-list root computed here matches the constant shield_swap_freezelist.aleo writes in initialize (3642…853field), and matches what testnet serves today.
  • On an empty list, buildExclusionProof reproduces the existing EMPTY_MERKLE_PROOFS witness byte for byte — so switching the DEX over later is provably behaviour-preserving while lists stay empty.
  • SealanceMerkleTree.convertAddressToField was checked against the native Address.toField() cast across random addresses; the contract compares account as field, so the two must agree.
  • Tests are built on a real seven-address tree generated with the SDK, covering all three verifier branches: bracketed between two leaves, below every entry, and above every entry.

Not in this PR

  • Wiring the DEX actions to fetch and build automatically, with caching. The cache wants keying on the root and invalidating when it moves — the tree reshapes whenever the address count crosses a power of two, which kills every outstanding proof.
  • MerkleProofInput is declared here rather than shared with shield-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.
  • Nothing has been reported upstream yet. The width default and the listed-address behaviour affect any consumer of the Sealance library, not just us.

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

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
veil-loyalty-dapp Ready Ready Preview Aug 10, 2026 9:09pm

Request Review

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

Copy link
Copy Markdown
Member Author

Added the devnode coverage on the same branch, since it validates exactly what this PR introduces.

Deployed bytecode, not this repo's sources

devnodeNetworkStack.ts mirrors six programs straight off testnet rather than compiling ~/dev/amm-v3 — the Merkle verifier under test is byte-for-byte the network's:

shield_swap_multisig_core.aleo
shield_swap_freezelist.aleo
shield_swap.aleo                964 KB
test_arc20_multisig_core.aleo
test_arc20_usdc.aleo    ┐ plain ARC-20s: amm_token_program == underlying_program,
test_arc20_eth.aleo     ┘ so they settle directly with no router

Bytecode is cached under the OS temp dir, so only the first run needs network.

The one modification, and why

The deployed constructors name Provable's deployer, whose key no local chain holds:

Program Occurrences What it blocks
shield_swap_freezelist.aleo 2 constructor asserts program_owner; initialize asserts the caller
shield_swap.aleo 1 constructor writes admin[true]
each ARC-20 1 initialize_token gate

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 proves

Three generated addresses are frozen, then:

  • The client-computed root matches freeze_list_root on chain, across three successive updates. This is stronger than a single comparison — update_freeze_list asserts the supplied old root equals the stored one, so the chain validates every intermediate root as a precondition. A mismatch anywhere would have rejected the transaction.
  • freeze_list_index and freeze_list track each entry.
  • A mint with a witness from buildExclusionProof against the resulting four-leaf tree is accepted. Acceptance is the assertion: the AMM recomputed the root from the supplied path, matched it against the freezelist mapping, and cleared the bracket and adjacency checks.
  • A listed address raises FrozenAddressError instead of yielding a witness.
✓ records the client-computed root on chain
✓ tracks every frozen address in the contract index
✓ mints with a real non-inclusion witness against the populated list   9300ms
✓ refuses to build a witness for an address on the list
+ 4 pure tests for the bytecode rewriting and tree shaping
Tests  8 passed (8)   49s

Gated behind VEIL_DEVNODE_INTEGRATION=1; the four pure tests run unconditionally. Full suite is 1496 passed / 234 skipped.

Notes

No shield-swap-sdk signatures or plumbing changed — the fixture drives the existing actions and injects proofs through the ProofProvider seam that was already there.

One thing I left alone deliberately: devnodeNetworkStack.ts re-implements waitAccepted, waitQueryable and write from devnodeAmm.ts rather than extracting them. Sharing would mean editing a fixture three other e2e suites depend on, and those need an amm-v3 checkout I can't exercise here — not worth the risk for ~40 lines.

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

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-core public action + HTTP transport routing for getFreezeList (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

  • fetchProgram always 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.
@iamalwaysuncomfortable

Copy link
Copy Markdown
Member Author

Correction to my earlier comment

I claimed the freeze sequence validated root computation. It doesn't, and I've fixed the claim.

update_freeze_list asserts the supplied old root equals the stored one and then stores whatever new root it is handed — it never recomputes. So that chain proves only that roots were passed forward consistently. A self-consistent sequence of garbage roots would have been accepted just the same.

What actually validated it was the mint: verify_merkle_non_inclusion recomputes the root from the supplied path using the contract's own hashing, and assert_valid_freeze_list_root compares that against the stored value. Two independent implementations agreeing. That was always the real check — I attributed it to the wrong step.

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

exclusionProofVerifier.test.ts reimplements 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 rules must land on the root the tree was built with.

Coverage, in ~400 ms:

Lists 1, 2, 3, 5, 7, 8, 9, 15 addresses
Depths 1 through 4
Subjects every non-listed address in a fixed 20-address pool
Branches bracketed and above-all (below-all is unreachable whenever the leaf row carries a 0field pad)

The pool is fixed rather than generated so a failure reproduces.

Negative cases, so the mirror has teeth — without these it could pass vacuously:

  • a tampered node-layer sibling must diverge from the real root
  • a reordered leaf pair (slots 0/1 swapped without changing leaf_index) must diverge
  • the leaf and node domain separators must produce different hashes on the same pair
  • the proof SealanceMerkleTree returns for a listed address is reconstructed and run through the verifier, showing the precise failure: root and depth agree, and value < proofs[1].siblings[0] compares the address against its own leaf

That last one is the defect this PR guards against, now demonstrated against the contract's own logic rather than asserted.

Devnode deepened

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

✓ mints with a real non-inclusion witness against the populated list   10406ms
Tests  8 passed (8)   58s

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