
This document is the narrative companion to alberta-buck-notes.org (architectural
overview), alberta-buck-proofs.org (formal correctness), and alberta-buck-ethereum.org
(Solidity surface). It walks one concrete example – Alice mints a 1,200-BUCK transfer
into six notes of denominations [1000, 100, 25, 25, 25, 25] – end-to-end through the
Phase-7-bis SNARK-verified batch-mint pipeline, and then walks the inverse flow:
Bob redeems his 1,000-BUCK note via the Phase-7 spend circuit, receives 1,000 BUCK
from the pool via a demurrage-carrying transfer, and leaves no chain-observable link
back to cm_0 (the specific commitment he opened). Every actor, every artifact, and
every check is named.
The mint pipeline is in the middle of a planned pivot away from the original Phase-7
design (per-leaf on-chain Poseidon insertion at ~800K gas/leaf, capped by the block
limit at N <= ~30 in practice). In the new design the per-leaf Merkle-tree
insertions are pushed into the mint SNARK: the circuit takes oldRoot as a public
input, folds N commitments into the rolling tree using witness sibling paths, and
emits a SNARK-attested newRoot for the contract to record. On-chain mint cost
becomes essentially constant in N – ~380K plus ~500 gas per leaf of calldata –
making honest single-call batches of 16, 128, or 1024 notes practical. The full
design memo, including R1CS counts, gas projections, and a per-attack-vector
hostile-Alice analysis, is alberta-buck-notes-rollup-mint.org.
The Phase-7 spend pipeline shipped in the bearer-shape (spend.circom,
SpendGroth16Verifier, Notes.spend) and is exercised end-to-end in
test/SpendVerifier.t.sol. The A-flavor pipeline (Phase 8) ships in two layers,
both in tree. V1 added the addressed-spend scaffolding (spend_a.circom – A-tag
nullifier, flavor in {A1, A2}) without the cryptographic identity gate. V2 ships
the identity binding via two cooperating components glued by the new
Notes.spendACP entrypoint:
- In-circuit:
spend_a.circomV2 publishesE_n(the spender's note ciphertext, as four BN254 G1 affine coordinates) and enforces a Poseidon-8 binding gate that pinsE_nto the leaf at mint time (Poseidon-8(E_n.R.x, E_n.R.y, E_n.C.x, E_n.C.y, issuerData[0..3]) === idHash). This costs ~265 R1CS and stops a spender from substituting a differentE_njust to make the off-chain identity check pass with their own key. - Off-chain: a 4-element Chaum-Pedersen DLEQ proof
(e, s, T1, T2)verified insideIdentityRegistry.verifySpendCPvia the EIP-196 BN254 precompiles (~36K gas) – not inside the SNARK, since the DLEQ statement requires native BN254 G1 arithmetic that is prohibitively expensive in non-native circom (~+250K R1CS). The verifier reads the spender's registered(pk_dep, E_reg)from storage and confirms a singlesk_depsatisfiespk_dep === sk_dep * Gand the equal-plaintext relation betweenE_nandE_reg.
Notes.spendACP(proof, root, nullifier, face, recipient, E_n, cpProof) calls
both the SpendA Groth16 verifier (which checks the binding gate) and
IdentityRegistry.verifySpendCP (which checks the DLEQ); both must succeed for
the pool to call buck.transferCarrying(recipient, face). All A1/A2 identity,
security, and privacy properties remain in place; only the DLEQ verification
location moved. Exercised end-to-end by 26 forge tests in
test/SpendAVerifier.t.sol. The B-spend path is unchanged by the mint pivot:
it already takes cm as a private witness under a public noteRoot, and the
rolling-tree-update trick is contained to mint.
The aim is to show, by walking the path, that the system delivers five concrete guarantees:
- Atomic conservation – if the transaction succeeds, the BUCK balance leaves Alice exactly once and matches the public total of the new notes. If the proof fails, no BUCK moves and no commitments are appended.
- Issuer-bound, recipient-blinded artifacts – the public commitments name the issuer
(
msg.sender) but reveal nothing about per-note values or recipients. - Per-note disclosure to recipients only – each recipient receives an opening
off-chain that lets them reconstruct exactly their own note's value and the issuer's
verifiable identity, plus the fact that N siblings exist whose hidden values sum to
totalFace– and nothing more. - Offline receipt reconstruction – given only the mint transaction ID, anyone holding an opening can independently verify the note against the chain, with no help from Alice or any indexer.
- Adversarial-prover soundness – even if Alice runs a maliciously patched EVM and an
arbitrarily hostile witness generator, the honest validators executing the deployed
Notes.solandMintGroth16Verifier.solreject every batch that does not satisfy the mint relation. The on-chain truth is what governs.
The remaining caveats (the pinned-N dispatch set under mint_batch.circom, and
the V2 wiring through Notes.spendACP + IdentityRegistry.verifySpendCP) are
addressed at the end and cross-referenced into the proofs and implementation
docs. The mint-circuit field-overflow concern from earlier drafts has been
closed by the Num2Bits(128) range bounds in mint_batch.circom and the
matching bounds in spend.circom. The A-flavor in-circuit identity binding
gap that earlier drafts flagged as deferred is now closed by Phase 8 V2:
the in-circuit Poseidon-8 binding gate pins E_n to the leaf, and the
off-chain CP-DLEQ (IdentityRegistry.verifySpendCP, called from
Notes.spendACP) verifies that the spender's sk_dep decrypts both E_n
and E_reg to the same identity point M.
Cast of Characters
The example uses concrete names so the message flow is easy to track:
- Alice – the issuer. She holds 1,200 BUCK and wants to break it into six notes. She
is registered with the IdentityRegistry so that her
msg.senderaddress resolves to a cryptographically attested identity. - Bob, Carol, Dave, Eve, Frank, Grace – six recipients. Bob will get the 1,000-BUCK note; Carol the 100-BUCK note; Dave/Eve/Frank/Grace one 25-BUCK note each. Recipients do not need to be on-chain known to Alice – the recipient identity binding is a field inside the commitment, not an on-chain registration step at mint.
- The honest EVM – every Ethereum validator running stock client software. This is
the only EVM whose verdict matters. It executes the deployed
Notes.sol,MintGroth16Verifier.sol,MintVerifierAdapter.sol, andBuck.solbytecode. - Alice's hostile EVM (Eve-EVM) – a fictional entity Alice can run locally. She may patch any contract logic she wants, run any witness generator, and produce any proof bytes. The point of the design is that nothing she runs locally affects what the honest EVM accepts.
- A passive observer (Mallory-the-watcher) – someone scanning the public chain after the fact, trying to learn which note went to whom for how much. Mallory has full chain history and unlimited compute.
The Promise
By the end of the mint transaction Alice will have:
-
A single Ethereum transaction
T_minton-chain whose effects are:- 1,200 BUCK debited from Alice and credited to the
Notespool address. - A batch of
N*opaqueuint256commitments (hereN* = 16: six live plus ten dummies) folded into the note Merkle tree, moving the root fromoldRootto the SNARK-attestednewRoot, and the tree size fromnextLeafIndextonextLeafIndex + N*. - One
Minted(issuer=Alice, totalFace=1200e18, startIndex=K, count=N*, newRoot)event; no per-leafAppendedevents – the commitments themselves live in the transaction calldata. - One
Transfer(Alice, Notes, 1200)ERC-20 event. - One Groth16 proof (256 bytes, shape-independent of N) in calldata, accepted by the on-chain per-N verifier.
- 1,200 BUCK debited from Alice and credited to the
- Six off-chain artifacts, one per live recipient, each containing the opening of exactly one commitment plus the mint-transaction reference. The ten dummy openings are discarded or retained privately by Alice as anonymity- set filler.
The key invariant: looking at the chain alone, Mallory sees that Alice escrowed
1,200 BUCK and folded N* hashes into the tree. She cannot tell which hash
hides which value, who any recipient is, or even whether two of the recipients
are the same person – or which entries are live notes vs. zero-value padding.
Looking at his off-chain artifact, Bob sees his 1,000 BUCK is real, was minted
by Alice in T_mint, and that the live siblings among the other fifteen have
hidden values summing to exactly 200 BUCK (the rest are v = 0 padding he
cannot distinguish from live notes by chain inspection alone) – but he does not
learn what those values are or who holds them.
What's Inside a Note (Conceptually)
A "note" is just a five-tuple known by Alice and the recipient:
opening_i = ( flavor_i, v_i, rho_i, idHash_i, predicate_i )
cm_i = Poseidon5( flavor_i, v_i, rho_i, idHash_i, predicate_i )flavor_i– which note variant (A1 identified cheque, A2 private cheque, B1 bearer cheque); see Notes for the menu.v_i– the face value in BUCK base units (18-decimal scaled integer).rho_i– a fresh, uniformly random nonce. This is the source of unlinkability: swappingrho_ichangescm_ito a uniformly fresh hash even if every other field is identical.idHash_i– a Poseidon-hashed handle on the recipient's identity (e.g. a Poseidon-hash of the recipient's registered ElGamal credential, or zero for a pure bearer note).predicate_i– a domain-separating tag for any optional spending condition (escrow, time-lock, etc.). Zero in the simplest case.
The Poseidon-5 hash gives the commitment two cryptographic properties that make the whole flow work:
- Hiding. Without
rho_i, the commitment leaks no information about the other fields. Mallory seescm_iand gains nothing aboutv_i,idHash_i, orflavor_i. - Binding. No prover, hostile or honest, can find two distinct openings hashing to
the same
cm_i(collision-resistance of Poseidon on BN254, modelled as a random oracle in alberta-buck-proofs.org Part IV).
The on-chain contract stores only cm_i. Openings live exclusively off-chain.
The Mint, Step by Step
Step 0: Pre-flight (Off-Chain, In Alice's Wallet)
Alice's wallet has done all the slow work before any transaction is broadcast:
- She owns 1,200 BUCK on-chain.
- She has decided on the denomination split:
[1000, 100, 25, 25, 25, 25]. N=6. The deployed mint pipeline pins a small set of batch sizes – recommended {16, 128, 1024} – one circom circuit and one verifier per pin. Alice's wallet rounds her requested split up to the smallest pinN* >= 6, hereN* = 16, and pads with16 - 6 = 10"blinded zero" openings whosev_i = 0. The padding leaves cost the same SNARK constraints as live notes but contribute zero tototalFaceand are spendable for zero BUCK; in effect they are anonymity-set filler whose openings Alice may discard. The narrative below tracks the six live notes and ignores the ten dummies (they show up in the on-chain commitments[] vector alongside the live ones, indistinguishable to Mallory). - She has chosen an
idHash_ifor each recipient (typically a Poseidon hash of the recipient's IdentityRegistry-bound ElGamal credential, derived once at out-of-band introduction time). - Her CSPRNG produces a fresh
rho_inonce per leaf – live or padded.
She now has 16 opening tuples in memory: 6 live, 10 dummies.
Step 1: Build Commitments
For i = 0..15 (six live, ten dummies) her wallet computes
cm_i = Poseidon5( flavor_i, v_i, rho_i, idHash_i, predicate_i )
This is pure off-chain Poseidon arithmetic in alberta_buck.wallet.notes. Sixteen
uint256 field elements drop out. Their order in the eventual on-chain array is fixed
by Alice's local list – the contract preserves whatever order she submits, and the
SNARK assumes leaves land in the tree at indices [nextLeafIndex, nextLeafIndex+1,
..., nextLeafIndex+N-1] in submission order.
Alice's wallet must also keep a local mirror of the rolling tree state. The
Tornado-style Merkle tree the contract maintains is parameterised by the standard
filledSubtrees[d] for d = 0..TREE_DEPTH-1 – the rightmost partially-built node
at each level. The wallet snapshots this state from the chain (or replays it from
the mint event log) immediately before assembling the proof, then derives, for each
of the 16 leaves in submission order, the depth-20 sibling sequence the in-circuit
insertion will consume. That derivation is the only delicate piece of new wallet
code; everything else is unchanged from Phase-7.
Step 2: Generate the Batch-Mint Groth16 Proof
Alice's wallet runs scripts/snark/prove_mint_batch.js (the Phase-7-bis successor
to Phase-7's prove_mint.js). The mint_batch.circom circuit at her chosen pin
N* = 16 takes a substantially richer witness than the Phase-7 mint did, because
the Merkle-tree update has moved into the SNARK.
Public inputs (5 entries, N-independent):
oldRoot # the live note root she read from chain
newRoot # the root after folding her N leaves
nextLeafIndex # the live tree size she read from chain
totalFace # 1200e18 -- range-bounded to 128 bits
cmBatchHash # H(cm_0, cm_1, ..., cm_{N-1}) -- ties calldata to the SNARK
The hash H(...) is keccak256 (cheap on-chain; the contract recomputes it from
calldata and asserts equality with the SNARK's bound value), or Poseidon if a
future deployment chooses to keep the hash inside the field for in-circuit reuse.
Private witness (per-leaf and per-level):
For i in 0..N-1:
flavor[i], v[i], rho[i], idHash[i], predicate[i] # the opening
pathSiblings[i][d] for d in 0..TREE_DEPTH-1 # 20 siblings per leaf
The pathSiblings for leaf i are derived by the wallet from its local
filledSubtrees mirror plus the previously-folded leaves of this same batch
(leaves 0..i-1 sit at known positions and contribute their Poseidon hashes to
i's path on the right). The wallet does this work; the chain consumes only
oldRoot, newRoot, and the calldata cms[].
In-circuit constraints:
- Per-leaf opening. For each
i,cm[i] === Poseidon5(flavor[i], v[i], rho[i], idHash[i], predicate[i]). N Poseidon-5 evaluations, N equality constraints. - Range bounds. Each
v[i]andtotalFacepasses throughNum2Bits(128). - Sum conservation.
sum(v[0..N-1]) === totalFace. - Rolling Merkle insertion. The circuit threads
oldRootandnextLeafIndexthrough N successive single-leaf insertions, mirroring the on-chain Phase-7Notes._insertlogic but in-circuit: at each step it foldscm[i]up the tree usingpathSiblings[i][*]andSwitcher(...)per level, advances the rolling state, and finally asserts the resulting root equalsnewRoot. N x TREE_DEPTH Poseidon-2 hashes (~4.3K constraints per leaf). - Calldata binding.
cmBatchHash === H(cm[0], cm[1], ..., cm[N-1]). - (Optional belt-and-suspenders.)
cm[i] != 0andcm[i] != ZERO_VALUEper leaf, blocking pathological collisions with empty-tree zeros.
Constraint counts work out to roughly 21,800 R1CS per leaf (opening +
range-bound + dual Merkle walk: one path consumed to attest oldRoot, one to
advance newRoot); the shipped pinned-N set measures 21,914 R1CS at N=1,
348,705 at N=16, and 697,281 at N=32. The doubling per pin holds linearly
through the projected sizes (N=128 ~2.8M R1CS, N=1024 ~22M). At N=16 the
2^19 PTAU and ~6 s prover wall on a dev laptop are comfortable; at N=128 it
is rapidsnark-and-minutes; at N=1024 it is hours plus a multi-GB zkey. The
scripts/snark/setup.sh ceremony emits one verifier per pin.
The witness is processed by snarkjs's Groth16 prover. Tens of seconds to minutes
later the wallet has a fixed-size proof object (pi_A: G1, pi_B: G2, pi_C: G1),
encoded as ABI-static (uint256[2], uint256[2][2], uint256[2]) – exactly 256
bytes regardless of N.
Observation. The Groth16 proof reveals nothing about the witness. Soundness
guarantees that no proofBytes can satisfy the verification equation unless a
witness exists; zero-knowledge guarantees that the verification equation reveals
nothing about which witness was used. Mallory, even with the proof in hand,
cannot back out v[i], rho[i], idHash[i], flavor[i], predicate[i], or
the per-leaf Merkle paths – only oldRoot, newRoot, nextLeafIndex,
totalFace, and cmBatchHash leak.
Step 3: Submit Notes.mint On Chain
Alice now broadcasts a single transaction T_mint calling
notes.mint(
proofBytes,
oldRoot, newRoot, nextLeafIndex,
1200e18, // totalFace
[cm_0, cm_1, ..., cm_15] // calldata, cms.length === N* (here 16)
);
The honest EVM walks Notes.mint (Phase-7-bis surface):
require(cms.length > 0, "Notes: empty mint");
// Stale-state guards: the prover must have read the live tree state.
require(oldRoot == roots[currentRootIndex], "Notes: stale oldRoot");
require(nextLeafIndex == self.nextLeafIndex, "Notes: stale leafIndex");
// SNARK verify against the dispatched per-N adapter.
bytes32 cmBatchHash = keccak256(abi.encodePacked(cms));
IMintVerifier mintVerifier = mintVerifiers[cms.length];
require(address(mintVerifier) != address(0), "Notes: unknown N");
require(mintVerifier.verifyMint(
proofBytes, oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash
), "Notes: bad mint proof");
// Pull BUCK only after the proof is accepted.
require(buck.transferFrom(msg.sender, address(this), totalFace),
"Notes: transfer failed");
// Adopt the SNARK-attested new root, advance the rolling state.
currentRootIndex = (currentRootIndex + 1) % ROOT_HISTORY_SIZE;
roots[currentRootIndex] = newRoot;
self.nextLeafIndex += cms.length;
noteFaceSum += totalFace;
// Single batch event; per-leaf Appended is gone. cms[] live in calldata.
emit Minted(msg.sender, totalFace, /*startIndex=*/ nextLeafIndex,
/*count=*/ uint16(cms.length), newRoot);Five things happen in strict order, and all of them are atomic (one transaction, one EVM state transition):
- Stale-state guards. The contract asserts that
oldRootequals the current ring-buffer head andnextLeafIndexequals the live tree size. Either fails if another mint landed since Alice's wallet snapshotted state (more on this under Prover Contention and the Rollup Race below). - Calldata binding. The contract recomputes
cmBatchHash = keccak(abi.encodePacked(cms))over the calldata it just received. This hash is one of the Groth16 public inputs, so any tampering withcms[]en route from the prover to the chain mismatches the SNARK and the verify call rejects. - SNARK verify. The
MintVerifierAdapterdispatches bycms.lengthto the per-N verifier (one circuit per pin, one verifier contract per pin), unpacks the 256-byteproofBytesinto Groth16 form, builds the 5-entry public-signal vector[oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash], and invokes the appropriateMintGroth16Verifier. If the verifier returns false, the entire transaction reverts – nothing is written, no BUCK moves. - BUCK escrow.
buck.transferFromdebitstotalFacefrom Alice and credits theNotespool. Allowance-driven pull, gated by Alice's priorapprove(notes, 1200e18, E_notes, pi_CP). The approve was CP-bound (the always-CP rule applies regardless of spender flavour – see Why approve Is Always CP-Bound), so Alice's side of the receipt is the per-pair CP fragment. The pool was bound under a Public Identity (bindContract(notes, pk, E, true)), so the pool side of the receipt falls back to_identityHash(notes)without requiring a per-pair fragment from the pool. - Root advancement. The contract writes
newRootinto the next ring-buffer slot, advancescurrentRootIndexandnextLeafIndex, accumulatesnoteFaceSum, and emits a singleMinted(issuer, totalFace, startIndex, count, newRoot)event. The new root immediately becomes the live root that the next minter must read.
If any of (a)-(e) fails – stale root, stale leafIndex, bad proof, insufficient BUCK, missing approval – the entire transaction reverts and the chain state is indistinguishable from no submission at all. Alice gets one shot per transaction; she cannot retry a partial state.
Note on duplicate commitments. Phase-7 carried an explicit
commitmentExists[cm] mapping that reverted on duplicate insertion. The
batch-mint contract drops it – not because duplicates are harmless, but because
they are now economically self-punishing: if Alice submits the same opening
twice (whether across two batches or twice in one), she pays totalFace on each
occurrence, the leaves land at distinct tree indices, but the spend nullifier
Poseidon3(rho, idHash, 4242) is deterministic in the opening. After the
first spend nullifies cm, the second copy is unspendable forever. Net result:
Alice burns BUCK she could have kept. The chain's job is value conservation, not
saving Alice from a buggy rho RNG. See Why Outside Observers Cannot Link
below and the per-attack-vector audit in
alberta-buck-notes-rollup-mint.org.
Step 4: Off-Chain Note Delivery
After T_mint confirms, Alice transmits each live opening to its intended recipient
by any out-of-band channel: an encrypted message, a printed QR code, a USB stick.
The payload to recipient i is:
note_artifact_i = {
txHash: T_mint, # Ethereum transaction ID
leafIndex: startIndex + i, # absolute position in the commitment vector
cm: cm_i, # for cross-checking against calldata
opening: ( flavor_i, v_i, rho_i, idHash_i, predicate_i ),
meta: { issuer: Alice's IdentityRegistry handle, batchSize: N*, ... },
}
The chain itself never sees opening. cm_i is already folded into newRoot;
the artifact carries it explicitly only as a convenience so the recipient does
not have to recompute it before locating it in the batch's calldata cms[].
The ten dummy openings Alice generated to pad to the pinned N=16 circuit receive
no artifact; they are discarded (or, at Alice's option, retained privately as
unspendable anonymity-set filler). From the recipient's perspective the presence
of dummies is invisible: Bob's artifact only names his own leaf, and the batch
total totalFace = 1,200 BUCK is exactly the sum of the live v_i.
Step 5: Recipient Verification (Offline, Independent)
Bob receives his artifact for the 1,000-BUCK note. Without trusting Alice and without any help from an indexer, he runs:
- Recompute and check. Bob computes
cm_check = Poseidon5(flavor, 1000e18, rho, idHash, predicate)and assertscm_check == cm. If this fails, Alice lied about the opening; Bob discards the artifact and demands a real one. (Binding of Poseidon makes substitution impossible.) - Look up the mint transaction. Bob reads
T_minton-chain, parses the call data into(proofBytes, oldRoot, newRoot, nextLeafIndex, totalFace, cms[])and readsmsg.senderfrom the transaction envelope. - Confirm
msg.senderis Alice. Cross-checkmsg.senderagainst the IdentityRegistry: it must resolve to Alice's PS-credentialed identity. If Bob wanted an A1 identified cheque, the issuer field of his opening must match. - Confirm calldata membership. Assert
cms[leafIndex - nextLeafIndex] == cm(where the artifact'sleafIndexis the absolute position). The chain's acceptance ofT_mintconfirms that every entry incms[]was folded intonewRootand thatnewRootwas added to the root ring buffer. Bob therefore has chain-anchored evidence thatcmis officially a commitment in the pool. - Confirm batch totals. Read
totalFacefrom the call data. The chain's acceptance ofT_mintis itself a proof that the Groth16 verifier accepted – which meanssum(v[0..N-1]) === totalFaceholds in the witness. Bob does not need to re-verify the proof; the EVM already did. But if he wants belt-and-suspenders, he can re-runMintGroth16Verifier.verify(proofBytes, [oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash])locally against the same on-chain bytecode – andcmBatchHashhe recomputes from the calldatacms[]using the samekeccak256(abi.encodePacked(cms))the contract uses.
After (1)-(5) Bob knows:
- His note encodes exactly 1,000 BUCK. (Step 1.)
- It was minted in
T_mintby Alice. (Steps 2, 3.) - It is one of
N*(here 16) commitments folded into the tree by that mint, the others beingcms[] \ cm. (Step 4.) - The live values among the other siblings sum to exactly
1200 - 1000 = 200BUCK (the residual oftotalFaceonce Bob subtracts his ownv); the batch may also contain dummy openings withv = 0. (Step 5 + the per-note opening relation: he knows his value, the chain attests to the sum.) - He learns nothing else about the other notes – not their individual values, not
their recipients, not their flavors, not which are live and which are padding.
Each
cm_jis hiding under a freshrho_jhe does not have.
This is the unlinkability promise made concrete: each recipient can pin down their own slice and the batch total, without learning anything about siblings.
Why Alice Cannot Cheat (Even With a Hostile EVM and Prover)
Alice runs whatever software she wants on her laptop. She can fork the snarkjs
witness generator, patch the circom compiler, run Anvil with a malicious
MintGroth16Verifier that always accepts. None of it matters, because the truth on
the public chain is determined by what the honest validators run. Three pillars
prop up the soundness story:
Pillar 1: The On-Chain Verifier Is Fixed Code
MintGroth16Verifier.sol (one per pinned N) was emitted from the Groth16
verification key produced by the trusted setup (Phase 9 for production; dev-only
entropy in scripts/snark/setup.sh today). Its bytecode is deployed once and
immutable. Validators run whatever bytecode sits at that address; Alice cannot
tamper with it without consensus-breaking the chain.
Knowledge soundness of Groth16 means: for every proofBytes the verifier accepts
on public inputs [oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash],
there exists a witness
(flavor[i], v[i], rho[i], idHash[i], predicate[i], pathSiblings[i][*])_i such
that:
cm[i] = Poseidon5(flavor[i], v[i], rho[i], idHash[i], predicate[i])for alli- each
v[i]andtotalFacefit in 128 bits andsum(v[i]) = totalFace - folding
cm[0..N-1]into the tree rooted atoldRoot, starting at positionnextLeafIndex, usingpathSiblings[i][*]as the right-of-insert context, yields exactlynewRoot cmBatchHash = H(cm[0], ..., cm[N-1]).
Alice is free to find such a witness any way she likes – she's the prover. But she cannot find a witness for a false statement; that's what soundness prohibits.
Pillar 2: BUCK Escrow Is Inside the Same Atomic Call
The proof check, the BUCK pull, and the root advancement all live in the same
Notes.mint function. An EVM transaction is all-or-nothing: the only two
outcomes are "the entire call's state changes commit" or "the entire call's state
changes revert".
This means Alice cannot, for instance:
- Verify a proof, then refuse to pay – the verifier acceptance and the
transferFromlive in the same call, gated by the samerequire. - Pay 1,200 BUCK but then get away with advancing only part of the tree –
newRootis SNARK-attested as the deterministic fold ofallofcms[]ontooldRootatnextLeafIndex, and the contract writes it in one store. - Replay a prior
proofBytes– the SNARK is bound to(oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash)and the contract's stale-state guards reject any submission whoseoldRootno longer matches the live root or whosenextLeafIndexno longer matches the live tree size. - Submit the same opening twice in one batch to double-spend at spend time –
the nullifier is deterministic in
(rho, idHash), so whichever duplicate lands first at spend will consume both. (See the duplicate-commitment note under Step 3.)
Pillar 3: Public Inputs Are Sealed Into the Proof
The Groth16 verifier consumes the public-signal vector [oldRoot, newRoot,
nextLeafIndex, totalFace, cmBatchHash]. The proof was generated against the
specific values Alice committed to before submission. If Alice tries to submit
the proof with a different totalFace, a different newRoot, a different
nextLeafIndex, or a permuted cms[] whose keccak no longer matches
cmBatchHash, the verifier rejects.
Note that cms[] itself is not a public input to the SNARK – the proof binds
only to its hash cmBatchHash. The contract is the component that recomputes
that hash from calldata on every call, so any mismatch between the proved hash
and the recomputed hash reverts at verify time.
The refreshed test/MintVerifier.t.sol should cover at least these tamper paths,
each expected to revert with Notes: bad mint proof or Notes: stale oldRoot or
Notes: stale leafIndex or an ABI decode error:
totalFace + 1newRootflipped by one bitoldRootspoofed to a stale valuenextLeafIndexspoofed to a fictional sizecms[0] ^ 1(commitment tampered in calldata after proving)cms[]length differs from the dispatched verifier's pinned N- malformed 256-byte
proofBytes
Soundness is not a proof of absence of attack, but every documented attack surface is closed by a test that triggers a verifier reject.
Putting the Pillars Together: The Adversarial Walk
Suppose hostile-Alice tries to mint two contradictory batches that both claim to
be the canonical breakdown of her T_mint transfer. She would need:
- Two different
proofBytesand/orcms[], both passing the on-chain verifier.
That requires either:
- Two valid Groth16 witnesses for the same public inputs (allowed – but they
yield the same
cms, so they are not contradictory), or - Two valid Groth16 witnesses with different public inputs. Allowed too, but
each one demands its own
transferFromfor its owntotalFace– that is the duplicate-spend problem ERC-20 solves. Alice cannot transfer the same 1,200 BUCK twice without holding 2,400 BUCK to start with.
So a single 1,200-BUCK escrow yields exactly one accepted batch. The only freedom Alice has is which batch she lays down – which denominations and which recipients – and that freedom is hers to keep, by design.
What about hostile-Alice trying to mint more commitment value than she
escrowed? She would need a witness whose sum(v[i]) equals the integer
totalFace that the EVM sees, but whose individual v[i] values actually
sum to more. In F_r with r ~ 2^254 and no range check on v[i], the
constraint sum(v[i]) === totalFace would be satisfiable by witnesses whose
integer sum differs from totalFace by a multiple of r. mint_batch.circom
closes this with a Num2Bits(128) range constraint on each v[i] and on
totalFace, mirrored by spend.circom's check on face and the witness v.
A 128-bit cap is comfortably above any plausible BUCK denomination and well
below r/2, so an integer overshoot can never wrap.
What about hostile-Alice trying to mint a commitment that is already in the
tree (to collide on a nullifier she does not control, or to attempt a
hash-collision attack)? She cannot collide on a nullifier without knowing
somebody else's opening – the nullifier is a Poseidon-PRF output, so forging a
collision is as hard as inverting Poseidon. She can re-mint her own opening
(the contract no longer blocks duplicates), but doing so forces her to pay
totalFace a second time and buys her nothing: the nullifier is deterministic
in the opening, so whichever of her two copies she spends first permanently
nullifies both. This is the value-conservation property in action – the chain
does not need a commitmentExists check because the nullifier mechanism already
collapses duplicate openings to one spend, and Alice bears the cost of any
duplication.
Full per-attack-vector analysis (twelve attack vectors A1-A12) is in the pivot design memo alberta-buck-notes-rollup-mint.org. Every hostile-Alice guarantee from the Phase-7 per-leaf insertion design survives under the batch-mint pivot; the only behavioural change is that duplicate-commitment rejection moves from an explicit on-chain check to economic self-enforcement via the deterministic nullifier.
Why Outside Observers Cannot Link
Mallory has full read access to the chain after T_mint confirms. What does she see?
T_mint:
from: Alice (a registered IdentityRegistry account)
to: Notes (a system-public contract)
value: 0 (BUCK is ERC-20, not ETH)
data: Notes.mint(proofBytes, oldRoot, newRoot, nextLeafIndex,
1200e18, [cm_0..cm_15])
events emitted (in order):
Buck.Transfer(Alice, Notes, 1200e18)
Notes.Minted(Alice, 1200e18, startIndex=K, count=16, newRoot)
The batch-mint contract emits one Minted event per batch rather than per-leaf
Appended events; the commitments themselves live in calldata for off-chain
provers and wallets to read. (Calldata is about an order of magnitude cheaper per
byte than LOG data, which is part of why the N-independent cost bound holds.)
What can Mallory infer?
- That Alice (publicly) escrowed 1,200 BUCK. Yes – her address is
msg.sender. - That sixteen new commitments entered the pool at indices
[K..K+15]. Yes –MintedannouncesstartIndexandcount, andcms[]is in calldata. - Which commitment is worth how much. No – each is a fresh
Poseidon5output hidden by an independentrho_i. Without arho_i, distinguishing the 1,000-BUCK hash from the 25-BUCK hash (or from a 0-BUCK dummy) is computationally equivalent to inverting Poseidon on a uniform field element, modelled here as a random oracle. - /Which entries are live notes and which are dummies.* No – the padding
leaves are constructed with the same Poseidon-5 shape and a fresh
rho; nothing about them is chain-distinguishable from a livecmwith a smallv. - Who holds each live note. No –
idHash_iis also inside the hash. - Whether two commitments share a recipient. No – two notes addressed to the
same
idHashbut with distinctrho_iproduce uniformly independentcm_i, so the chain shows nothing distinguishable from independent draws.
What about linking back to a specific note artifact that Bob later possesses? Mallory
sees cm_0..cm_5 published by Alice in T_mint. Bob's artifact references some
cm_i and T_mint, but the artifact lives off-chain. Mallory cannot get the artifact
unless Bob (or Alice) shows it to her, and the chain alone gives her no way to single
out cm_i as "the one Bob holds" against the other five.
The remaining linkability surface is the issuer. msg.sender is publicly Alice, so
every commitment in the batch carries a public issuer tag. This is intentional and
desired: a recipient must be able to verify who minted his note. The privacy goal is
unlinkability of recipients and amounts, not of issuers.
Spend-Time Unlinkability
The shipped spend circuit (Phase 7) lets Bob deposit his note without revealing
which cm in the on-chain set he opened. The spend SNARK proves "I know an
opening to some commitment in the Merkle tree under the public noteRoot that
hashes to this nullifier", with cm as a private witness rather than a public
input. Unlinkability of spend events to mint events is now a chain-observable
property: Mallory sees a fresh nullifier, a public face, and a recipient – nothing
that points back to a specific commitment in the tree. The full walkthrough of the
redemption flow is in The Redemption (Spend), Step by Step below.
Atomicity, No Double-Mint, No Replay
Three properties together rule out duplicated or partial mints:
Atomicity
Already covered above: Notes.mint performs proof verification, BUCK pull, and
commitment appends in one EVM call. Either all happen or none.
No Double-Mint From One Transfer
The transfer and the commitments are bound in the same call: there is no
"pre-deposit, then mint" or "mint, then deposit later" flow that an attacker could
desynchronize. Each Notes.mint invocation does its own transferFrom; each
transferFrom debits Alice's BUCK balance once. Two minting transactions require two
transferFrom debits, which require Alice to actually hold 2 * totalFace BUCK.
No Replay
A literal replay – broadcasting the exact same T_mint calldata in a new
transaction – fails for three independent reasons:
- Stale-state guards. The contract checks
oldRoot == roots[currentRootIndex]andnextLeafIndex == self.nextLeafIndex. The first mint has already advanced both, so the replay'soldRootandnextLeafIndexpublic inputs no longer match the live chain state. The contract reverts before the SNARK is even called, withNotes: stale oldRootorNotes: stale leafIndex. - SNARK binding. Even if a future minter coincidentally re-created the same
oldRootandnextLeafIndexstate (which is prohibited by the monotonic advancement ofnextLeafIndex), the SNARK was bound to the specific(cmBatchHash, newRoot)pair of the prior batch. A replay would fold the samecms[]a second time and produce a differentnewRootthan the SNARK attested to, breaking the rolling-tree relation. - Allowance and balance. Even setting the above aside, the second mint would
re-debit Alice's BUCK and re-consume her allowance to the
Notespool – economic-level double-escrow protection on top of the structural guards.
Replay of the same opening across batches (the replacement of the Phase-7
commitmentExists check) is structurally allowed but economically
self-punishing: Alice escrows totalFace again, the duplicate commitment lands
at a fresh leaf index, and the deterministic nullifier Poseidon3(rho, idHash,
4242) means the first spend nullifies every copy. Alice pays twice for a
spend-once outcome.
Cross-Chain or Cross-Deployment Replay
The MintGroth16Verifier verification key is fixed by the trusted-setup ceremony for
that deployment. A proof generated against the dev-key in
scripts/snark/setup.sh will not verify against a production-key contract on
mainnet, and vice versa. Public inputs are also sealed into the proof: a proof valid
on Sepolia for totalFace=1200 cannot be reused on mainnet because the on-chain
verifier address (and key) differ.
The only cross-context vulnerability would be a chain-id collision on identical verification keys, which the production setup ceremony precludes by construction.
Prover Contention and the Rollup Race
The batch-mint pivot introduces one genuinely new operational concern that did
not exist in the Phase-7 per-leaf insertion design: a mint transaction is now a
rollup-style transaction. Alice's proof is bound to the specific oldRoot
and nextLeafIndex she snapshotted when she began proving; if any other mint
lands between her snapshot and her submission, those values go stale and her
transaction reverts at one of the stale-state guards.
This is not a safety concern – Alice loses no BUCK (the contract reverts
before transferFrom runs) and produces no bad on-chain state. It is a
liveness and efficiency concern: Alice wasted the prover time spent
constructing a proof that is no longer accepted, and has to re-prove against
the fresh state.
Who Wins When Two Minters Race?
When Alice and Bob both submit T_mint_A and T_mint_B targeting the same
oldRoot:
- One of the two (by block ordering) lands first and updates the root.
- The other transaction executes, discovers the stale-state mismatch, and
reverts at the
require(oldRoot == roots[currentRootIndex])line before any BUCK moves.
Transaction fees are paid by the loser for the reverted transaction (gas for
the revert path, not for the full mint). The loser then re-proves against the
new oldRoot and nextLeafIndex and resubmits. The new proof folds their
commitments onto the root that now includes the winner's commitments – no
commitment has been lost; the loser's tree-position is just different from the
one they originally predicted.
Mitigation Layers
Three mitigation layers, in ascending complexity:
- Optimistic retry (wallet-level). On revert, the wallet re-fetches
(currentRoot, nextLeafIndex)from the chain, rederives the sibling paths for the new insertion positions, re-runs the prover, and resubmits. For small N this is fast enough to not matter. This is the recommended initial deployment strategy. - Retry budget and back-off (wallet-level). For large N where prover time is nontrivial (minutes to hours at N=1024), the wallet should expose a "chance of contention" estimate to the user and offer to batch at the smallest pinned N that fits the job, or to enqueue the mint on a private relay channel during low-traffic windows.
- Sequencer / proof aggregation (future). If real-world contention warrants it, an external sequencer can batch multiple users' mints into a single proof per slot, preserving the N-independent on-chain cost while amortising prover time. Sequencer capture is a separate trust axis and is deferred; the shipped protocol is sequencer-free.
What Does Not Regress
- Atomicity. A reverted mint leaves no chain-visible state change; it is strictly indistinguishable from no submission.
- Value conservation. No reverted transaction debits BUCK, so there is no way for a loser to pay the pool without appending commitments.
- Unlinkability. A re-submitted mint on a fresh root still produces the same (anonymity-preserving) calldata and event shape; Mallory cannot tell a retry from a first attempt.
- Spend-time invariants. The spend circuit takes the current
noteRoot(or any recent-window root); a late-arriving mint does not invalidate any previously-proved spend.
The rollup race is the price of replacing ~800K gas/leaf with ~500 gas/leaf, and for practical block-rate contention it is negligible.
Receipt Construction From the Transaction ID Alone
A practical question: months later, Bob wants to prove to Carol that his note was legitimately issued. He needs no help from Alice and no live indexer:
- Bob fetches
T_mintfrom any Ethereum archive node. - He decodes the call data into
(proofBytes, oldRoot, newRoot, nextLeafIndex, totalFace, cms)using the standardNotes.mintABI. - He notes
msg.senderand resolves it to Alice's identity via the IdentityRegistry on the same chain (a singleeth_call). - He locates his
cmincms[]atleafIndex - nextLeafIndexand asserts equality. - He recomputes
Poseidon5(flavor, v, rho, idHash, predicate) == cmfrom the opening. - (Optional.) He recomputes
cmBatchHash = keccak256(abi.encodePacked(cms))and re-runsMintGroth16Verifier.verify(proofBytes, [oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash])against the per-N verifier keyed bycms.length, convincing himself the EVM did its job.
The "receipt" Carol receives is therefore (T_mint, opening, idHash mapping).
Its validity is independently checkable against the public chain. Nothing here
requires ongoing cooperation from Alice – which is exactly the property a
receipt should have.
The Redemption (Spend), Step by Step
This section walks the inverse of the mint. Bob holds the opening for the 1,000-BUCK
note cm_0; he wants to redeem it for 1,000 BUCK credited to his on-chain account, and
he wants no public observer to be able to tell which commitment in the pool he
consumed. The shipped pieces for B-spend are circuits/spend.circom,
SpendGroth16Verifier.sol, SpendVerifierAdapter.sol, Notes.spend(), and
the Tornado-style on-chain Merkle accumulator inside Notes.sol; for A-spend
the shipped pieces are circuits/spend_a.circom, SpendAGroth16Verifier.sol,
SpendAVerifierAdapter.sol, IdentityRegistry.verifySpendCP, and
Notes.spendACP(). Everything below describes code paths that are in the
tree, exercised by test/SpendVerifier.t.sol (B-shape) and
test/SpendAVerifier.t.sol (A-shape, V2). The companion theorems live in
Theorems 8-10 of alberta-buck-proofs.org.
Bob's note is of flavor A1 (addressed, identified cheque). The Phase-7
spend.circom is flavor-agnostic – it enforces a B-shape (bearer) discharge
in which knowledge of the Poseidon-5 opening plus the Poseidon-3 nullifier
preimage is the spend authorization. Phase 8 V2 added an A-shape companion
spend_a.circom (A-tag nullifier, flavor in {A1, A2}, plus the in-circuit
Poseidon-8 binding gate that pins E_n to idHash) and a new on-chain
entrypoint Notes.spendACP that calls both the A-Groth16 verifier
spendAVerifier.verifySpendA and IdentityRegistry.verifySpendCP. The
DLEQ check lives off-chain (in Solidity, via the EIP-196 BN254 precompiles)
because verifying it inside the SNARK would have cost ~+250K R1CS in
non-native circom; on-chain it costs ~36K gas (2 ecMul + 2 ecAdd + keccak FS).
The walk below uses Bob's A1 note end-to-end: Bob's wallet emits E_n
(the spender's note ciphertext, public-input to the SNARK) and a 4-element
Chaum-Pedersen DLEQ proof (e, s, T1, T2); Notes.spendACP rejects unless
both checks pass, with all A1/A2 identity, security, and privacy properties
preserved.
Three properties of the flow are worth stating up front, because they shape every step:
- The anonymity set is the full pool, not only same-denomination. The spend
proof takes
cmas a private witness under a publicnoteRoot– the chain-observable anonymity set is every unspent commitment in the tree. The face value is a public output, because the BUCK contract must know how much to pay out; amount-level linkability narrows the effective set to commitments whose hiddenvcould equal the revealedface. Same-denomination issuance is therefore the strongest-privacy regime, but the underlying cryptographic hiding is over the whole tree, not over a denomination class. - The BUCK ERC-20 does not verify the SNARK itself. The authorization chain
is: valid SNARK + accepted root + fresh nullifier (+ for A-spends, valid
CP-DLEQ identity proof) \(\to\)
Notes.spendorNotes.spendACPproceeds \(\to\) the entrypoint invokesbuck.transferCarrying(recipient, face)from the pool. The proof is a capability token for entry into the appropriate spend method; the resulting ERC-20 movement is a routine pool-to-recipient call gated by that entry. Since theNotespool is a system-public account in the IdentityRegistry, the pool's outbound transfer does not itself require a Chaum-Pedersen receipt – the recipient's own registration is what makes them acceptable as a transfer destination. - Public inputs are bound into the proof, including
chainId.spend.circomghost-bindsrecipientandchainId(viax*xrows) so Groth16's IC[] commitment makes them non-malleable from the mempool. A front-runner who steals Bob'sproofBytesand resubmits with a differentrecipientor on a different chain id is rejected by the verifier.
Step 0: Bob Holds a Verified Note
From the mint walk (Step 5: Recipient Verification) Bob already has:
note_artifact_0 = {
txHash: T_mint,
index: K + 0,
cm: cm_0,
opening: ( flavor_A1, 1000e18, rho_0, idHash_bob, predicate_0 ),
meta: { issuer: Alice, ... },
}
He has independently confirmed that cm_0 is in Notes.commitments[] at index K
and that Poseidon5(opening) reproduces it. The opening has been sitting in his
wallet for weeks, months, or years – addressed A-notes are cryptographically safe for
indefinite holding (Notes).
Step 1: Fetch the Commitment Tree and Build a Merkle Path
Notes.sol maintains a Tornado-style incremental Poseidon-T3 Merkle tree of
TREE_DEPTH = 20 (max ~1M leaves). Under Phase-7-bis the per-leaf insertion
math lives inside the mint SNARK; the contract just records the SNARK-attested
newRoot at each mint() call. The current root is exposed as noteRoot().
Two things happen:
- Bob (or, more practically, any light-node helper running off-chain)
reconstructs the tree off-chain. The commitments themselves are available
from each mint transaction's calldata
cms[], keyed by theMinted(issuer, totalFace, startIndex, count, newRoot)event that indexes the batch. A replayer walks allMintedevents in order, takes thecms[]slice out of each mint's calldata, and appends them to its local tree using the sameZERO_VALUEand Poseidon-T3 wrapper the in-circuit insertion uses. Bob's Merkle path forcm_0– a sequence of sibling hashes plus left/right indices up to the root – falls out of that rebuilt tree. The path is purely public information; everyone can reproduce it, and Bob does not have to trust any indexer because calldata is canonical. - Bob reads the current
noteRoot()viaeth_call. He may instead pin any root in the recent-roots window:Notes.solretains the lastROOT_HISTORY_SIZE = 30roots in a ring buffer (roots[], advanced once per inserted leaf), and the on-chainisAcceptedRoot()scans that ring backward fromcurrentRootIndex. This is the standard zk-rollup hygiene (Zcash, Tornado Cash) for tolerating prover latency: a spend assembled against a slightly-stale root remains acceptable so long as fewer than 30 mints have landed in the meantime.
Step 2: Compute the Nullifier
The shipped spend.circom derives the nullifier as a Poseidon-3 hash of the
witness rho, the witness idHash bound at mint, and a constant tag:
\[ nf \;=\; \mathrm{Poseidon}_3\!\bigl(\rho_0,\; \mathrm{idHash}_0,\; 4242\bigr). \]
The tag (4242) is a fixed nonzero field constant that domain-separates nullifier preimages from the Poseidon-5 commitment preimages, so a structural collision between a nullifier and a commitment is impossible. Three properties follow:
- Deterministic. The same
(rho, idHash)always yields the samenf. So Bob can only spendcm_0once; a second attempt re-derives the samenfand is rejected bynullifiers[nf] = trueon the first spend. - Unforgeable without the opening. An attacker who sees
cm_0and the tree cannot computenfwithoutrho_0andidHash_0, both of which live inside the opening (andrho_0is not derivable from the recipient's identity alone). - A-flavor binding is enforced jointly by the SNARK + off-chain CP-DLEQ.
The Phase-7 bearer-shape circuit treats
idHashas a witness scalar – it does not constrain it to the recipient's registered identity. Phase 8 V2 closes the gap with two cooperating components glued byNotes.spendACP: (a)spend_a.circomV2 publishesE_nas 4 public inputs and enforcesPoseidon-8(E_n.R.x, E_n.R.y, E_n.C.x, E_n.C.y, issuerData[0..3]) === idHash, pinningE_nto the leaf at mint time (~265 R1CS); (b) Bob provides a 4-element CP-DLEQ proof(e, s, T1, T2), andIdentityRegistry.verifySpendCPreads Bob's registered(pk_dep, E_reg)from storage and confirms a singlesk_depsatisfies bothpk_dep === sk_dep * Gand the equal-plaintext relationC_n - sk_dep * R_n === C_reg - sk_dep * R_regvia the EIP-196 BN254 precompiles (~36K gas). Combined, this makesnfeffectively a deterministic function of(M_rec, rho)alone (Theorem 10.1a) at the contract boundary, and prevents a spender from substituting a differentE_njust to make the off-chain check pass with their own key. The A-spend binding is to the mint-time key pair: the verifier requires Bob to supply the \( sk_{\text{rec}} \) paired with the \( pk_{\text{rec}} \) the issuer used at mint, so A-notes are not recoverable via Identity re-issuance if that key is lost (Theorem 10.1b; proofs.org "Key-Pair Binding and Loss Semantics"). The DLEQ-off-chain placement is forced by the prohibitive cost of native BN254 G1 arithmetic in non-native circom (~+250K R1CS for one CP-equality set).
The nullifier is the chain-observable anchor of the spend. Publishing it marks
cm_0 as consumed from the tree's semantic point of view, even though the
tree itself remains append-only and nothing is deleted.
Step 3: Generate the Spend Proof
Bob's wallet runs scripts/snark/prove_spend.js (the spend-side analogue of
prove_mint_batch.js, using the depth-20 PTAU produced by
scripts/snark/setup.sh). spend.circom's signal layout is exactly:
public noteRoot; # an accepted root from the on-chain ring buffer
public nullifier; # the nf from Step 2
public face; # 1000e18 -- the BUCK to release (range-bounded to 128 bits)
public recipient; # Bob's on-chain address (the payout destination)
public chainId; # replay-domain separation
private flavor, v, rho,
idHash, predicate; # the Poseidon-5 opening
private pathElements[depth]; # depth-20 sibling hashes
private pathIndices[depth]; # left/right bit per levelThe shipped circuit enforces five relation groups:
- Range bounds and value match. Both the public
faceand the witnessvpass throughNum2Bits(128), thenface === v. This closes the field-overflow gap:vcannot wrap aroundrto claim more than its in-range share. - Opening binds.
cm = Poseidon5(flavor, v, rho, idHash, predicate). - Merkle membership.
MerkleProof(20)walks (cm,pathElements,pathIndices) up the tree usingSwitcher+Poseidon(2)per level and asserts the climb reachesnoteRoot. - Nullifier derivation.
nullifier === Poseidon3(rho, idHash, 4242). - Public-input ghost binding.
recipientandchainIdeach appear in a trivialx*xrow. This is the minimal anchor needed for circom not to optimise out unreferenced public signals; once anchored, Groth16's IC[] commitment makes them non-malleable from the mempool's perspective.
Critically, cm is not a public input; it is recomputed inside the proof from
the witness opening. The proof convinces the verifier that some cm in the
tree satisfies (1)-(4); it does not reveal which.
Bob's A1 spend uses the A-shape circuit spend_a.circom V2 and the new
on-chain entrypoint Notes.spendACP, not the B-shape Notes.spend. The
A-shape circuit publishes nine public inputs – the original five
(noteRoot, nullifier, face, recipient, chainId) plus four new
E_n coordinates (R_n.x, R_n.y, C_n.x, C_n.y) – and adds the Poseidon-8
binding constraint Poseidon-8(E_n.R.x, E_n.R.y, E_n.C.x, E_n.C.y,
issuerData[0..3]) === idHash (~265 R1CS). The Chaum-Pedersen DLEQ check
itself lives off-chain in IdentityRegistry.verifySpendCP, called from
Notes.spendACP via the EIP-196 BN254 precompiles (~36K gas). This split
delivers genuine A-flavor protection (Theorem 8 #3 + Theorem 10.1) without
paying the +250K-R1CS cost of native G1 arithmetic in non-native circom.
Bob's wallet serializes the A-shape Groth16 proof into the standard
(uint256[2], uint256[2][2], uint256[2]) ABI-static tuple (256 bytes), and
also assembles the 4-element CP-DLEQ proof (e, s, T1, T2) using
alberta_buck.wallet.spend_cp.prove_spend_cp bound to the transcript
(E_n, E_reg, pk_dep, T1, T2, recipient, chainid).
Step 4: Submit Notes.spendACP On Chain
Bob now broadcasts the A-spend transaction T_spend:
notes.spendACP(proofBytes, noteRoot, nullifier, 1000e18, bob, E_n, cpProof);
The honest EVM walks Notes.spendACP (the function exactly as it ships):
require(address(spendAVerifier) != address(0), "Notes: A-spend disabled");
require(address(identityRegistry) != address(0), "Notes: identity registry not set");
require(recipient != address(0), "Notes: zero recipient");
require(face > 0, "Notes: zero face");
require(_isAcceptedRoot(root), "Notes: unknown root");
require(!nullifiers[nullifier], "Notes: already spent");
// SNARK leg: 9 public inputs bind the 5-tuple plus E_n's coords.
require(spendAVerifier.verifySpendA(
proof, root, nullifier, face, recipient, block.chainid,
E_n.R.X, E_n.R.Y, E_n.C.X, E_n.C.Y
), "Notes: bad spend proof");
// Identity leg: msg.sender must own the sk_dep that decrypts both
// E_n and their registered E_addr to the same M.
require(identityRegistry.verifySpendCP(
msg.sender, recipient, E_n, cpProof
), "Notes: bad identity proof");
nullifiers[nullifier] = true; // mark spent before paying out
noteFaceSum -= face;
require(IBuckCarrying(address(buck))
.transferCarrying(recipient, face),
"Notes: transfer failed");
emit SpentA(nullifier, face, recipient);Five invariants are enforced in order:
rootappears in the recent-roots window. A root that has rotated out is stale – Bob must rebuild the proof against a fresher root.nullifierhas never been published before (Theorem 10.5 in the proofs says this single-bit check is sufficient for double-spend prevention).- The SNARK is valid against
SpendAGroth16Verifiervia theSpendAVerifierAdapter, which packs[root, nullifier, face, recipient, chainid, E_n.R.X, E_n.R.Y, E_n.C.X, E_n.C.Y]into the 9-element public- signal vector and unpacksproofBytesto Groth16 form. By soundness, a witness satisfying (1)-(5) above exists, including the Poseidon-8 binding that pinsE_nto the leaf at mint time. identityRegistry.verifySpendCP(msg.sender, recipient, E_n, cpProof)succeeds, i.e.msg.sender's registeredsk_depsatisfiespk_dep === sk_dep * Gand the equal-plaintext relation betweenE_nandE_reg, verified via 2 ecMul + 2 ecAdd + keccak Fiat-Shamir (~36K gas).- Only after (a)-(d) pass does the contract mark the nullifier and invoke
buck.transferCarrying(bob, 1000e18)from its own pool balance (see Step 5). Because theNotespool was bound under a Public Identity in the IdentityRegistry (bindContract(notes, pk, E, true)), the outbound transfer's bilateral identity check still requires both parties to beisVerified, but the pool-side receipt fragment falls back to the pool's deterministic_identityHash– no per-counterparty CP receipt is required for the Notes-pool side because the pool's operator has already disclosedmoff-chain, making the registry-derived hash a non-confidential audit trail equivalent to what an EOA-to-EOA approve-time CP receipt would provide.
If any of (a)-(e) reverts, the whole transaction reverts: no BUCK moves, the
nullifier bit is never set, the noteFaceSum is unchanged. Bob gets one
attempt per root window; a failed spend can be retried with a refreshed proof,
but never in a way that consumes a nullifier without paying out the BUCK.
(For B1 bearer notes, the corresponding entrypoint is the simpler
Notes.spend(proofBytes, root, nullifier, face, recipient) that omits
the SNARK's E_n public inputs and the off-chain CP leg – B-spends are
authorised purely by knowledge of the Poseidon-5 opening + Poseidon-3
nullifier preimage.)
Step 5: BUCK ERC-20 Pays Out (transferCarrying)
The call inside Notes.spendACP is buck.transferCarrying(bob, 1000e18), a small
extension of the standard BUCK transfer that the Notes pool uses on its single
egress. (Notes.spend calls the same primitive on the B-spend path.) Nothing else in Buck.sol is special-cased for the pool: it is just a
registered account making an outbound transfer of 1,000 BUCK to a registered
recipient. The result on-chain:
Buck.balanceOf(Notes)decreases by exactly 1,000 BUCK – nominal balance only.Buck.balanceOf(Bob)increases by exactly 1,000 BUCK.Buck.Transfer(Notes, Bob, 1000e18)is emitted.Buck.TransferCarrying(Notes, Bob, 1000e18)is emitted as the carry signal.Notes.Spent(nullifier, 1000e18, Bob)is emitted.Notes.nullifiers[nullifier]becomestrue.
What "carrying" means in the ledger. BUCK demurrage uses the standard
cumulative-index accounting: a global cumIndex advances at BASE_RATE_PER_SEC
and each account stores its _indexAtLastTouch. A standard transfer settles the
sender's accrued fee at transfer time (deducting it as a one-shot burn) and merges
the recipient's index toward the global cumIndex weighted by the incoming
amount. transferCarrying differs in two surgical ways:
- Sender's index is unchanged. The sender's remaining balance keeps its pre-transfer age; no fee is burned at the sender on this transfer.
-
Recipient absorbs the sender's age. The recipient's
_indexAtLastTouchis updated by a balance-weighted average toward the sender's pre-transfer index (idxFrom), not toward the globalcumIndex:_indexAtLastTouch[to] = (br * _indexAtLastTouch[to] + amount * idxFrom) / (br + amount);
System-wide BUCK-age is preserved across the call (no fee is burned, no fresh-BUCK dilution at the recipient); the demurrage that the pool would have burned on a standard outgoing transfer instead rides into Bob's account as a forward index shift. Bob will pay it the next time he makes a standard outgoing transfer.
Why this is the right rule for the pool. Per-note age is opaque – the pool
holds aggregate BUCK behind opaque commitments and cannot tell Bob's note's age
from any other note's. transferCarrying collapses the calculation to the pool's
average index at spend time: every spender of the same face absorbs the same
forward index shift, keeping the spend-time anonymity set intact. The trade-off
is that Bob inherits the pool's average age regardless of how long his specific
note sat in the tree. Anyone whose note rested longer than the pool average comes
out ahead; this is the cost of anonymity, and the upside for long-term holders.
The full discussion (one new BUCK method, one new event, no per-account mode flag) is in Demurrage-Carrying Transfers below.
What the Observer Sees at Spend (Mallory Returns)
After T_spend confirms, Mallory can read:
T_spend:
from: Bob (a registered IdentityRegistry account)
to: Notes (system-public)
data: Notes.spendACP(proofBytes, noteRoot, nullifier, 1000e18, Bob, E_n, cpProof)
events:
Buck.Transfer(Notes, Bob, 1000e18)
Notes.SpentA(nullifier, 1000e18, Bob)What she infers:
- That a note was spent. Yes – the nullifier is fresh and the pool balance dropped by 1,000 BUCK.
- That the spend was for 1,000 BUCK to Bob. Yes –
faceandrecipientare public inputs (they must be, to tell the ERC-20 whom to pay). - Which commitment in the tree was consumed? No –
cmis a private witness to the SNARK. Mallory's anonymity set is every unspent commitment of flavorAin the pool whose hiddenvcould equal 1,000 BUCK. At deployment scale this is potentially the entire pool minus prior nullified entries; in a same-denomination regime (many 1,000-BUCK notes in circulation) it is exactly the 1,000-BUCK subset. - Which mint did the spent note come from? No – there is no cross-reference
to
T_mintinT_spend. The only link back to any specific mint would be the nullifier, but nullifiers are Poseidon-PRF outputs over secret material; inverting one to recover(flavor, M_rec, rho)is exactly the pre-image resistance we assume. - Who held the note before Bob? No. If Alice gave the note to Carol who handed it to Dave who sold it to Bob, none of those transfers touched the chain. The chain sees only "at mint Alice deposited, later Bob withdrew" – the intermediate holders are invisible.
The residual linkability surfaces are (i) face (the revealed amount) and (ii)
recipient (the on-chain payout address). Both are unavoidable at the SNARK/ERC-20
boundary – the contract must know where and how much to pay. The community-
standardized mitigation is fixed-denomination notes (so revealing face does not
subdivide the anonymity set) and batched, relay-mediated payouts (so revealing
recipient need not reveal the true beneficiary). Both are orthogonal to the
core spend circuit and can be layered on top.
Why Alice Cannot Steal Bob's Note (Even Between Rooms)
Can any actor other than Bob – Alice-the-issuer, a prior holder, or a random
Mallory – redeem cm_0 under the shipped circuit? Three protections apply,
the third now shipped via Phase 8 V2's off-chain CP-DLEQ in
IdentityRegistry.verifySpendCP (called from Notes.spendACP):
- Opening secrecy.
spend.circomrequires the full Poseidon-5 opening(flavor, v, rho, idHash, predicate)as a private witness. Withoutrho_0andidHash_0the circuit is unsatisfiable, so no one without the opening can produce a valid nullifier or proof. For Alice's secrets, this is the load- bearing protection on bearer-flavor (B1) notes: she could in principle race the bearer to deposit, which is the civil-trust semantics of bearer cash and is discussed at length in alberta-buck-notes.org §Long-Term Holding Safety. - Recipient-binding at the payout site.
recipientis a public SNARK input ghost-bound byrecipient * recipient, so Groth16's IC[] commitment makes it non-malleable. A front-runner who intercepts Bob'sproofBytesand resubmits with a differentrecipientis rejected by the verifier.chainIdis bound identically, blocking cross-chain replay. - Recipient-binding via off-chain CP-DLEQ (Phase 8 V2, shipped).
For A-flavor notes the spender provides
E_nalongside the spend and a Chaum-Pedersen DLEQ proof(e, s, T1, T2);Notes.spendACPcallsidentityRegistry.verifySpendCP(msg.sender, recipient, E_n, cpProof)which reads the spender's registered(pk_dep, E_reg)from storage and confirms a singlesk_depsatisfies bothpk_dep === sk_dep * Gand the equal-plaintext relationC_n - sk_dep * R_n === C_reg - sk_dep * R_reg(Theorem 8 #3).E_nis bound to the spent leaf via the Poseidon-8 constraintidHash === Poseidon-8(R_n.x, R_n.y, C_n.x, C_n.y, issuerData[0..3])added tospend_a.circomV2, so Alice-who-minted cannot substitute a different ciphertext. This makes Alice unable to redeem to herself even though she knowsidHash_0andrho_0: she does not hold Bob'ssk_rec, so the DLEQ fails. The verifier costs ~36K gas (two ecMul + two ecAdd via EIP-196 precompiles, plus a keccak Fiat-Shamir recompute) – much cheaper than the +250K-R1CS prove-time cost of doing the same check in non-native circom, which is why the DLEQ verification site lives inNotes.spendACP(Solidity) rather than inside the SNARK.
The A-flavor strict-binding guarantee Theorem 8 promises is now a
chain-observable property: the verifySpendCP boolean is the gate. Bob's
idHash_0 is what authenticates him at the artifact level, the SNARK
enforces the bearer-shape discharge, and the off-chain CP-DLEQ enforces the
identity binding to Bob's registered key – with all A1/A2 identity, security,
and privacy properties preserved.
B-Flavor Notes Today
The shipped spend.circom is the B-shape circuit: Bob's note above could
equally well be a B1 bearer cheque, and the same five relation groups apply
unchanged. Holder binding for B1 is the Schnorr-PoK style behaviour the wallet
gets for free from the recipient-ghost-binding (relation 5) plus the depositor
having to produce a valid T_spend from their own EOA: if two parties hold copies
of the same B1 opening (e.g. a paper note photographed and forwarded), whichever
submits T_spend first wins. That is the intended civil-trust semantics of a
bearer cheque, not a bug. An explicit in-circuit Schnorr PoK over
(chainid || nullifier || recipient) – to harden against opening-leakage between
two B1 holders both submitting in the same block – is a possible follow-up but
not in the shipped circuit.
Recapping Property C (Only Valid Notes Redeem)
The original Property C question – subsequently only those N valid notes can be
deposited – becomes concrete at this layer. Claim: no actor can redeem a
commitment cm unless
cmwas appended tocommitments[]by a priorNotes.minttransaction whose Groth16 proof verified (i.e.,cmis part of an honest mint batch), and- The actor holds the opening of
cm, and - For A-spends, the actor is the depositor keyed by
M_recin that opening.
Proof sketch. The spend circuit's relation 2 (Merkle membership) rejects any cm
not in noteRoot. The mint circuit's relations 1-2 (opening binding and sum
conservation) are what places a cm in noteRoot in the first place, and by the
mint-side soundness story in Why Alice Cannot Cheat every such cm corresponds to
a well-formed opening whose v is included in the mint's totalFace escrow. The
spend circuit's relation 5 (face === v) then ensures the BUCK released equals
exactly the share of the escrow that cm was allocated. Summing over every
accepted spend of a given mint batch, the cumulative BUCK released equals at most
the totalFace escrowed – property C follows from mint-side value conservation
plus spend-side nullifier uniqueness.
The earlier caveat about a missing Num2Bits range check on v[i] at mint time
is closed: mint_batch.circom range-bounds each v[i] to 128 bits and
spend.circom range-bounds both face and the witness v identically, so an
integer overshoot of totalFace by a multiple of r is structurally impossible
at either end.
Property C now rests on those range bounds plus the nullifier-uniqueness check and
Merkle membership.
Demurrage-Carrying Transfers
The Note-spend payout in Step 5 invoked buck.transferCarrying, a small sibling
of the standard BUCK transfer that the Notes pool uses on its single egress.
This section unpacks why one new method – not a per-account mode flag, not a
routing decision, not a marker interface – is sufficient, and what its ledger
effect looks like.
Two Ways to Handle Demurrage at Transfer Time
BUCK demurrage accumulates via a global cumIndex advancing at
BASE_RATE_PER_SEC and a per-account _indexAtLastTouch. An account's accrued
fee is the gap (cumIndex - _indexAtLastTouch) \cdot balance at the moment the
account is "touched" (standard transfer, settle, or carry). Two ledger responses
to that accrued fee are distinguishable at transfer time:
- Deducting transfer (
transfer/transferFrom, the default). On a 1,000-BUCK outgoing, Alice's accrued fee for the pre-transfer balance is settled at Alice – one-shot burned against her balance, resetting_indexAtLastTouch[alice]to the currentcumIndex. The 1,000 BUCK then lands at Bob fresh: Bob's index is merged by a balance-weighted average towardcumIndex, diluting Bob's effective age. From a system perspective, accrued fees are burned at each transfer and total BUCK-age drops on Alice's side while the recipient enters fresh. - Carrying transfer (
transferCarrying, the new variant). On the same 1,000-BUCK outgoing, nothing settles at the sender: Alice's_indexAtLastTouchis unchanged, and her remaining balance keeps its age. Bob's index is merged by a balance-weighted average toward Alice's pre-transfer_indexAtLastTouch, so Bob inherits the age of the BUCKs he just received. Nominal balances move by exactly 1,000 on each side; no fee is burned; total system BUCK-age is preserved.
The invariant the carrying variant keeps is: nominal BUCK out = nominal BUCK in (no silent burn at the transfer site), with the recipient's forward index shift representing the demurrage they will pay on their next standard outgoing transfer.
Why the Note Pool Needs It
Three structural features of the pool make the deducting default wrong:
- Per-note age opacity. When Alice mints six notes, the pool records only six opaque Poseidon commitments. The pool cannot deduct "the demurrage Alice's specific note owes" at spend time, because the chain does not distinguish one commitment's age from another. Any per-note fee would have to be computed off the pool aggregate, which would force Bob to pay fees that the pool's other depositors owed – and, worse, differentiate one commitment's payout from another, destroying the anonymity set.
- Face-to-payout identity.
faceis the public SNARK output and the value the mint circuit committed to. A deducting pool transfer would deliverface - feeto Bob while the proof claimedface. This would break the mint-to-spend value-conservation invariant: the SNARK's promise would not match the ERC-20 ledger movement, and the spend circuit would have to become aware of demurrage accounting to compensate. - Pool as bookkeeping buffer. The pool is not a long-term BUCK holder; every BUCK that enters is earmarked for one future redeemer. Deducting fees at the pool's egress would siphon value into a burn that the pool has no way to replenish, producing a cumulative insolvency as outstanding notes circulate.
transferCarrying fixes all three: no per-note age, face to payout, no pool
insolvency.
Shipped Surface in Buck.sol
The entire extension is one method plus one event:
event TransferCarrying(address indexed from, address indexed to, uint256 amount);
/// @notice Transfer `amount` to `to` carrying its accumulated BUCK-age. No fee
/// is burned at transfer time; recipient's index is shifted backward by
/// a balance-weighted average so total system age is preserved.
function transferCarrying(address to, uint256 amount) external returns (bool);
The preconditions match transfer: sender and recipient both isVerified
in the IdentityRegistry, recipient not the Jubilee address (the Jubilee is
not an accepted carry destination). The bilateral receipt-fragment check
applies as on transfer: an EOA-to-EOA Encrypted-Identity pair must have
a prior approve-time CP receipt, and Public-Identity contracts (those bound
via bindContract(target, pk, E, true)) fall back to the deterministic
_identityHash on the public side. The weighted-merge update on the
recipient is:
_indexAtLastTouch[to] =
(br * _indexAtLastTouch[to] + amount * _indexAtLastTouch[from]) / (br + amount);
– the sender's current _indexAtLastTouch is the value merged in, not the
global cumIndex. The sender's index is left untouched, and the fee burn path
in _update is skipped via the settling flag so the carried merge survives.
There is deliberately no per-account mode flag, no marker interface, no
governance-gated whitelist, and no balance precondition. Any registered
account may invoke transferCarrying; it is a strictly additive method. What
makes it pool-friendly is the semantics (no settle-at-sender, weighted-merge
at recipient), not a policy toggle.
Who Else Might Call It
The Note pool is the first and canonical caller, but the method is not
pool-private. Any registered account can invoke transferCarrying whenever
carrying demurrage forward to the recipient is the right economic choice –
escrow accounts whose beneficiaries are the true owners, payroll contracts
pulling from an employer, cross-chain bridge egresses. The semantic invariant
is "the source is a transitory holder of BUCK earmarked for the recipient," and
in that case carrying the age forward matches the economic reality.
For direct peer-to-peer payments the deducting default remains correct: the sender is paying from their own pocket and is the natural place to burn their own accrued fee.
Open Question: Identity Receipts on Carry
The shipped transferCarrying uses the same identity predicate as
transfer – both parties isVerified, with the receipt-fragment fallback
to _identityHash on the public side when at least one party has
isPublicIdentity = true=. Unlike approve, transferCarrying does not
require its own per-call Chaum-Pedersen receipt; the Notes pool side relies
on its bound Public Identity to satisfy the bilateral check. Whether
transferCarrying for two Encrypted-Identity counterparties should require
a per-call CP receipt is an open question; for the Notes pool it is moot
(the pool is bound under a Public Identity), but if peer-to-peer carry is
ever opened up an approve-style receipt may be wanted for auditability.
See the Open Work Items in alberta-buck-ethereum.org.
Putting the Guarantees Side-by-Side
The mint walkthrough established five guarantees; the spend walkthrough adds four more that become concrete only once the redemption path exists.
| Stage | Guarantee | Mechanism |
|---|---|---|
| Mint | Atomic conservation | Single EVM call: proof verify, transferFrom, append-cms all-or-nothing |
| Mint | Issuer-bound, recipient-blinded | msg.sender is public; cm_i is Poseidon-hidden over (flavor, v, rho, idHash, pred) |
| Mint | Per-note disclosure (recipient only) | Off-chain opening contains exactly one note's witness fields |
| Mint | Offline receipt reconstruction | T_mint calldata + opening + on-chain Identity lookup is sufficient and self-checkable |
| Mint | Adversarial-prover soundness | Groth16 knowledge soundness on fixed on-chain verifier; ERC-20 balance/allowance integrity |
| Spend | Redemption unlinkable to specific cm |
cm is a private witness under public noteRoot; only nullifier + face + recipient revealed |
| Spend | Double-spend prevented | Deterministic nullifier + one-bit nullifiers mapping; rejected on reuse |
| Spend | Recipient-bound payout | recipient is a public SNARK input; proof rejects if re-targeted in-flight |
| Spend | Value conservation across mint<->spend | Range-bounded mint v[i] + spend face === v; cumulative payouts ≤ totalFace per batch |
All nine rows are enforced by code in the tree. Mint landed in two phases:
Phase 5 (Notes.sol with a stub verifier) and Phase 7 (per-leaf on-chain
Poseidon insertion with mint.circom, MintGroth16Verifier,
MintVerifierAdapter). Phase 7-bis moves the Merkle insertion into the
SNARK (mint_batch.circom, per-N Groth16 verifiers, per-N adapter dispatch);
the chain writes the SNARK-attested newRoot into the same 30-slot root-
history ring Notes.sol already maintained for the spend side. Spend is
unchanged by the pivot: spend.circom in bearer shape,
SpendGroth16Verifier, SpendVerifierAdapter, the on-chain Merkle
accumulator, and buck.transferCarrying. The IdentityRegistry provides the
issuer-attestation and recipient-verification legs (now extended with
verifySpendCP for A-flavor identity binding), and the formal proofs
(Theorems 7-11) provide the cryptographic backstop. The A-flavor identity
binding – Chaum-Pedersen equality on the depositor's registered credential
(Theorem 8 #3) – ships in Phase 8 V2 in two cooperating layers: (a) the
in-circuit Poseidon-8 binding gate in spend_a.circom V2 that pins E_n
to idHash at mint time, and (b) the off-chain DLEQ verified in
Notes.spendACP via the EIP-196 BN254 precompiles inside
IdentityRegistry.verifySpendCP. The on-chain DLEQ check is ~36K gas
(two ecMul + two ecAdd + Fiat-Shamir keccak), much cheaper than the
~+250K-R1CS prove-time cost of doing the same constraint over non-native
BN254 G1 in circom.
Open Questions and Forward-Looking Caveats
The example above uses the Phase-7-bis batch-mint + Phase-7 spend pipeline, extended with Phase 8 V2's off-chain CP-DLEQ identity binding for A-flavor spend. Six items are tracked in depth elsewhere; listed here so the reader is not misled about the current state.
- Range bounds on
v[i](resolved). Earlier drafts flagged the possibility thatsum(v[i]) === totalFaceinF_rwithout per-vrange checks would let an adversarial mint overshoottotalFaceby a multiple ofr. Bothmint_batch.circomandspend.circomnow range-bound each value viaNum2Bits(128). The integer-overflow attack is structurally impossible. Preserved here as a breadcrumb; see Open Question 1 in the proofs for the historical formulation. - Implicit transfer<->batch binding. The chain emits
TransferandMintedin the same transaction, but no on-chain batch identifier ties the BUCK transfer to the specificcmrange. Off-chain indexers reconstruct the binding from event correlation. Whether to add an explicitbatchIdstorage slot (and a matching public input to the spend circuit) is an open design question; see Open Question 2. - A-flavor identity binding (Phase 8 V2 shipped). The Phase-7
spend.circomenforces a bearer-shape (B-spend) discharge: knowledge of the Poseidon-5 opening is the spend authorization. Phase 8 ships A-flavor in two layers: V1'sspend_a.circomadds the addressed-spend scaffolding (A-tag nullifier, flavor in {A1, A2}); V2 layers the cryptographic identity gate via two cooperating components, glued by the newNotes.spendACPentrypoint. In-circuit,spend_a.circomV2 publishesE_nas 4 public inputs and enforces the Poseidon-8 bindingidHash === Poseidon-8(R_n.x, R_n.y, C_n.x, C_n.y, issuerData[0..3]), pinning the revealed ciphertext to the leaf at mint time (~265 R1CS). Off-chain,IdentityRegistry.verifySpendCPverifies the 4-element Chaum-Pedersen DLEQ via the EIP-196 BN254 precompiles – not inside the SNARK, since the DLEQ requires native BN254 G1 arithmetic that is prohibitively expensive in non-native circom (~+250K R1CS). The on-chain DLEQ check is36K gas (two ecMul + two ecAdd + Fiat-Shamir keccak) and confirms a single =sk_dep= satisfies both ~pk_dep === sk_dep * Gand the equal-plaintext relationC_n - sk_dep * R_n === C_reg - sk_dep * R_reg(Theorem 8 #3). All A1/A2 identity, security, and privacy properties enumerated in alberta-buck-notes.org remain in place; only the DLEQ verification location moved. Exercised end-to-end by 26 forge tests intest/SpendAVerifier.t.sol. - Variable-
Nbatch size (resolved via pinned set). Phase-7'smint.circomwas templated atN=2. Phase-7-bis generalises this: the wallet rounds any requested split up to the smallest pinned N >= N_requested from the deployed set (recommended {16, 128, 1024}), padding withv = 0dummy openings. Each pinned N has its own verifier contract; theMintVerifierAdapterdispatches oncms.length. Adding a new pin means running a new trusted-setup ceremony, deploying a new verifier, and registering it inmintVerifiers[N]– operationally additive, no protocol change. - Hash choice for
cmBatchHash(open). Two candidates: keccak256 (cheap on-chain, reconstructed from calldata in a few hundred gas) or Poseidon over the field (in-circuit "free" but ~30-60K gas to recompute on-chain). Default is keccak; see the design memo for the trade-off. Revisit if an on-chain Poseidon precompile ships. - Prover contention (resolved operationally, not protocolly). The batch-
mint pivot makes each mint a rollup-style transaction with a stale-state
guard on
oldRootandnextLeafIndex. Losers in a race re-prove; no BUCK is lost. Initial deployment uses wallet-side optimistic retry; a sequencer/aggregator is deferred until real-world contention warrants it. See Prover Contention and the Rollup Race above.
Cross-References
- alberta-buck-notes.org – architectural overview of the Notes primitive (flavors, Merkle tree, demurrage interaction).
- alberta-buck-notes-rollup-mint.org – Phase-7-bis batch-mint design memo: R1CS counts per N, gas projections, per-attack-vector hostile-Alice audit (A1-A12), concurrency model, migration plan.
- alberta-buck-proofs.org – formal correctness arguments (Theorems 7-11; Open Questions in Part IV).
- alberta-buck-ethereum.org – Solidity surface, gas costs, phase-by-phase implementation status, Open Work Items.
- alberta-buck-identity.org – IdentityRegistry, PS credentials, and the
bilateral ElGamal handshake that anchors
msg.senderto a verifiable identity. circuits/mint_batch.circom– the Phase-7-bis batch-mint circuit (one compile per pinned N).circuits/spend.circom– the Phase-7 spend circuit (bearer-shape).src/Notes.sol,src/MintGroth16Verifier*.sol,src/MintVerifierAdapter.sol,src/SpendGroth16Verifier.sol,src/SpendVerifierAdapter.sol– the on-chain code paths walked above.scripts/snark/prove_mint_batch.js,scripts/snark/prove_spend.js– the reference provers used in tests.test/MintVerifier.t.sol,test/SpendVerifier.t.sol– the Foundry tests that pin the soundness stories to executable assertions.