
One Notes.mint transaction takes 1,200 BUCK from Alice and splits it,
atomically, into six notes of denominations [1000, 100, 25, 25, 25, 25] –
and the public chain learns nothing about who received what. This document
walks that example end to end, executably: the SNARK-verified batch mint that
folds the note commitments into the on-chain Merkle tree, the off-chain
delivery and offline verification of each recipient's opening, and the three
Identity-M redemption flows – spendCoupledA2 (addressed, private issuer),
spendCoupledA1 (addressed, public issuer), and spendCoupledB1 (bearer,
public issuer) – by which a note is deposited by proving, in zero knowledge,
both which Identity may spend it and that the Identity is a registered
member of the on-chain accumulator.
The walkthrough demonstrates five concrete guarantees – atomic value
conservation, recipient-blinded commitments, per-note disclosure to recipients
only, offline receipt reconstruction from the transaction ID alone, and
soundness against an arbitrarily hostile issuer-side EVM and prover – and
audits the privacy surface step by step: each party can name its counterparty
under compulsion, while no third party can name either, or link mint to spend.
Every cryptographic step is exercised by the shipped alberta_buck.wallet code
in one seeded, deterministic session, with the output captured in place. (PDF, Text)
Introduction
This document is the narrative and executable companion to
alberta-buck-notes.org (architectural overview; together they are the two
living Notes references) 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 SNARK-verified
batch-mint pipeline, and then walks the inverse flows: A2 deposit (Bob redeems
his identity-targeted note with one folded proof) and
B1 deposit (Carol redeems a bearer note while the issuer learns who cashed it).
Every actor, every artifact, and every check is named.
The mint pipeline uses SNARK-verified batch minting: 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 is essentially constant in N – ~380K plus ~500 gas per leaf of calldata.
The spend's authority axis is the KYC-bound Identity, not an account. The
mailbox is a separate receiving key \( pk_{\text{recv}} = kG \), derived from
the wallet seed and not from the identity scalar, because that scalar is
handed to every counterparty and so cannot also open the recipient's notes.
Addressed spends (A1, A2) are one Groth16 proof over one witness
(deposit_fold_a1.circom, deposit_fold_a2.circom): \( k \) opens the note,
the deposit account's credential opens to the named Identity, and a
registered leaf \( \mathrm{Poseidon}(m, k, salt) \) binds the two, on a path
to the posted root. A2 adds the membership of the decrypted issuer Identity
under a second salt, the one the issuer ships. B1 is not that circuit. Its
two claims rest on one secret, so it keeps the depositor-binding sigma and
pairs it with identity_membership_b1.circom, whose blind is a multiple of
\( H_{\mathrm{PEDERSEN}} \), hashed to the curve so the discrete log is
unknown. Notes.spendCoupled{A1,A2} verify that one proof and
nothing else: the slot holding the gate cannot be cleared, and an addressed
spend with it unset reverts before the spend SNARK is even checked. There is
no weaker path to fall back to, which is the property the whole section rests
on. The dev setups are not ceremonies.
The cryptographic walkthroughs in this document are executable: the Python
blocks run against the shipped alberta_buck.wallet modules in one seeded,
deterministic session, and their printed output is captured below each block.
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.solandMintBatchN*Groth16Verifier.solreject every batch that does not satisfy the mint relation. The on-chain truth is what governs.
The one structural caveat (the pinned-N dispatch set under mint_batch.circom)
is addressed at the end and cross-referenced into the proofs and implementation
docs.
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,MintBatchN*Groth16Verifier.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 = Poseidon6( T_CM, flavor_i, v_i, rho_i, idHash_i, predicate_i )
T_CM is a fixed domain tag, keccak("AlbertaBuck/Notes/Commitment/v2") mod r, not part of
the opening: it makes the commitment its own function, disjoint from every other Poseidon hash
in the protocol.
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 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 = Poseidon6( T_CM, 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 one delicate piece of wallet-side
mint code; everything else is straightforward Poseidon arithmetic.
Step 2: Generate the Batch-Mint Groth16 Proof
Alice's wallet runs scripts/snark/prove_mint_batch.js. The mint_batch.circom
circuit at her chosen pin N* = 16 takes a witness that covers not just the
openings but the tree update itself, because the Merkle insertion lives inside
the SNARK.
Public signals (circom emits the circuit OUTPUTS first, then the public
inputs; arity 2N+4):
issuerMode[i] # i in 0..N-1: per-leaf PUBLIC=1 / PRIVATE=2 (a circuit OUTPUT,
# the deterministic flavor projection -- Notes.mint gates on it)
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
cm[i] # i in 0..N-1: the commitments, exposed DIRECTLY (not a batch hash)
The commitments are exposed as public inputs directly (no batch digest): each
input costs ~6K gas in the EVM verifier, cheaper than recomputing a keccak over
calldata on-chain, and far cheaper than an in-circuit Poseidon over N
elements. (A2 / private-issuer batches use a separate circuit, mint_batch_a2,
that exposes each leaf's eIss instead of issuerMode; see The Redemption and
the Decryptability companion.)
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] === Poseidon6(T_CM, flavor[i], v[i], rho[i], idHash[i], predicate[i]). N Poseidon-6 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 – the standard Tornado-style incremental-tree fold, performed 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). - Flavor range + issuer mode. Each
flavor[i] \in {A1=1, A2=2, B1=3}, andissuerMode[i]is the deterministic projection (A2 → PRIVATE, else PUBLIC), exposed as a public output. This is what makes a bearer leaf provably public-issuer (and a private-issuer bearer note unmintable – see the Decryptability companion, Why B2 is impossible).
Constraint counts work out to roughly 21,900 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 22,052 R1CS at N=1,
350,913 at N=16, and 701,697 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, the cm[i], and the per-leaf issuerMode[i] (flavor-class:
public vs private, no identity) leak.
Step 3: Submit Notes.mint On Chain
Alice now broadcasts a single transaction T_mint calling
// Minting is gated-only: a nameable issuer Identity is bound to every batch.
// Alice (a public Corporate Identity here) uses the PUBLIC overload:
notes.mint(
proofBytes,
oldRoot, newRoot, nextLeafIndex,
1200e18, // totalFace
[cm_0, cm_1, ..., cm_15], // cms.length === N* (here 16)
[PUBLIC, ..., PUBLIC], // issuerMode (a SNARK public input of mint_batch)
issuerSig // Schnorr by Alice's registered key over keccak(cms)
);
// A private (A2) issuer uses the other overload instead:
// notes.mint(..., cms, issuerMode, a2Bindings) // routes the proof to mint_batch_a2
// // and ties each leaf's eIss
// There is no unbound mint path -- an unnameable note cannot be created.
The honest EVM walks Notes.mint (the PUBLIC overload):
// Stale-state guards + field bounds (_verifyMintOrRevert).
require(cms.length > 0, "Notes: empty mint");
require(issuerMode.length == cms.length, "Notes: issuerMode/cms length");
require(oldRoot == roots[currentRootIndex], "Notes: stale oldRoot");
require(nextLeafIndex == self.nextLeafIndex, "Notes: stale nextLeafIndex");
require(newRoot != 0 && newRoot < FIELD_R, "Notes: bad newRoot");
// SNARK verify: the proof binds issuerMode + cm[] + the roots. The
// MintVerifierAdapter dispatches by cms.length to the per-N Groth16 verifier;
// public-signal vector is [issuerMode[0..N), oldRoot, newRoot, nextLeafIndex,
// totalFace, cm[0..N)] (outputs lead), arity 2N+4.
require(mintVerifier.verifyMint(
proofBytes, issuerMode, oldRoot, newRoot, nextLeafIndex, totalFace, cms
), "Notes: bad mint proof");
// Public-issuer binding (gated-only): every leaf must be PUBLIC-mode,
// msg.sender must be a registered PUBLIC Identity, and issuerSig must be a valid
// Schnorr over keccak256(cms). (The PRIVATE-mode overload instead routes the
// proof to mint_batch_a2 and verifies one A2 re-encryption binding per leaf,
// tying each committed leaf's eIss.)
require(identityRegistry.isPublicIdentity(msg.sender),
"Notes: public-mode leaf needs public issuer");
require(identityRegistry.verifyIssuerSchnorr(
msg.sender, keccak256(abi.encodePacked(cms)), issuerSig),
"Notes: bad issuer binding");
// Pull BUCK only after the proof is accepted, then advance (_advanceAndPull).
require(buck.transferFrom(msg.sender, address(this), totalFace),
"Notes: transfer failed");
currentRootIndex = (currentRootIndex + 1) % ROOT_HISTORY_SIZE;
roots[currentRootIndex] = newRoot;
self.nextLeafIndex += cms.length;
noteFaceSum += totalFace;
// Single batch event; cms[] live in calldata.
emit Minted(msg.sender, totalFace, /*startIndex=*/ nextLeafIndex,
/*count=*/ 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
cms[]are exposed directly as Groth16 public inputs (no batch digest), alongside the per-leafissuerMode[]the circuit emits, so any tampering withcms[]orissuerMode[]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 the2N+4-entry public-signal vector[issuerMode[0..N), oldRoot, newRoot, nextLeafIndex, totalFace, cm[0..N)](circuit outputs lead), and invokes the appropriateMintBatchN${N}Groth16Verifier. 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). This CP-bound approve is mandatory: Alice is a private EOA, and mutual decryptability requires a per-pair CP fragment for every private party (theidentityHashfallback is only valid for Public-Identity contracts). The pool was bound under a Public Identity (bindContract(notes, pk, E, true)), so the pool side falls back to_identityHash(notes)without requiring a fragment from the pool. The Notes operator decrypts Alice's CP ciphertext from theIdentityExchangeevent under subpoena. - 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. The contract carries no
commitmentExists[cm] duplicate check – not because duplicates are harmless,
but because they are 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(T_NF, rho, idHash) 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.
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 = Poseidon6(T_CM, 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-runMintVerifierAdapter.verifyMint(proofBytes, issuerMode[0..N), oldRoot, newRoot, nextLeafIndex, totalFace, cm[0..N))locally against the same on-chain bytecode, assembling the public-signal vector from the calldata exactly as the contract does.
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 mint verifier 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
MintBatchN*Groth16Verifier.sol (one per pinned N) was emitted from the Groth16
verification key produced by the trusted setup (production ceremony pending;
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 [issuerMode[0..N), oldRoot, newRoot, nextLeafIndex, totalFace,
cm[0..N)], there exists a witness
(flavor[i], v[i], rho[i], idHash[i], predicate[i], pathSiblings[i][*])_i such
that:
cm[i] = Poseidon6(T_CM, 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 - each
issuerMode[i]is the deterministic flavor projection (A2 → PRIVATE, else PUBLIC) of the witnessflavor[i].
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)and thecm[]/issuerMode[]vectors, 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 [issuerMode[0..N),
oldRoot, newRoot, nextLeafIndex, totalFace, cm[0..N)]. 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 or tampered cms[], the verifier
rejects.
The cm[i] are public inputs to the SNARK directly: the contract passes the
calldata cms[] straight into the verifier's public-signal vector, so any
mismatch between the commitments the proof sealed and the commitments the
transaction carries 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.
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
Poseidon6output 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-6 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 (spend.circom; see alberta-buck-ethereum.org) 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(cms[], 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 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(T_NF, rho,
idHash) means the first spend nullifies every copy. Alice pays twice for a
spend-once outcome.
Cross-Chain or Cross-Deployment Replay
The mint verifiers' verification keys are 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
Batch minting carries one genuinely operational concern: a mint transaction is
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
Poseidon6(T_CM, flavor, v, rho, idHash, predicate) == cmfrom the opening. - (Optional.) He re-runs
MintVerifierAdapter.verifyMint(proofBytes, issuerMode[0..N), oldRoot, newRoot, nextLeafIndex, totalFace, cm[0..N))against the per-N verifier keyed bycms.length, assembling the public-signal vector from the calldata exactly as the contract does, 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, without revealing which commitment in the pool he consumed.
There is one spend path, the Identity-M-bound deposit: Bob redeems by proving he is the KYC-registered Identity the note names – not a particular account – so he may use any Fountain account bound to his Identity point \( M_{\text{rec}} \), and he reveals no Identity to the chain. Every spend composes the same two halves over a single committed point \( P \):
- The note proof (this section, Steps 1–3): the kept, flavor-agnostic
spend.circomGroth16 proves the note's commitmentcmis in the pool tree under a publicnoteRootand emits a fresh, well-formednullifier– hiding which commitment was consumed.Notes.spendCoupled{A1,A2,B1}all reusespendVerifier.verifySpendfor exactly this. - The identity binding (Step 4): an EIP-196 Okamoto sigma + a membership proof bound to the same \( P \), proving the spender is a registered Identity. The flavor selects which sigma and what \( P \) commits – the issuer's \( M_I \) (A2), or the spender's own \( M_{\text{rec}}/M_{\text{dep}} \) (A1/B1). This is detailed per-flavor in The Identity-M Spend Path (A1, A2, B1); the walk below uses Bob's A2 note (addressed, private issuer) as the running example.
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
identity binding) \(\to\) the relevant
spendCoupled{A1,A2,B1}entrypoint proceeds \(\to\) the entrypoint invokesbuck.transfer(recipient, face)from the pool (Buck routes it through the Carrying path). 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 Poseidon6(T_CM, 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). 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 a fixed
tag, the witness rho, and the witness idHash bound at mint:
\[ nf \;=\; \mathrm{Poseidon}_3\!\bigl(T_{NF},\; \rho_0,\; \mathrm{idHash}_0\bigr). \]
The tag \( T_{NF} \) is keccak("AlbertaBuck/Notes/Nullifier/v2") mod r. It
domain-separates nullifier preimages from every other Poseidon hash in the
protocol, the commitment included, 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). - The note proof binds the opening and nullifier, not the identity. The
flavor-agnostic
spend.circomtreatsidHashas a witness scalar – it does not constrain it to a registered Identity. That binding is the separate Step-4 deposit gate: for A1/A2 one folded proof that recomputes this same nullifier from the \( idHash \) it opens, and shows the spender is the registered Identity paired with the key that reads the note; for B1 the EIP-196 depositor-binding sigma and a membership proof over its committed point. Keeping the identity binding out ofspend.circomkeeps the one proof every spend shares small. And because the deposit is identity-keyed (not account-keyed), an addressed note survives the loss of any single account key: re-register a fresh Fountain account on the same \( M_{\text{rec}} \) and deposit (recovery, not finality).
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 note 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 = Poseidon6(T_CM, 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(T_NF, rho, idHash). - 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.
This spend.circom note proof is flavor-agnostic: A1, A2, and B1 all use the
same circuit and the same on-chain spendVerifier.verifySpend check for the
cm-membership + nullifier discharge (one nullifier derivation). Bob's wallet
serializes the Groth16 proof into the standard
(uint256[2], uint256[2][2], uint256[2]) ABI tuple (256 bytes). The
flavor-specific identity material – the re-encryption eEnc and the fold
proof (A1/A2), or eDepForIss, the depositor-binding proof and its membership
proof (B1) – is assembled separately and submitted alongside the note proof in
Step 4.
Step 4: Submit Notes.spendCoupledA2 On Chain
Bob's note is A2 (private issuer), so he deposits via spendCoupledA2. Beyond
the Step 1–3 note proof he supplies exactly two things: eEnc – a fresh
re-encryption of the leaf ciphertext \( eIss \) under his mailbox key
\( pk_{\text{recv}} \) (same plaintext, new randomness, so the chain never
sees the mint-time ciphertext again) – and foldProof, the folded gate's
Groth16 triple (alberta_buck.wallet.deposit_fold):
notes.spendCoupledA2(proofBytes, noteRoot, identityRoot, nullifier, 1000e18, bob,
eEnc, foldProof);That is the whole addressed calldata, with the identity root Bob proved against. A sigma, a separate membership proof and a note-binding proof used to ride along beside it, sharing a public point; one witness states what they only jointly suggested, so they are gone and so is the point. The honest EVM walks:
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");
// (1) Note proof: cm in the pool tree under `root`, nullifier well-formed,
// flavor bound to this entry point.
require(spendVerifier.verifySpend(
proof, root, nullifier, face, recipient, block.chainid, FLAVOR_A2, 0
), "Notes: bad spend proof");
// (2) The identity root: one the registry still retains, posted within
// the age the Notes consumer declares (seven days).
require(identityRegistry.acceptsRoot(identityRoot, NOTES_MEMBERSHIP_CONSUMER),
"Notes: identity root not accepted");
// (3) The folded gate. The adapter reads msg.sender's registered key and
// credential and derives EVERY public input, so relation (2) is
// necessarily about an account that really is registered.
require(depositFoldVerifier.verifyFoldA2(
foldProof, nullifier, identityRoot, eEnc, msg.sender),
"Notes: bad folded deposit gate");
nullifiers[nullifier] = true;
noteFaceSum -= face;
require(buck.transfer(recipient, face), // pool isCarrying => Carrying path
"Notes: transfer failed");
emit SpentCoupledA2(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 in the proofs says this single-bit check is sufficient for double-spend prevention).- The note proof is valid against
SpendGroth16Verifiervia theSpendVerifierAdapter, which packs[root, nullifier, face, recipient, chainid]into the public-signal vector. By soundness Bob holds an opening of acmunderroot, withnullifier = Poseidon3(T_NF, rho, idHash). identityRootis accepted: the registry still holds it in its ring of 256 postings, and it was posted within the seven days Notes declares. That age is how long a holder cleared from its registry can still spend.verifyFoldA2succeeds. One proof shows that \( k \) openseEnc, thatmsg.sender's registered credential opens to the same \( m_{\text{rec}} \) the receiving leaf commits beside \( k \), that the decrypted issuer Identity is registered under the shipped salt, that the note's committed \( T \) opens against \( k \), and that the nullifier is the one this note's \( idHash \) produces. The adapter derives the public inputs, including the account read from the registry. There is no committed point in the statement, and the event logs none.
If any of (a)-(e) reverts, the whole transaction reverts: no BUCK moves, the
nullifier bit is never set, noteFaceSum is unchanged. Bob gets one attempt
per root window; a failed spend retries with a refreshed proof, but never
consumes a nullifier without paying out the BUCK.
An A1 note takes spendCoupledA1, the same fold with the face public and
no issuer-membership relation: eRec encrypts the plaintext
\( M_{\text{rec}} \) under \( pk_{\text{recv}} \). A B1 note takes
spendCoupledB1, where the sigma is verifyDepositorBinding and the
membership proof opens \( P_{\text{dep}} \) on \( H_{\mathrm{PEDERSEN}} \).
All three are detailed in The Identity-M Spend Path (A1, A2, B1).
Step 5: BUCK ERC-20 Pays Out (the Carrying path)
The call inside Notes.spendCoupledA2 is a plain buck.transfer(bob, 1000e18).
Because the Notes pool is bound isCarrying in the IdentityRegistry, BUCK
routes that transfer through its internal _carryingTransfer path on this
single egress. (spendCoupledA1 and spendCoupledB1 take the same path.)
Nothing in Buck.sol is special-cased for the pool: it is just a registered,
isCarrying 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 (a standard ERC-20 event; the carrying apportionment is internal, with no distinct event).Notes.Spent(nullifier, 1000e18, Bob)is emitted.Notes.nullifiers[nullifier]becomestrue.
What "carrying" means in the ledger. BUCK demurrage is a balance-time
integral: each account stores its buckSeconds (the crystallised
\( \int B\,dt \)) plus the timestamp of its last touch. A transfer from a
non-Carrying account settles the sender's accrued fee (a one-shot burn) and
hands the recipient fresh, zero-age BUCK. A transfer from an isCarrying
account – the _carryingTransfer path – differs in two surgical ways:
- No sender-side fee burn. The pool sheds a proportional slice of its live buck-seconds rather than burning a fee; its per-BUCK age is preserved.
-
Recipient absorbs the pool's age. The recipient inherits that slice of buck-seconds (
carried = liveBs * amount / poolRaw), added to its own:liveBs = pool.buckSeconds + pool.raw * elapsed; carried = liveBs * amount / pool.raw; pool.buckSeconds = liveBs - carried; // pool sheds its share recipient.buckSeconds += carried; // recipient inherits the age
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 inherited buck-seconds. 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. The Carrying path collapses the calculation to the pool's
average per-BUCK age at spend time: every spender of the same face absorbs the
same buck-seconds slice, 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.spendCoupledA2(proofBytes, noteRoot, identityRoot, nullifier,
1000e18, Bob, eEnc, foldProof)
events:
Buck.Transfer(Notes, Bob, 1000e18)
Notes.SpentCoupledA2(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? Three protections apply, the third being the folded
deposit gate:
- Opening secrecy.
spend.circomrequires the full note 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. - Identity binding via the folded gate. For an A2 deposit Bob
provides the ciphertext and one Groth16 proof. With the fold verifier
set,
spendCoupledA2checks that the receiving secret opens the ciphertext, that Bob's registered account opens to the Identity paired with that secret in the receiving leaf, and that the decrypted issuer is registered under the shipped salt. Alice cannot redeem to herself even knowing the opening: she does not hold \( k \), and no leaf pairs her Identity with Bob's mailbox.
The strict identity-binding guarantee is a chain-observable property when the fold verifier is set: one Groth16 accept is the gate. The note proof enforces the commitment and the nullifier; the fold enforces that the depositor is the Identity paired with the mailbox the note was keyed to.
B1 Bearer Notes
A B1 note (bearer, public issuer) is redeemed the same way, with the depositor
binding playing the role the fold does for A2: spendCoupledB1 calls
verifyDepositorBinding (the depositor re-encrypts its own \( M_{\text{dep}} \)
under the public issuer's key, coupled to its payout account) plus a membership
proof of \( M_{\text{dep}} \). Knowledge of the note opening is still the
bearer authorisation – if two parties hold copies of the same B1 opening
(a paper note photographed and forwarded), whichever submits first wins, the
intended civil-trust semantics of bearer cash – but the depositor is
additionally proven to be a registered Identity the issuer can name (see
The Identity-M Spend Path (A1, A2, B1)).
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.
Field wrap-around is foreclosed by range bounds: 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 rests on those range bounds plus the nullifier-uniqueness check and
Merkle membership.
The Identity-M Spend Path (A1, A2, B1)
Step 4 above showed the A2 deposit in the redemption walk; this section gives the per-flavour detail for all three (the notes-identity-axis unification). The three entry points are not one gadget. Addressed spends are the folded proof. The bearer spend is a sigma beside a membership proof, because its two claims share a secret and the addressed claims do not. Three entrypoints:
- A2 (addressed, private issuer) –
Notes.spendCoupledA2callsverifyFoldA2. The ciphertext is keyed to \( pk_{\text{recv}} \). The proof shows \( k \) opens it to a registered issuer Identity, under the salt the issuer shipped, and that the depositor's account is the Identity paired with that \( k \) in the receiving leaf. - A1 (addressed, public issuer) –
Notes.spendCoupledA1callsverifyFoldA1.eRecencrypts the plaintext \( M_{\text{rec}} \) under \( pk_{\text{recv}} \). The issuer is public, named at mint by the batch Schnorr. The face is a public input of the fold. - B1 (bearer, public issuer) –
Notes.spendCoupledB1. The depositor encrypts its own \( M_{\text{dep}} \) under the issuer's registered account key and provesverifyDepositorBinding. The membership proof isidentity_membership_b1, bound to the sigma's \( P_{\text{dep}} \) by the contract passing that point in. The issuer decrypts the event.
Each spend names the identity root it proved against, and Notes accepts
one the registry retains and posted within seven days. Roots are posted
by the aggregator the registry's root authority appoints. A receiving
leaf is admitted by its registry's certification, since register
refuses a caller-supplied leaf and must: a registration that accepted any
leaf would accept a leaf pairing any Identity with any key, which is
precisely what relation (3) relies on being impossible. Addressed
membership is inside the fold, as \( \mathrm{Poseidon}(\tau_R, m, k,
salt) \). B1's membership is a separate proof of a salted coordinate
leaf. Both prove 32-level paths: the registry's subtree, then the
aggregator.
The note tie for A1 and A2 is inside the fold: the circuit recomputes the nullifier from the \( idHash \) it opens, so a substituted ciphertext is a different note. Nothing beside the fold verifies an addressed spend.
The Shared Primitives
Before the flavour-specific walkthroughs, three primitives are common to all three:
- The identity accumulator. Registries keep private subtrees of
depth 12, an aggregator of depth 20 has their roots for leaves, and
its root is posted on chain into a ring of 256, each with its time.
registerrefuses a caller-supplied leaf. Every leaf function leads with its own v2 field tag \( \tau \): the addressed gate proves \( \mathrm{Poseidon}(\tau_R, m, k, salt) \), and B1 proves \( \mathrm{Poseidon}(\tau_S, M.x, M.y, salt) \). The unsalted point leaf remains for public trees and for the unilateral receipt'scontainscheck, which is a different statement from the spend. - B1's membership proof.
identity_membership_b1.circomproves \( T = b H_{\mathrm{PEDERSEN}} \), \( P_{\text{dep}} = M + T \) with the addition's precondition enforced, and the salted leaf's path. The contract passes the sigma's \( P_{\text{dep}} \) into the verifier. The generator is hashed to the curve, so the blind cannot be shifted onto another registered Identity – a circuit that WITNESSED \( T \) instead, on a generator whose logarithm is known, let an unregistered depositor spend. - The aggregator. Registries, feature authorities and regulators feed
subtree roots into a
CentralMerkleService(depth 20, up to 2^20 subtrees) whose root the registry's ring retains. Every level hashes alike, so a subtree path and the aggregator path compose into the one 32-level path the gates prove. The reference isalberta_buck.registry.merkle_service; Rust and JavaScript mirror it.
A2 Identity-Targeted Spend (The Unilateral Receipt Flow)
Bob's note is flavour A2 (addressed, private issuer). The note opening
carries \( eIss = (R_e, C_e) \) – the issuer's Identity \( M_I \)
encrypted under Bob's receiving key \( pk_{\text{recv}} \), plus the
anti-framing binding (verifyIssuerReenc) that ties \( eIss \) to the
issuer's own registered Identity, plus the issuer's naming salt and the
randomness \( r' \). Bob decrypts with \( k \), not with
\( m_{\text{rec}} \).
==== Step A0: Bob Holds the A2 Note ====
The artifact Bob received off chain:
note = (
flavor = A2, v = 1000e18, rho, idHash, predicate, # the commitment opening
eIss = (R_e, C_e), # M_I under pk_recv
T, # committed in idHash
r_prime, gamma, salt_iss, # what the fold needs
binding, # verifyIssuerReenc
mint_tx_ref, # where cm was folded
)
==== Step A1: Bob Chooses a Deposit Account ====
Bob selects any registered Fountain account bound to his identity scalar \( m_{\text{rec}} \). His wallet reads the account's registered \( (pk_{\text{dep}}, E_{\text{dep}} = (R_d, C_d)) \) from the IdentityRegistry. Because all accounts share the same \( m_{\text{rec}} \), he is free to use a fresh account every time – no re-registration, no key pinning.
==== Step A2: Build the Folded Witness ====
Bob builds ONE witness (alberta_buck.wallet.deposit_fold), and the gate is
one proof over it. Two facts have to be established and they rest on two
different secrets – \( k \) opens the note, \( m_{\text{rec}} \) owns the
account – so there is no scalar for a sigma's shared nonce to tie, and
proving them side by side would say nothing about their owner. A thief with
a stolen payload answers the reading half with the stolen \( k \) and the
Identity half with its OWN registered Identity, and both halves are true.
private: k, m_rec, sk_dep, salt, r', gamma, salt_iss, two Merkle paths,
eNote, eIss, T
public: nullifier, identityRoot, eEnc, and -- read from the registry, not
accepted from the prover -- pk_dep and E_dep for msg.sender
The relations, all in circuits/deposit_fold_a2.circom:
- \( k \) decrypts the note's ciphertext to a point \( M_I \), and \( eEnc \) re-encrypts that same \( M_I \) under the same \( k \).
- The account credential decrypts under \( sk_{\text{dep}} \) to \( M_{\text{rec}} = m_{\text{rec}} G \).
- \( \mathrm{Poseidon}(\tau_R, m_{\text{rec}}, k, salt) \) is a registered leaf. This is the relation a split gate leaves out, and the one the thief cannot satisfy: no registered leaf pairs ITS Identity with the key it stole.
- That leaf's path folds to the posted
identityRoot. - \( M_I \) is itself registered, as \( \mathrm{identity\_leaf\_salted}(M_I, salt_{\text{iss}}) \) under the salt the note shipped.
- The note's committed \( T \) opens as \( (r'k)G + \gamma H_{\mathrm{PEDERSEN}} \): the key the mint binding proved about is the key Bob holds.
Relation (3) used to be free, because the two scalars were one value. It is now the load-bearing one. And because the circuit hashes the very signals its other relations consume, a single tampered \( m_{\text{rec}} \) breaks the decryption, the credential and the leaf at once – there is no seam between the two secrets for a thief to work in.
A1 is the same gate with relations (5) and (6) removed and the face made public: its ciphertext decrypts to the recipient's OWN Identity, so relation (2) already covers what (5) would say, its public issuer has no key to re-aim, and it ships no witness material about anyone else.
==== Step A3: What the Prover Does Not Choose ====
DepositFoldVerifierAdapter derives every public input on chain. It reads
\( pk_{\text{dep}} \) and \( E_{\text{dep}} \) from IdentityRegistry for
msg.sender, takes identityRoot from the registry, and limb-encodes
eEnc itself. So relation (2) is necessarily about an account that really
is registered, and there is no committed point for the wallet to decouple
from anything – because there is no second proof to decouple it from.
==== Step A4: Submit the Deposit ====
notes.spendCoupledA2(
proof, // Groth16: note opening + noteRoot membership
noteRoot,
identityRoot, // the posted root the fold proves against
nullifier, // Poseidon(T_NF, rho, idHash)
face, // 1000e18
recipient, // Bob's payout address
eIss, // eEnc: the fresh re-encryption under pk_recv
foldProof // the folded gate's (a, b, c)
);
==== Step A5: On-Chain Verification ====
spendCoupledA2 gates on five conditions:
- The fold verifier is wired. It cannot be cleared, so this is a deployment invariant rather than a configuration; an addressed spend without it reverts here, before anything else is checked.
- Standard note spend:
noteRootaccepted,nullifierfresh, the note opening proof verifies (spendVerifier.verifySpend). identityRootis accepted for the Notes consumer: retained in the registry's ring and posted within seven days.- The folded gate:
verifyFoldA2(foldProof, nullifier, identityRoot, eEnc, msg.sender)accepts. - BUCK transfer, then
nullifiers[nullifier] = trueandSpentCoupledA2(nullifier, face, recipient).
The event publishes no point. An earlier shape logged the committed \( P_I \); a log is not a constraint, and logging a point that no relation reads invites an auditor to believe otherwise.
If any check fails the whole transaction reverts. No BUCK moves, no
nullifier is consumed. (End to end, with real proofs: NotesE2E.t.sol.)
What relation (5) closes. Suppose an issuer keys \( eIss \) to a throwaway point so the recipient cannot name it. Relations (1) to (4) still pass – Bob really can decrypt to some point. But that point is not a registered Identity, so no salt opens a leaf for it and relation (5) has no witness: the deposit reverts. There is no spendable-but-unnameable note.
What relation (6) closes. An issuer can instead key the ciphertext so that the key its binding proves about opens it to its own \( M_A \) while Bob's key opens it to a DIFFERENT registered Identity \( M_B \). The binding is true and relation (5) holds on \( M_B \)'s leaf, so without (6) Bob would honestly name \( M_B \). But the note commits the binding's \( T = r'\,pk + \gamma H_{\mathrm{PEDERSEN}} \), and (6) opens it against Bob's own \( k \): re-aiming the key needs \( M_B - M_A \) as a known multiple of \( H_{\mathrm{PEDERSEN}} \), a discrete logarithm (Proofs, Theorem 12). The executed transcript below mints exactly this note and watches the delivery, the fold and the receipt each refuse it.
==== Step A6: The Unilateral Receipt ====
After the spend confirms, Bob's wallet produces a unilateral receipt naming both parties – from \( k \) and his own Identity, two values rather than one derived from the other:
\begin{align*} M_I &= C_e - k R_e && (\text{the issuer Identity}); \\ M_{\text{rec}} &= m_{\text{rec}} G && (\text{the recipient's own}). \end{align*}
A verifiable decryption proof (verifiable_decrypt_prove, a
Chaum-Pedersen DLEQ) makes the receipt third-party-checkable without
revealing \( k \). The receipt tuple:
A court or auditor checks five links (verify_receipt):
| # | Check | Establishes |
|---|---|---|
| 1 | verifyIssuerReenc vs. issuer's on-chain record |
\( eIss \) carries the issuer's registered \( M_I \) (no framing) |
| 2 | verifiable_decrypt_verify(eIss, pk_recv, M_I) |
\( eIss \) decrypts under \( pk_{\text{recv}} \) to \( M_I \) |
| 3 | \( C_e - T + \gamma H_{\mathrm{PEDERSEN}} = M_I \) | the binding's Identity is the one decrypted (the key tie) |
| 4 | \( \ell(M_I) \in \mathsf{rt_{id}} \) | the issuer Identity is registered |
| 5 | \( \ell(M_{\text{rec}}) \in \mathsf{rt_{id}} \) | the recipient Identity is registered |
Full specification and tests: alberta-buck-notes-unilateral.
A2 in Code: The Full Cycle, End to End
The cryptography above is small enough to run. This section is a complete,
executing transcript against the shipped wallet
(alberta_buck.wallet.unilateral_a2 over BN254 \( \mathbb{G}_1 \)) –
mirroring test_unilateral_a2.py. The blocks below are org-babel source
blocks sharing one Python session: re-exporting this document re-runs them
top to bottom, and the printed RESULTS are regenerated. The session is
seeded, so a re-run reproduces this transcript byte for byte – and fails
loudly if the wallet API drifts. The wallet's curve arithmetic executes
on the compiled buck-identity kernel (buck_core.buck_identity,
arkworks BN254); the first block asserts it, so this document renders
only against the production cryptography. The pure-Python py_ecc path
remains the executable specification the kernel is proven bit-identical
against (core/vectors/identity-kernel-vectors.json).
The cast: two Identities, one of them with two accounts. An Identity is a KYC-bound scalar \( m \) and its point \( M = mG \). The Identity Fountain lets one \( m \) back unlimited unlinkable accounts, each an ElGamal encryption \( E_{\text{addr}} = (R, C) = (rG,\ M + r\,pk) \) of the same \( M \) under that account's own key.
import random
from alberta_buck.wallet import backend
from alberta_buck.wallet.bn254 import G1, ORDER, mul, add, neg, eq, rand_scalar
from alberta_buck.wallet.elgamal import elgamal_encrypt, elgamal_decrypt
from alberta_buck.wallet.unilateral_a2 import (
IdentityTree, mint_unilateral_a2,
make_receipt, verify_receipt,
)
from alberta_buck.wallet.recvkey import receiving_key
from alberta_buck.wallet.salt import derive_salt
from alberta_buck.wallet.deposit_fold import (
DepositFoldRefused, deposit_fold_check, deposit_fold_a2_witness,
deposit_fold_witness,
)
from alberta_buck.registry.tree import identity_leaf_salted, receiving_leaf
from alberta_buck.registry.merkle_service import rooted_registry
assert backend() == "kernel", "must run the production kernel: evaluate in the repo venv"
print(f"wallet crypto backend = {backend()} (buck_core.buck_identity)")
_seed = random.Random(0xA1BE27AB0CC) # ONE deterministic transcript
rng = lambda: _seed.getrandbits(256)
CHAINID = 1
def account(m): # one Fountain account bound to identity m
sk = rand_scalar(rng); pk = mul(G1, sk)
M = mul(G1, m) # the shared Identity point
r = rand_scalar(rng) # registration randomness; the fold witnesses it
E = elgamal_encrypt(M, pk, r) # registered credential
return dict(m=m, M=M, sk=sk, pk=pk, E=E, r=r)
def pt(P): # compact point display
return f"({int(P[0]) % 10**8:>8d}..., {int(P[1]) % 10**8:>8d}...)"
m_iss, m_rec = rand_scalar(rng), rand_scalar(rng)
issuer = account(m_iss) # the payer (private issuer)
rec0 = account(m_rec) # recipient account #0
rec1 = account(m_rec) # recipient account #1 (SAME identity)
M_rec = mul(G1, m_rec) # what the issuer learns out of band
tree = IdentityTree() # unsalted points: what a receipt checks
for a in (issuer, rec0): # one leaf per *identity*
tree.insert(a["M"])
# The mailbox is not the Identity. k comes from the wallet seed, and the
# private registry commits the pair (m_rec, k) under the holder's salt.
# The issuer's SECOND association is an ordinary salted identity leaf: its
# salt is what an A2 payload ships, and it says nothing about the issuer's
# own mailbox. The registry's subtree is enrolled under an aggregator, so
# its paths are the 32-level paths the gates prove: 12, then 20.
KYC = "kyc:ca-ab-2026"
seed_rec = rand_scalar(rng)
k_rec, pk_recv = receiving_key(seed_rec)
salt_rec = derive_salt(seed_rec, KYC)
seed_iss = rand_scalar(rng)
salt_iss = derive_salt(seed_iss, KYC, 1)
priv = rooted_registry(KYC)
priv.insert_leaf(identity_leaf_salted(issuer["M"], salt_iss))
priv.insert_receiving(m_rec, k_rec, salt_rec)
print(f"issuer Identity M_I = {pt(issuer['M'])}")
print(f"recipient Identity M_rec = {pt(M_rec)} (accounts rec0, rec1)")
print(f"receiving key pk_recv = {pt(pk_recv)} (not M_rec)")
print(f"identity tree root = {hex(tree.root())[:20]}...")
print(f"posted identity root = {hex(priv.root())[:20]}... ({priv.depth}-level paths)")wallet crypto backend = kernel (buck_core.buck_identity) issuer Identity M_I = (34794633..., 55370756...) recipient Identity M_rec = (78777287..., 1501867...) (accounts rec0, rec1) receiving key pk_recv = (27820058..., 70606247...) (not M_rec) identity tree root = 0xff9f26af3a3e77cf95... posted identity root = 0x638a64903f90cc47d9... (32-level paths)
Mint: encrypt the issuer's own Identity under the recipient's mailbox.
The issuer addresses the note to the Identity and keys both ciphertexts to
\( pk_{\text{recv}} \), learned on the same out-of-band channel that carries
\( M_{\text{rec}} \). It needs nothing from the recipient at payment time.
The anti-framing binding (verifyIssuerReenc, with the recipient slot set
to \( pk_{\text{recv}} \)) proves \( eIss \) re-encrypts the issuer's own
registered \( M_I \). The mint object carries \( salt_{\text{iss}} \), the
salt of the issuer's naming association, and \( r' \), the randomness of
\( eIss \). The fold checks \( eIss.R = r'G \) and opens
\( \mathrm{identity\_leaf\_salted}(M_I, salt_{\text{iss}}) \); neither
value is the issuer's mailbox salt. The binding's \( T \) is committed in
\( idHash \) beside both ciphertexts, and its blind \( \gamma \) is the
third thing the recipient needs. The delivery (wallet/delivery.py)
carries \( r' \), \( \gamma \) and \( salt_{\text{iss}} \) wrapped to
\( pk_{\text{recv}} \), with \( T \) in the clear.
note = mint_unilateral_a2(issuer["sk"], issuer["E"], pk_recv,
v=1000, rho=rand_scalar(rng),
issuer=0xA11CE, chainid=CHAINID,
salt_iss=salt_iss, rng=rng)
assert eq(note.M_I, issuer["M"])
assert note.salt_iss == salt_iss
print(f"minted A2 note: v = {note.opening.v}")
print(f" cm = {hex(note.cm)[:20]}... (the only on-chain artifact)")
print(f" idHash = {hex(note.idHash)[:20]}... (Poseidon11(T_ID, eNote, eIss, T))")
print(f" eIss = ElGamal(M_I under pk_recv) R = {pt(note.eIss.R)}")minted A2 note: v = 1000 cm = 0xdfb168e9bc18175305... (the only on-chain artifact) idHash = 0x1d41ad0b39ccfc02fa... (Poseidon11(T_ID, eNote, eIss, T)) eIss = ElGamal(M_I under pk_recv) R = (49017840..., 29504036...)
Delivery: the recipient names the issuer with the receiving secret. The decryption secret is \( k \), not \( m_{\text{rec}} \). Every account the recipient owns shares \( m_{\text{rec}} \); none of them opens the note. \[ C_e - k\,R_e = (M_I + r'\,pk_{\text{recv}}) - k(r'G) = M_I. \]
M_I = elgamal_decrypt(note.eIss, k_rec)
assert eq(M_I, issuer["M"])
opened_by_m = eq(elgamal_decrypt(note.eIss, m_rec), issuer["M"])
print("recipient decrypts eIss with k -> the issuer's registered Identity:")
print(f" C_e - k*R_e = {pt(M_I)} == M_I")
print(f" the identity scalar opens it: {opened_by_m}")recipient decrypts eIss with k -> the issuer's registered Identity: C_e - k*R_e = (34794633..., 55370756...) == M_I the identity scalar opens it: False
Deposit from ANY account bound to \( m_{\text{rec}} \).
Authority is the Identity, so both rec0 and rec1 spend the same note
under the one mailbox key. The gate is one witness. It refuses unless
\( k \) decrypts the ciphertext, the account credential decrypts to
\( M_{\text{rec}} \), and a registered leaf commits the pair
\( (m_{\text{rec}}, k) \) under the holder's salt, on a path to the posted
root. There is no shared public point for a second proof to meet at.
for name, acct in (("rec0", rec0), ("rec1", rec1)):
w_acct = deposit_fold_witness(
m_rec=m_rec, k=k_rec, sk_dep=acct["sk"], salt=salt_rec,
E_dep=acct["E"], note_ct=note.eIss, tree=priv)
ok = deposit_fold_check(
w_acct, pk_dep=acct["pk"], E_dep=acct["E"],
note_ct=note.eIss, root=priv.root())
print(f"folded gate from {name}: verifies = {ok}")folded gate from rec0: verifies = True folded gate from rec1: verifies = True
On chain the same statement is one Groth16 proof.
DepositFoldVerifierAdapter.verifyFoldA2 derives every public input: the
nullifier, the posted identity root, the ciphertext, and the depositor's
registered key and credential, the last two read from the registry rather
than accepted from the caller. The proof bytes are only the Groth16
triple, and they are the whole of the addressed calldata beside the
re-encryption:
// Notes._spendCoupled (abridged).
require(identityRegistry.acceptsRoot(identityRoot, NOTES_MEMBERSHIP_CONSUMER),
"Notes: identity root not accepted");
bool ok = depositFoldVerifier.verifyFoldA2(
foldProof, nullifier, identityRoot, eEnc, msg.sender);
require(ok, "Notes: bad folded deposit gate");
The fold witness: the note tie and the issuer's naming leaf, in one proof.
The spend supplies a fresh re-encryption of \( eIss \) under the same
\( pk_{\text{recv}} \). deposit_fold_a2_witness is the JSON the circuit
consumes. It asserts the four relations above, the fifth – the decrypted
issuer Identity opens identity_leaf_salted(M_I, salt_iss), and that
leaf's path folds to the same root – and the sixth, \( T \) opening
against \( k \). A wrong salt is not a member.
s = rand_scalar(rng)
t = (note.r_prime + s) % ORDER
eEnc = elgamal_encrypt(note.M_I, pk_recv, t)
w = deposit_fold_witness(
m_rec=m_rec, k=k_rec, sk_dep=rec0["sk"], salt=salt_rec,
E_dep=rec0["E"], note_ct=note.eIss, tree=priv)
iss_leaf = identity_leaf_salted(issuer["M"], salt_iss)
iss_path = priv.path(priv.leaves.index(iss_leaf))
fold = deposit_fold_a2_witness(
witness=w, rho=note.opening.rho, id_hash=note.idHash,
e_note=note.eNote, e_iss=note.eIss, r_prime=note.r_prime,
t=t, r_E=rec0["r"], e_dep=rec0["E"], pk_dep=rec0["pk"],
e_enc=eEnc, salt_iss=salt_iss, iss_path=iss_path,
T=note.binding.T, gamma=note.gamma, identity_root=priv.root())
print(f"A2 fold witness built: {len(fold)} signals, relations asserted")
print(f" public nullifier = {hex(int(fold['nullifier']))[:20]}...")
print(f" eEnc.R limb0 = {fold['eEncRx'][0]} (fresh re-encryption)")
assert fold["saltIss"] == str(salt_iss)
assert int(fold["eEncRx"][0]) != 0
try:
deposit_fold_a2_witness(
witness=w, rho=note.opening.rho, id_hash=note.idHash,
e_note=note.eNote, e_iss=note.eIss, r_prime=note.r_prime,
t=t, r_E=rec0["r"], e_dep=rec0["E"], pk_dep=rec0["pk"],
e_enc=eEnc, salt_iss=(salt_iss + 1) % ORDER, iss_path=iss_path,
T=note.binding.T, gamma=note.gamma, identity_root=priv.root())
print("wrong issuer salt: BUILT (must not happen!)")
except AssertionError as exc:
print(f"wrong issuer salt: REFUSED ({exc})")A2 fold witness built: 34 signals, relations asserted public nullifier = 0x13bfa2e1a4b72873ff... eEnc.R limb0 = 5454261353227722156 (fresh re-encryption) wrong issuer salt: REFUSED (the shipped issuer salt does not open the issuer's leaf)
Proving this witness is the heavyweight step – 3,833,338 constraints along
32-level paths – and it runs in the build pipeline
(scripts/snark/setup_deposit_fold_a2.sh) rather than in this session.
The setups are dev entropy. test/DepositFoldA2Verifier.t.sol verifies a
real proof on chain and requires every public input, walked alone, to
break it.
The unilateral receipt: the recipient names BOTH parties, alone. From \( k \) and its own Identity – two values, not one derived from the other – the recipient recovers \( M_I \) and produces a third-party-checkable receipt. The verifiable decryption is under \( pk_{\text{recv}} \). The membership checks below are membership of the two Identity points in the unsalted tree the receipt was given. They are not the spend's receiving-leaf check; that check is the fold.
receipt = make_receipt(k_rec, M_rec, note, issuer=0xA11CE, chainid=CHAINID,
tree=tree, rng=rng)
res = verify_receipt(receipt, issuer["pk"], issuer["E"], tree.root(), tree)
assert res.valid
assert eq(res.issuer_M, issuer["M"])
assert eq(res.recipient_M, M_rec)
assert res.value == 1000
assert eq(receipt.pk_recv, pk_recv)
print(f"unilateral receipt: {res.reason}")
print(f" names issuer {pt(res.issuer_M)}")
print(f" names recipient {pt(res.recipient_M)}")
print(f" decrypts under {pt(receipt.pk_recv)}")
print(f" value {res.value}")unilateral receipt: VALID names issuer (34794633..., 55370756...) names recipient (78777287..., 1501867...) decrypts under (27820058..., 70606247...) value 1000
A stolen payload does not spend, and a throwaway key does not name. A thief holding \( k \) and its own registered Identity satisfies each half of a split gate. The fold refuses on the leaf: no registered leaf pairs the thief's Identity with the key it stole. Separately, a ciphertext keyed to a key the recipient does not hold decrypts to garbage, and that point is not a registered Identity, so the receipt does not verify.
m_thief = rand_scalar(rng)
thief = account(m_thief)
seed_t = rand_scalar(rng)
k_t, _pk_t = receiving_key(seed_t)
salt_t = derive_salt(seed_t, KYC)
priv.insert_receiving(m_thief, k_t, salt_t)
try:
deposit_fold_witness(
m_rec=m_thief, k=k_rec, sk_dep=thief["sk"], salt=salt_t,
E_dep=thief["E"], note_ct=note.eIss, tree=priv)
print("thief witness: BUILT (must not happen!)")
except DepositFoldRefused as exc:
print(f"thief witness: REFUSED on relation {exc.relation}")
pk_bogus = mul(G1, rand_scalar(rng))
eIss_bogus = elgamal_encrypt(issuer["M"], pk_bogus, rand_scalar(rng))
M_I_bogus = elgamal_decrypt(eIss_bogus, k_rec)
print(f"ciphertext under a throwaway key decrypts to {pt(M_I_bogus)}")
print(f" that point is a registered identity: {tree.contains(M_I_bogus)}")thief witness: REFUSED on relation 3 ciphertext under a throwaway key decrypts to (49911686..., 9395411...) that point is a registered identity: False
A split key names no puppet. The mint binding proves \( eIss \) carries
the minter's own Identity to the key hidden in \( Q \), and an ElGamal
ciphertext does not commit to its key. So a minter holding a registered
sock puppet \( M_B \) picks
\( pk_{\text{mint}} = pk_{\text{recv}} + r'^{-1}(M_B - M_I) \): the same
\( eIss \) is its own \( M_I \) under \( pk_{\text{mint}} \) and
\( M_B \) under Bob's key. The binding verifies, relation (5) holds on
\( M_B \)'s leaf, and without more an honest Bob would name the puppet.
The more is \( T \): the note commits it, and it opens under the key the
minter chose, not Bob's. Bob's wallet refuses the delivery, the fold
refuses relation (6), and the receipt refuses to name \( M_B \).
mint_unilateral_a2 cannot produce this note, so it is minted by hand:
from alberta_buck.wallet.issuer_reenc import issuer_reenc_prove, issuer_reenc_verify
from alberta_buck.wallet.unilateral_a2 import MintedA2, a2_id_hash
from alberta_buck.wallet.notes import FLAVOR_A2, NoteOpening, note_commitment
from alberta_buck.wallet.delivery import DeliveryRefused, deliver_a2, open_a2
_seed_sp = random.Random(0x5B1175) # local; the shared transcript is untouched
rng_sp = lambda: _seed_sp.getrandbits(256)
M_B = mul(G1, rand_scalar(rng_sp)) # the minter's registered sock puppet
salt_B = derive_salt(rand_scalar(rng_sp), KYC, 1)
priv.insert_identity_salted(M_B, salt_B)
tree.insert(M_B)
r_s = rand_scalar(rng_sp)
pk_mint = add(pk_recv, mul(add(M_B, neg(issuer["M"])), pow(r_s, -1, ORDER)))
eIss_s = elgamal_encrypt(issuer["M"], pk_mint, r_s)
gamma_s = rand_scalar(rng_sp)
bind_s = issuer_reenc_prove(issuer["sk"], r_s, pk_mint, issuer["E"], eIss_s,
0xA11CE, CHAINID, gamma=gamma_s, rng=rng_sp)
rn_s = rand_scalar(rng_sp)
eNote_s = elgamal_encrypt(mul(G1, 1000), pk_recv, rn_s)
idh_s = a2_id_hash(eNote_s, eIss_s, bind_s.T)
open_s = NoteOpening(FLAVOR_A2, 1000, rand_scalar(rng_sp), idh_s, 0)
split = MintedA2(eNote=eNote_s, eIss=eIss_s, M_I=issuer["M"], idHash=idh_s,
cm=note_commitment(open_s), opening=open_s, binding=bind_s,
r_prime=r_s, r_note=rn_s, gamma=gamma_s, salt_iss=salt_B)
print(f"binding verifies against Alice's record: "
f"{issuer_reenc_verify(issuer['pk'], issuer['E'], eIss_s, bind_s, 0xA11CE, CHAINID)}")
print(f"Bob's k opens eIss to the puppet: {eq(elgamal_decrypt(eIss_s, k_rec), M_B)}")
try:
open_a2(deliver_a2(split, pk_recv), k_rec)
print("delivery: OPENED (must not happen!)")
except DeliveryRefused as exc:
print(f"delivery: REFUSED ({exc})")
t_s = (r_s + rand_scalar(rng_sp)) % ORDER
eEnc_s = elgamal_encrypt(M_B, pk_recv, t_s)
w_s = deposit_fold_witness(m_rec=m_rec, k=k_rec, sk_dep=rec0["sk"], salt=salt_rec,
E_dep=rec0["E"], note_ct=eEnc_s, tree=priv)
try:
deposit_fold_a2_witness(
witness=w_s, rho=open_s.rho, id_hash=idh_s, e_note=eNote_s, e_iss=eIss_s,
r_prime=r_s, t=t_s, r_E=rec0["r"], e_dep=rec0["E"], pk_dep=rec0["pk"],
e_enc=eEnc_s, salt_iss=salt_B,
iss_path=priv.path(priv.leaves.index(identity_leaf_salted(M_B, salt_B))),
T=bind_s.T, gamma=gamma_s, identity_root=priv.root())
print("fold: BUILT (must not happen!)")
except AssertionError as exc:
print(f"fold: REFUSED ({exc})")
res_s = verify_receipt(make_receipt(k_rec, M_rec, split, issuer=0xA11CE, chainid=CHAINID,
tree=tree, rng=rng_sp),
issuer["pk"], issuer["E"], tree.root(), tree)
print(f"receipt: {'VALID' if res_s.valid else 'INVALID'} ({res_s.reason})")binding verifies against Alice's record: True Bob's k opens eIss to the puppet: True delivery: REFUSED (T does not open to this mailbox under the unwrapped gamma) fold: REFUSED (T != rm*G + gamma*H (keyed to another mailbox)) receipt: INVALID (the binding's Identity is not the one decrypted)
That is the guarantee: the recipient holds the secret that names the issuer, the identity scalar does not, a stolen mailbox key does not spend under a different Identity, and a key chosen to frame a puppet does not name one.
The Receiving Key: Your Identity Is Not Your Mailbox Key
An addressed note names an Identity and is keyed to that Identity's receiving key. Those are two different objects doing two different jobs, and it is worth being clear why, because collapsing them into one is the most natural design in the world and it does not work.
The read/write split makes \( m \) a read capability: the design hands it to every counterparty, because that is how a receipt names a person. So it cannot also be the thing that opens the recipient's mail. One disclosure would read every note that person ever receives, in both directions in time. A read capability you give away cannot be a decryption key you keep.
The arithmetic is even less forgiving than the principle. Suppose a leaf encrypted the recipient's Identity under itself: \[ eRec = (r'G,\; M_{\text{rec}} + r'M_{\text{rec}}) = (r'G,\; m_{\text{rec}}(G + R)). \] Message and key would share one secret, so the Diffie-Hellman hardness protecting every other ciphertext here would simply be absent. A candidate Identity tests with one scalar multiplication – no proof, no key, no issuer secret – and an issuer holding its client list could ask "is any note in existence addressed to anyone I certified?" and get answers.
Keying the same plaintext to \( pk_{\text{recv}} \) instead turns that question into a decisional Diffie-Hellman decision, which is the lock the rest of this design already rests on. Run both and watch the difference:
import random
from alberta_buck.wallet.elgamal import elgamal_encrypt, elgamal_decrypt
from alberta_buck.wallet.salt import derive_salt
from alberta_buck.wallet.deposit_fold import (
DepositFoldRefused, deposit_fold_witness,
)
from alberta_buck.registry.tree import IdentityMerkleTree
_seed_rk = random.Random(0xBADDECAF) # local; the shared transcript is untouched
rng_rk = lambda: _seed_rk.getrandbits(256)
m_rec_rk = rand_scalar(rng_rk) # the recipient's Identity
M_rec_rk = mul(G1, m_rec_rk)
k_rk = rand_scalar(rng_rk) # the recipient's RECEIVING key
pk_demo = mul(G1, k_rk) # local; must not clobber the session's pk_recv
# A harvester's candidate list: every Identity it ever certified, plus the one
# it is hunting. It holds no keys and needs none.
cands = [rand_scalar(rng_rk) for _ in range(8)] + [m_rec_rk]
# Today's A1 leaf ciphertext: the Identity encrypted under ITSELF.
eRec_today = elgamal_encrypt(M_rec_rk, M_rec_rk, rand_scalar(rng_rk))
hit = [c for c in cands if eq(eRec_today.C, mul(add(G1, eRec_today.R), c))]
print(f"today: C == m*(G+R) over {len(cands)} candidates -> {len(hit)} hit"
f" ({'IDENTIFIED' if hit == [m_rec_rk] else 'no'})")
# The same plaintext under a key that is not the Identity.
eRec_new = elgamal_encrypt(M_rec_rk, pk_demo, rand_scalar(rng_rk))
hit2 = [c for c in cands if eq(eRec_new.C, mul(add(G1, eRec_new.R), c))]
print(f"fixed: the same scan over the same candidates -> {len(hit2)} hit")
print(f" recipient still reads it with k: {eq(elgamal_decrypt(eRec_new, k_rk), M_rec_rk)}")
print(f" the Identity scalar no longer does: {eq(elgamal_decrypt(eRec_new, m_rec_rk), M_rec_rk)}")
today: C == m*(G+R) over 9 candidates -> 1 hit (IDENTIFIED)
fixed: the same scan over the same candidates -> 0 hit
recipient still reads it with k: True
the Identity scalar no longer does: False
Nine candidates, one hit under the collapsed design, zero under the real one. The recipient still reads its mail; it reads it with \( k \), a secret it hands to nobody.
Two secrets need a gate that joins them. Separating reading from Identity buys the privacy above and creates an obligation: the deposit gate must say something a single-secret design would get for free. "I can read this note" and "I am this registered Identity" are facts about two different secrets, and a gate that proved them side by side would say nothing at all about their owner:
sk_rk = rand_scalar(rng_rk); pk_rk = mul(G1, sk_rk)
E_dep_rk = elgamal_encrypt(M_rec_rk, pk_rk, rand_scalar(rng_rk)) # registered account
m_iss_rk = rand_scalar(rng_rk); M_I_rk = mul(G1, m_iss_rk)
eIss_rk = elgamal_encrypt(M_I_rk, pk_demo, rand_scalar(rng_rk)) # A2 note, to the demo key
def split_gate(m_id, k_read, sk_acct, E_acct, ct):
"""The deposit gate with the two jobs merely separated: prove the account
holds m_id, and prove k_read decrypts the note. Nothing joins them."""
holds = eq(E_acct.C, add(mul(G1, m_id), mul(E_acct.R, sk_acct)))
M = add(ct.C, neg(mul(ct.R, k_read))) # whatever k decrypts to
return holds, M
holds, M = split_gate(m_rec_rk, k_rk, sk_rk, E_dep_rk, eIss_rk)
print(f"honest deposit: account holds the Identity = {holds}, "
f"decrypts to M_I = {eq(M, M_I_rk)}")
m_thief = rand_scalar(rng_rk); sk_thief = rand_scalar(rng_rk)
E_thief = elgamal_encrypt(mul(G1, m_thief), mul(G1, sk_thief), rand_scalar(rng_rk))
holds_t, M_t = split_gate(m_thief, k_rk, sk_thief, E_thief, eIss_rk)
print(f"thief with a stolen payload and its OWN Identity:")
print(f" its account holds its own Identity = {holds_t}")
print(f" the stolen k decrypts to M_I = {eq(M_t, M_I_rk)}")
print(f" -> both halves pass. The note is spendable by the wrong person.")honest deposit: account holds the Identity = True, decrypts to M_I = True thief with a stolen payload and its OWN Identity: its account holds its own Identity = True the stolen k decrypts to M_I = True -> both halves pass. The note is spendable by the wrong person.
A thief holding a stolen payload proves the reading half with the stolen \( k \) and the Identity half with its own registered Identity. Both halves are true. Neither says they belong together.
So the gate states the tie instead of assuming it – the discipline every relation in the folded gates follows, and for the same reason: never infer equality from two proofs that happen to share a public point. One witness, one circuit, four relations, with the accumulator leaf committing the scalars \( (m, k, salt) \):
secret_rk = rand_scalar(rng_rk)
salt_rk = derive_salt(secret_rk, "kyc:ca-ab-2026")
tree_rk = IdentityMerkleTree(depth=10, private=True)
tree_rk.insert_receiving(m_rec_rk, k_rk, salt_rk) # scalars, not points
tree_rk.insert_receiving(m_thief, rand_scalar(rng_rk),
derive_salt(rand_scalar(rng_rk), "kyc:ca-ab-2026"))
# deposit_fold_witness carries the four relations over ONE witness and
# refuses on the one that fails. It is the reference the circuit is
# checked against, so this block runs the shipped gate, not a sketch.
w = deposit_fold_witness(m_rec=m_rec_rk, k=k_rk, sk_dep=sk_rk, salt=salt_rk,
E_dep=E_dep_rk, note_ct=eIss_rk, tree=tree_rk)
print(f"honest witness: exists, k decrypts to M_I = {eq(w.M, M_I_rk)}")
try:
deposit_fold_witness(m_rec=m_thief, k=k_rk, sk_dep=sk_thief, salt=salt_rk,
E_dep=E_thief, note_ct=eIss_rk, tree=tree_rk)
print("thief witness: BUILT (must not happen!)")
except DepositFoldRefused as exc:
print(f"thief witness: REFUSED on relation {exc.relation}")
k2 = rand_scalar(rng_rk); salt2 = derive_salt(secret_rk, "kyc:ca-ab-2026", 1)
tree_rk.insert_receiving(m_rec_rk, k2, salt2)
w2 = deposit_fold_witness(m_rec=m_rec_rk, k=k2, sk_dep=sk_rk, salt=salt2,
E_dep=E_dep_rk, tree=tree_rk,
note_ct=elgamal_encrypt(M_I_rk, mul(G1, k2),
rand_scalar(rng_rk)))
print(f"rotated key: spends, under an unlinkable leaf = {w2.leaf != w.leaf}")honest witness: exists, k decrypts to M_I = True thief witness: REFUSED on relation 3 rotated key: spends, under an unlinkable leaf = True
The thief fails on relation (3): no registered leaf pairs its Identity with the key it stole. Rotation works – a fresh receiving key, a fresh salt, a leaf that links to nothing – because rotation is the accumulator's re-association.
Where each secret lives, and why that is the right place. Authority is the Identity, so losing an account key strands nothing: register another account on the same Identity and deposit. Reading is a key, so it lives in the wallet seed, which is where every wallet has always kept reading keys, with guardian-wrapped copies for the cases that warrant more. What the Identity scalar deliberately cannot do is reconstruct the reading key, because a key derivable from a record you have disclosed is a key held by everyone who has the record. The cost of naming yourself to a counterparty is that they can name you back. It stops there.
What the separation does not cost. Addressing stays unilateral: the payer needs \( pk_{\text{recv}} \), learned on the same out-of-band channel that carries \( M_{\text{rec}} \), and needs nothing from the recipient at payment time. Receipts still name both parties. The note tie keeps its shape, since it witnesses a product of the randomness and the recipient's scalar and computes it fixed-base, and \( r \cdot k \) is as fixed-base as anything else. And B1 needs none of this: a bearer note is keyed to the issuer's registered account key, so the question never arises.
Where to read further. The
shipped surface is wallet/recvkey.py (the key, its seed derivation and
its binding evidence), registry/tree.py receiving_leaf (the leaf that
commits the pair) and wallet/deposit_fold.py (the gate above), with
twenty-five tests in alberta_buck/test/test_receiving_key.py. The same
arguments also run standalone in scripts/review/receiving_key_proto.py,
deposit_gate_split.py and deposit_gate_folded.py.
A1 in Code: The Public-Issuer Addressed Cycle
A1 is A2 with the issuer in the clear. \( idHash \) commits
\( (eNote, m_{\text{issuer}}) \) instead of a second ciphertext.
eRec encrypts the plaintext \( M_{\text{rec}} \) under \( pk_{\text{recv}} \),
not under \( M_{\text{rec}} \): the Identity is what the note names, and
the mailbox key is what opens it. The spend gate is deposit_fold_a1,
the same four relations as A2 without the issuer-membership relation,
because the plaintext of eRec is the recipient's own Identity, which the
receiving leaf already commits. The face \( v \) is a public input of
that proof. (The batch Schnorr is checked at the mint transaction –
verifyIssuerSchnorr; here the signature is synthetic, since the session
has no chain.)
from alberta_buck.wallet.unilateral_a1 import (
mint_unilateral_a1, make_receipt_a1, verify_receipt_a1,
)
from alberta_buck.wallet.deposit_fold import deposit_fold_a1_witness
note_a1 = mint_unilateral_a1(M_rec, pk_recv, v=100, rho=rand_scalar(rng),
m_issuer=m_iss, rng=rng)
print(f"minted A1 note: v = {note_a1.opening.v}")
print(f" idHash = Poseidon6(T_ID, eNote, m_issuer) = {hex(note_a1.idHash)[:20]}...")
print(f" eRec = ElGamal(M_rec under pk_recv) R = {pt(note_a1.eRec.R)}")
print(f" identity scalar opens eRec: "
f"{eq(elgamal_decrypt(note_a1.eRec, m_rec), M_rec)}")
print(f" receiving secret opens eRec: "
f"{eq(elgamal_decrypt(note_a1.eRec, k_rec), M_rec)}")
rcpt_a1 = make_receipt_a1(k_rec, M_rec, note_a1, issuer["M"], issuer=0xA11CE,
chainid=CHAINID, tree=tree, rng=rng)
res_a1 = verify_receipt_a1(rcpt_a1, tree.root(), tree)
assert res_a1.valid and res_a1.value == 100
assert eq(rcpt_a1.pk_recv, pk_recv)
print(f"A1 receipt: {res_a1.reason} -- issuer public, decryption under pk_recv")minted A1 note: v = 100 idHash = Poseidon6(T_ID, eNote, m_issuer) = 0x911c1e6a2194d467e9... eRec = ElGamal(M_rec under pk_recv) R = (21561397..., 33803650...) identity scalar opens eRec: False receiving secret opens eRec: True A1 receipt: VALID -- issuer public, decryption under pk_recv
The A1 fold witness: the value ciphertext is pinned by the public face.
deposit_fold_a1_witness is what deposit_fold_a1.circom consumes. It
checks \( eNote = (r_n G,\ (v + r_n k)G) \) and
\( eEnc = (t G,\ (m_{\text{rec}} + t k)G) \), with \( v \) public, and the
same leaf \( \mathrm{Poseidon}(\tau_R, m_{\text{rec}}, k, salt) \). A different
receiving secret does not open \( eNote \). A thief that holds \( k \) and
substitutes its own Identity is refused by the leaf, the same relation the
A2 block already showed.
w_a1 = deposit_fold_witness(
m_rec=m_rec, k=k_rec, sk_dep=rec0["sk"], salt=salt_rec,
E_dep=rec0["E"], note_ct=note_a1.eRec, tree=priv)
t_a1 = rand_scalar(rng)
eEnc_a1 = elgamal_encrypt(M_rec, pk_recv, t_a1)
fold_a1 = deposit_fold_a1_witness(
witness=w_a1, rho=note_a1.opening.rho, id_hash=note_a1.idHash,
e_note=note_a1.eNote, v=note_a1.opening.v, m_issuer=m_iss,
r_note=note_a1.r_note,
t=t_a1, r_E=rec0["r"], e_dep=rec0["E"], pk_dep=rec0["pk"],
e_enc=eEnc_a1, identity_root=priv.root())
print(f"A1 fold witness built: {len(fold_a1)} signals, relations asserted")
print(f" public face v = {fold_a1['v']}")
print(f" public nullifier = {hex(int(fold_a1['nullifier']))[:20]}...")
k_other, _pk_other = receiving_key(rand_scalar(rng))
try:
bad = deposit_fold_witness(
m_rec=m_rec, k=k_other, sk_dep=rec0["sk"], salt=salt_rec,
E_dep=rec0["E"], note_ct=note_a1.eRec, tree=priv)
print(f"wrong mailbox key: BUILT (must not happen!) leaf={bad.leaf}")
except DepositFoldRefused as exc:
print(f"wrong mailbox key: REFUSED on relation {exc.relation}")A1 fold witness built: 29 signals, relations asserted public face v = 100 public nullifier = 0x2803ab97dcb757fc45... wrong mailbox key: REFUSED on relation 3
The circuit is 3,309,276 constraints on a 32-level path. DepositFoldA1Verifier
verifies a real proof on chain (test/DepositFoldA1Verifier.t.sol), and
test/NotesE2E.t.sol spends an A1 note through the fold inside the full
lifecycle. The dev setup is not a ceremony.
B1 Bearer Spend with Depositor Naming (The A2 Dual)
Carol holds a B1 note from a public issuer. She has never been identified to the issuer; at spend she reveals her Identity to the issuer alone, through an encryption only the issuer can decrypt.
==== Step B0: Carol Holds a B1 Note ====
Carol's artifact carries the issuer's public Identity \( M_{\text{issuer}} \) in the clear with a Schnorr signature, but no per-recipient ciphertext (bearer semantics).
==== Step B1: Carol Encrypts Her Identity for the Issuer ====
At deposit Carol's wallet encrypts her Identity under the public issuer's key:
\[ E_{\text{dep}\to\text{iss}} = (R_f, C_f) = (rG,\ M_{\text{Carol}} + r\,pk_{\text{iss}}). \]
Only \( sk_{\text{iss}} \) recovers \( M_{\text{Carol}} \); to Mallory the ciphertext is IND-CPA noise.
==== Step B2: Generate the Depositor-Binding Proof (A2 dual) ====
Carol's wallet proves (b1_bind_prove, verifyDepositorBinding) knowledge
of \( (m_{\text{Carol}}, sk_{\text{dep}}, r) \) such that the ciphertext
encrypts the same Identity bound to Carol's payout account:
The shared witness \( m_{\text{Carol}} \) couples (E2), (F2), and (P): the Identity bound to Carol's payout account is exactly the one encrypted for the issuer and the one committed (blinded) in \( P_{\text{dep}} \) – so a colluding depositor cannot substitute a different Identity, and the membership below is bound to the right point. (11 Python + 6 Solidity tests.)
==== Step B3: Submit Notes.spendCoupledB1 ====
Carol calls the membership-bound B1 deposit:
notes.spendCoupledB1(
proof, noteRoot, identityRoot, nullifier, face, recipient,
issuanceCommitment, // the mint commitment the spend opened
issuer, // public issuer address
eDepForIss, // (R_f, C_f) encrypting M_Carol
b1Proof, // verifyDepositorBinding (incl. P_dep)
membershipProof, // membership of M_Carol, bound to P_dep
);
The contract verifies the binding, then the membership of b1Proof.P_dep (the
same point the binding committed), and emits
SpentCoupledB1(nullifier, face, recipient, issuer, eDepForIss) with the
ciphertext only the issuer can decrypt. Carol is now provably a registered
Identity the issuer can name – the membership-bound dual of the A2 recipient
receipt (test/NotesCoupledB1.t.sol, 6 tests).
==== Step B4: Issuer Decrypts and Produces a Receipt ====
The issuer scans SpentCoupledB1 events, decrypts \( M_{\text{Carol}} \) with
\( sk_{\text{iss}} \), and alone produces a plaintext receipt naming
both parties:
(make_issuer_receipt in alberta_buck.wallet.b1_binding.)
B1 in Code: The Issuer Names the Depositor
The same session, continued. Carol is a registered Identity the (public)
issuer has never met; she holds a bearer note's opening. At spend she
builds eDepForIss – her own \( M_{\text{dep}} \) encrypted under the
issuer's key – and the five-relation depositor-binding sigma couples it to
her payout account and to the blinded membership commitment
\( P_{\text{dep}} \):
from alberta_buck.wallet.b1_binding import (
b1_bind_prove, b1_bind_verify, make_issuer_receipt, verify_issuer_receipt,
)
m_carol = rand_scalar(rng)
carol = account(m_carol)
tree.insert(carol["M"])
pk_iss = issuer["pk"]
binding, eDepForIss = b1_bind_prove(m_carol, carol["sk"], carol["E"], pk_iss,
account=0xCA801, chainid=CHAINID, rng=rng)
ok = b1_bind_verify(carol["pk"], carol["E"], pk_iss, eDepForIss, binding,
account=0xCA801, chainid=CHAINID)
print(f"B1 depositor binding from Carol: verifies = {ok}")
print(f" eDepForIss = ElGamal(M_carol under pk_iss) R = {pt(eDepForIss.R)}")
print(f" P_dep = {pt(binding.P_dep)} (blinded; membership binds it)")B1 depositor binding from Carol: verifies = True eDepForIss = ElGamal(M_carol under pk_iss) R = (87291686..., 3992517...) P_dep = (81747299..., 99754363...) (blinded; membership binds it)
The issuer alone – reading eDepForIss from the SpentCoupledB1 event –
decrypts and proves the depositor's Identity, the dual of the A2
unilateral receipt. And the collusion-substitution is rejected: a
ciphertext under any other key breaks the coupled sigma, so the depositor
cannot mis-name itself.
rcpt_iss = make_issuer_receipt(issuer["sk"], issuer["M"], eDepForIss,
value=25, issuer=0xA11CE, chainid=CHAINID,
tree=tree, rng=rng)
res_b1 = verify_issuer_receipt(rcpt_iss, tree.root(), tree)
assert res_b1.valid
assert eq(res_b1.recipient_M, carol["M"])
print(f"issuer-unilateral receipt: {res_b1.reason}")
print(f" names depositor {pt(res_b1.recipient_M)} == M_carol")
eDep_sub = elgamal_encrypt(carol["M"], mul(G1, rand_scalar(rng)), rand_scalar(rng))
ok_sub = b1_bind_verify(carol["pk"], carol["E"], pk_iss, eDep_sub, binding,
account=0xCA801, chainid=CHAINID)
print(f"substituted eDepForIss verifies = {ok_sub} (collusion-substitution rejected)")issuer-unilateral receipt: VALID names depositor (53868701..., 76614019...) == M_carol substituted eDepForIss verifies = False (collusion-substitution rejected)
Summary: What the Observer Sees (Mallory)
In all three flows the chain reveals:
- A fresh nullifier and a spent face amount.
- The payout address.
- For A1/A2: the re-encryption \( eEnc \) under an unpublished \( pk_{\text{recv}} \) (semantically secure) and one fold proof; no committed point.
- For B1: the issuer address, \( E_{\text{dep}\to\text{iss}} \) (IND-CPA) and the perfectly hiding \( P_{\text{dep}} \).
- The posted identity root the spend names (one uint256 per posting, revealing nothing about which identities are active, since the leaves are salted).
- No plaintext Identities, no link between the spend and any specific commitment in the tree, and no link between mint and spend events.
The Prover Bill of Materials: Bandwidth, Storage, Time
Everything above names the proofs; this section prices them. The short
answer to "is wallet-side proving expensive?" is no – proving is seconds;
distribution is the cost. A Groth16 wallet never performs the trusted
setup (the multi-GB Powers-of-Tau and the hours of zkey ceremony are a
one-time, project-side event); it downloads two finished artifacts per
circuit – the proving key (.zkey) and the witness generator (a wasm
module, or a faster platform C++ binary plus its .dat constants) – and
should pin their hashes against the published release. Verification costs
a wallet nothing to distribute: the verifiers are contracts already on
chain. And note the asymmetry that matters for UX: receipts need no SNARK
at all – both parties' AB-RCPT/2 slips (Receipt) are sigma proofs and
decryptions, milliseconds of wallet math – the SNARKs are paid only at
mint and spend, once per note.
Who proves what:
- Issuer, at mint: one batch-mint proof –
mint_batch(B1/A1) ormint_batch_a2(A2) at the chosen batch pin \( N \in \{1,2,4,8,16,32\} \) – plus artifact-free wallet sigmas (the batch Schnorr for B1/A1; the per-leafissuer_reencbinding for A2; sub-millisecond each). - Depositor, at spend: the flavor-agnostic
spendproof, and then one identity proof. A1 and A2 prove the fold (deposit_fold_a1/deposit_fold_a2), which includes the note tie. B1 provesidentity_membership_b1beside the depositor-binding sigma. Three keys, one per gate, and no others. - B1 is the light flavor: no fold, and no addressed ciphertext at mint. Its membership key is the small one.
The blocks below are live: they read the artifact sizes from the local
SNARK build (build/snark/, produced by make snark and friends) and then
actually run every wallet prover once over the committed e2e fixture
worlds, timing each. (The printed timing lines vary with hardware –
Apple-silicon laptop here; every other line is deterministic.)
What the wallet downloads and stores
from pathlib import Path
SNARK = Path("build/snark")
def mb(*paths):
total = 0
for p in paths:
pp = Path(p)
if not pp.exists():
return None
total += pp.stat().st_size
return total / 1e6
ARTIFACTS = [
# circuit consumer zkey witness generator (+ .dat)
("mint_batch N=1 (B1/A1 mint)", "issuer",
"mint_batch_n1/mint_batch_n1_final.zkey",
["mint_batch_n1/mint_batch_n1_js/mint_batch_n1.wasm"]),
("mint_batch N=32 (B1/A1 mint)", "issuer",
"mint_batch_n32/mint_batch_n32_final.zkey",
["mint_batch_n32/mint_batch_n32_js/mint_batch_n32.wasm"]),
("mint_batch_a2 N=1 (A2 mint)", "issuer",
"mint_batch_a2_n1/mint_batch_a2_n1_final.zkey",
["mint_batch_a2_n1/mint_batch_a2_n1_js/mint_batch_a2_n1.wasm"]),
("mint_batch_a2 N=32 (A2 mint)", "issuer",
"mint_batch_a2_n32/mint_batch_a2_n32_final.zkey",
["mint_batch_a2_n32/mint_batch_a2_n32_js/mint_batch_a2_n32.wasm"]),
("spend (all spends)", "depositor",
"spend/spend_final.zkey",
["spend/spend_js/spend.wasm"]),
("b1 membership (B1 spend)", "depositor",
"b1_membership/b1m_0001.zkey",
["b1_membership/identity_membership_b1_js/identity_membership_b1.wasm"]),
("deposit_fold_a1 (A1 spend)", "depositor",
"deposit_fold_a1/deposit_fold_a1_0001.zkey",
["deposit_fold_a1/deposit_fold_a1_cpp/deposit_fold_a1",
"deposit_fold_a1/deposit_fold_a1_cpp/deposit_fold_a1.dat"]),
("deposit_fold_a2 (A2 spend)", "depositor",
"deposit_fold_a2/deposit_fold_a2_0001.zkey",
["deposit_fold_a2/deposit_fold_a2_cpp/deposit_fold_a2",
"deposit_fold_a2/deposit_fold_a2_cpp/deposit_fold_a2.dat"]),
]
print(f"{'circuit':34s} {'consumer':10s} {'zkey':>10s} {'witness gen':>12s}")
print("-" * 70)
sizes = {}
for name, who, zkey, gens in ARTIFACTS:
zs = mb(SNARK / zkey)
gs = mb(*(SNARK / g for g in gens))
if zs is None or gs is None:
print(f"{name:34s} {who:10s} {'absent':>8s} {'absent':>12s}")
continue
sizes[name] = (zs, gs)
print(f"{name:34s} {who:10s} {zs:>8.1f}MB {gs:>10.1f}MB")circuit consumer zkey witness gen ---------------------------------------------------------------------- mint_batch N=1 (B1/A1 mint) issuer 10.1MB 2.8MB mint_batch N=32 (B1/A1 mint) issuer 321.1MB 5.5MB mint_batch_a2 N=1 (A2 mint) issuer 10.6MB 4.3MB mint_batch_a2 N=32 (A2 mint) issuer 337.0MB 7.2MB spend (all spends) depositor 5.5MB 3.1MB b1 membership (B1 spend) depositor 272.1MB 8.5MB deposit_fold_a1 (A1 spend) depositor 1863.3MB 35.6MB deposit_fold_a2 (A2 spend) depositor 2116.0MB 44.4MB
The two fold zkeys dominate. They are the proving keys for the 3,309,276-constraint A1 gate and the 3,833,338-constraint A2 gate, and Groth16 proving keys scale with constraint count. B1's membership key is the 494,701-constraint circuit. Everything else a wallet fetches for a mint or a note opening is a few megabytes to a few hundred.
Running every wallet prover, timed
One real proof per circuit, over the same fixture worlds the live-EVM transcript spends (Receipt, The Whole Flow, Executed): snarkjs (wasm witness + prove) for the small circuits, the circom C++ witness generator
- rapidsnark for the big ties – exactly the pipeline
scripts/snark/gen_e2e_fixtures.sh runs.
import json, shlex, subprocess, tempfile, time
E2E = SNARK / "e2e"
FIX = Path("alberta_buck/test/vectors/e2e")
RAPID = next(Path("lib").glob("rapidsnark-*/bin/prover"))
tmp = Path(tempfile.mkdtemp(prefix="docflow-prove-"))
def timed(cmd):
t0 = time.monotonic()
subprocess.run(cmd, check=True, capture_output=True)
return time.monotonic() - t0
times = {}
# Issuer: the batch-mint proofs (witness + Groth16 via snarkjs).
for flavor, script in (("b1", "prove_mint_batch.js"),
("a2", "prove_mint_batch_a2.js")):
args = json.loads((FIX / f"{flavor}.json").read_text())["mint_args"]
args = [a.replace(f"e2e_{flavor}", f"docflow_{flavor}") for a in args]
times[f"mint {flavor} (issuer)"] = timed(
["node", f"scripts/snark/{script}", *args])
# Depositor: the flavor-agnostic spend proof (against the docflow mint).
payout = json.loads((FIX / "a2.json").read_text())["payout"]
times["spend (depositor, all)"] = timed(
["node", "scripts/snark/prove_spend.js",
"build/snark/mint_batch_a2_n1/fixtures/docflow_a2.json",
"0", payout, "1", "docflow_a2"])
# Depositor, B1: the repaired membership proof (wasm witness + snarkjs).
b1 = SNARK / "b1_membership"
times["b1 membership (depositor)"] = (
timed(["npx", "snarkjs", "wtns", "calculate",
str(b1 / "identity_membership_b1_js/identity_membership_b1.wasm"),
str(E2E / "b1/b1_membership_input.json"), str(tmp / "b1.wtns")])
+ timed(["npx", "snarkjs", "groth16", "prove", str(b1 / "b1m_0001.zkey"),
str(tmp / "b1.wtns"),
str(tmp / "b1_proof.json"), str(tmp / "b1_public.json")]))
# Depositor, addressed flavors: the folded gate (C++ witness + rapidsnark).
for flavor, nb in (("a1", "deposit_fold_a1"), ("a2", "deposit_fold_a2")):
gen = SNARK / nb / f"{nb}_cpp" / nb
zkey = next((SNARK / nb).glob("*_0001.zkey"))
wit = timed(["bash", "-c",
f"ulimit -s 65520 && {shlex.quote(str(gen))} "
f"{shlex.quote(str(E2E / flavor / 'fold_input.json'))} "
f"{tmp}/{nb}.wtns"])
prv = timed([str(RAPID), str(zkey), f"{tmp}/{nb}.wtns",
f"{tmp}/{nb}_proof.json", f"{tmp}/{nb}_public.json"])
times[f"fold {flavor} (depositor)"] = wit + prv
for k, v in times.items():
print(f"{k:30s} {v:6.2f}s")mint b1 (issuer) 0.96s mint a2 (issuer) 0.98s spend (depositor, all) 0.75s b1 membership (depositor) 20.09s fold a1 (depositor) 13.05s fold a2 (depositor) 16.94s
The bill, per flavor and role
Download = first-run fetch (zkey + witness generator); prove = wall time for every SNARK that party runs for one note, from the measurements above. The artifact-free sigma legs (Schnorr / issuer_reenc / depositor binding / eEnc re-encryption / receipt vd proofs) add milliseconds and nothing to download.
BILL = [
("issuer b1/a1 (N=1)", ["mint_batch N=1 (B1/A1 mint)"],
["mint b1 (issuer)"]),
("issuer a2 (N=1)", ["mint_batch_a2 N=1 (A2 mint)"],
["mint a2 (issuer)"]),
("depositor b1", ["spend (all spends)",
"b1 membership (B1 spend)"],
["spend (depositor, all)", "b1 membership (depositor)"]),
("depositor a1", ["spend (all spends)",
"deposit_fold_a1 (A1 spend)"],
["spend (depositor, all)", "fold a1 (depositor)"]),
("depositor a2", ["spend (all spends)",
"deposit_fold_a2 (A2 spend)"],
["spend (depositor, all)", "fold a2 (depositor)"]),
]
print(f"{'party / flavor':24s} {'download+store':>15s} {'prove (per note)':>17s}")
print("-" * 58)
for who, arts, ts in BILL:
dl = sum(sum(sizes[a]) for a in arts)
pt = sum(times[t] for t in ts)
print(f"{who:24s} {dl:>13.1f}MB {pt:>16.2f}s")party / flavor download+store prove (per note) ---------------------------------------------------------- issuer b1/a1 (N=1) 12.9MB 0.96s issuer a2 (N=1) 14.9MB 0.98s depositor b1 289.2MB 20.84s depositor a1 1907.6MB 13.81s depositor a2 2169.0MB 17.70s
What this means for the wallet UX
- Lazy, tiered artifact fetch. A receive-and-hold wallet needs zero
SNARK artifacts. A B1-spending wallet needs ~25 MB (
spend+identity_membership_b1). The 1.9 and 2.2 GB fold keys are needed only to spend an addressed note, so fetch them in the background on first A1/A2 receipt (the user will plausibly spend later), with hash-pinned, resumable downloads. An issuer wallet sizes its mint key to its batch habit: 10 MB at \( N{=}1 \), ~330 MB at \( N{=}32 \). - One progress bar that matters. Every proof except the depositor's identity gate is under ~2 s – below UX-noticeable for a payment flow. That gate's ~15-20 s (a fold under rapidsnark, or B1's membership under snarkjs) is the one moment to design for: prove in the background between "note received and verified" and "deposit", or show explicit progress at spend. Proving is local and private – nothing leaves the device – and never blocks the counterparty (the issuer's proof is done before delivery; the depositor's at their leisure).
- Per-payment chain bandwidth is negligible next to the artifacts: the
depositor replays the commitment tree from
Mintedcalldata (32 B per leaf in the pool plus log overhead) and reads one identity-tree path; both are KB-scale at today's pool sizes and cacheable incrementally. - Verification is free to distribute: the Groth16 verifiers are on chain, and the bilateral receipts re-verify with no SNARK artifacts at all – a third party checks a slip with the pure-Python wallet alone.
Demurrage-Carrying Transfers
The Note-spend payout in Step 5 went out as a plain buck.transfer that BUCK
routed through its internal carrying path because the Notes pool is bound
isCarrying. This section unpacks why a single per-account flag – not a
separate method, not a marker interface – is sufficient, and what its ledger
effect looks like.
Two Ways to Handle Demurrage at Transfer Time
BUCK demurrage is a balance-time integral: each account stores its buckSeconds
(the crystallised \( \int B\,dt \)) plus the timestamp of its last touch, and
its accrued fee is rate \cdot (buckSeconds + balance \cdot elapsed). Two ledger
responses to that accrued fee are distinguishable at transfer time, selected by
the sender's isCarrying flag:
- Deducting transfer (sender is non-Carrying, 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, and her
buckSecondsis re-crystallised from the new balance. The 1,000 BUCK then lands at Bob fresh: his inflow begins accruing age from now, diluting his effective age. From a system perspective, accrued fees are burned at each transfer and the recipient enters fresh. - Carrying transfer (sender is
isCarrying). On the same 1,000-BUCK outgoing, nothing is burned at the sender: the pool sheds a proportional slice of its live buck-seconds (carried = liveBs \cdot 1000 / poolRaw) and keeps its per-BUCK age. Bob inherits that slice (buckSeconds +carried=), so he absorbs 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.
The carrying path fixes all three: no per-note age, face to payout, no pool
insolvency.
Shipped Surface in Buck.sol
There is no separate transferCarrying method and no TransferCarrying event.
The carrying behaviour is selected by a per-account flag, isCarrying, set when
the account is bound:
// IdentityRegistry: governance sets isCarrying at bind time.
function bindContract(address t, ..., bool isPublicIdentity_, bool isCarrying_) external;
mapping(address => bool) public isCarrying;
The standard transfer path then dispatches on the sender's flag:
if (identity.isCarrying(from)) _carryingTransfer(from, to, amount);
else _nonCarryingTransfer(from, to, amount);
emit Transfer(from, to, amount); // one standard event either way
The preconditions are exactly those of any transfer (sender and recipient both
isVerified, the bilateral receipt-fragment / _identityHash identity check),
because it is an ordinary transfer. _carryingTransfer sheds a proportional
slice of the sender's live buck-seconds onto the recipient rather than burning a
fee at the sender:
liveBs = from.buckSeconds + from.raw * elapsed;
carried = liveBs * amount / from.raw;
from.buckSeconds = liveBs - carried; // sender keeps its per-BUCK age
recipient.buckSeconds += carried; // recipient inherits the age
Carrying accounts hold no NFT-backed credit and cannot go negative (the path
asserts amount < raw=). What makes an account pool-friendly is the flag, set
once at bind time; there is no per-call method to invoke and no balance
precondition beyond that non-negative assertion.
Who Else Is Carrying
The Note pool is the canonical isCarrying account, but the flag is not
pool-private: governance binds AMM pools, the Jubilee, and the Notes pool
isCarrying, and could bind any transitory-holder contract the same way –
escrow accounts whose beneficiaries are the true owners, payroll contracts
pulling from an employer, cross-chain bridge egresses. The semantic invariant
is "the account is a transitory holder of BUCK earmarked for its recipients,"
and in that case carrying the age forward matches the economic reality.
For ordinary peer-to-peer accounts the deducting default remains correct: the sender is paying from their own pocket and is the natural place to burn their own accrued fee, so EOAs are bound non-Carrying.
Open Question: Identity Receipts on Carry
Because a carrying transfer is an ordinary transfer, it uses the same
identity predicate – both parties isVerified, with the receipt-fragment
fallback to _identityHash on the public side when a party has
isPublicIdentity = true=. The Notes pool side relies on its bound Public
Identity to satisfy the bilateral check. Whether a carrying transfer between
two Encrypted-Identity counterparties should require its own per-call
Chaum-Pedersen receipt is an open question; for the Notes pool it is moot (the
pool is bound under a Public Identity), but if transitory-holder carry is ever
opened to private-to-private pairs 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. On the mint side the Merkle
insertion lives inside the SNARK (mint_batch.circom, per-N Groth16
verifiers, per-N adapter dispatch); the chain writes the SNARK-attested
newRoot into the 30-slot root-history ring Notes.sol maintains for the
spend side. On the spend side: the flavor-agnostic spend.circom note
proof, SpendGroth16Verifier, SpendVerifierAdapter, the on-chain Merkle
accumulator, and BUCK's isCarrying carrying-transfer path. The IdentityRegistry provides the
issuer-attestation and recipient-verification legs. Addressed spends
verify one folded Groth16 proof. B1 verifies verifyDepositorBinding
and identity_membership_b1. Theorems 7-12 of the
formal proofs
are the argument for these gates. The wallet cost of the fold is in The Prover Bill of
Materials below: fifteen to eighteen seconds and a 1.9 to 2.2 GB proving
key per addressed note, measured on this machine.
Why the spend carries this apparatus at all – anonymity is the cost. A
direct transfer reaches the same mutual-decryptability guarantee with no
SNARK, because the bilateral approve names both parties at once. The Notes
detour pays the mint SNARK, the recipient-key blinding, and the folded
gate purely to buy mint <-> spend unlinkability: the issuer's
registered anchor lives at mint, the recipient's at spend, and they are never
co-present, so the apparatus is what bridges them. Public-issuer notes
(A1/B1) keep the cheap, fully-sound path; A2 is the premium that hides the
issuer too. See alberta-buck-notes ("Why A2 Costs More: Anonymity Is the
Cost") and the privacy audit below ("The EOA Transfer Mirror").
Deployment Status and Open Questions
The redemption walk above is the identity-aware spend. Addressed notes
use the folded gate when depositFoldVerifier is set. B1 uses
verifyDepositorBinding and identity_membership_b1.circom when that
adapter is the membership slot. What is actually in the tree:
- On-chain identity root. Governance appoints a root authority, which
enrolls each registry's subtree at an aggregator slot and appoints the
aggregator; the aggregator alone posts roots, into a ring of 256 with
their times, and each consumer declares the oldest it accepts (Notes:
seven days). A receiving leaf is admitted by its registry:
registerandbindContractrefuse a caller-supplied leaf, and must, since a call that accepted any leaf would accept one pairing any Identity with any key. The organisation admits the leaf to its own subtree, the aggregator composes, and a membership path is 32 levels. The canonical off-chain trees arealberta_buck.registry.treeandmerkle_service. - B1 membership.
identity_membership_b1.circomproves \( T = b H_{\mathrm{PEDERSEN}} \), the salted leaf, and the path.IdentityMembershipB1VerifierAdapteris the verifier, and it is the only membership adapter in the tree. - Addressed spends.
spendCoupledA1/spendCoupledA2verify one proof.DepositFoldVerifierAdapterderives the public inputs and reads the depositor's registered account. The slot cannot be cleared and an addressed spend without it reverts, so there is no configuration in which a weaker gate answers for one.spendCoupledB1composes its sigma with the membership verifier, passing \( P_{\text{dep}} \) into it – sound there because B1's two facts rest on one secret. - The note tie lives in the fold.
spend.circomexposes no \( idHash \).deposit_fold_a1anddeposit_fold_a2recompute the nullifier from the \( idHash \) they open, so the ciphertext they check is this note's. - The receipt names by evidence, not by derivation. The canonical AB-RCPT verifier no longer derives the payee's identity scalar and decrypts the addressed ciphertexts with it: that procedure worked for anyone who had ever seen a receipt. The recipient proves by verifiable decryption under \( pk_{\text{recv}} \); the issuer, which cannot open its own ciphertexts, discloses the mint randomness instead; and the mailbox leaf ties that key to the named Identity with a Poseidon and a path. An issuer-side receipt without it prints UNVERIFIED RECIPIENT. An A2 receipt also checks \( C_i - T + \gamma H_{\mathrm{PEDERSEN}} \) against the Identity it names.
- The payload's scalars are wrapped to the mailbox key. A delivery must carry \( r' \) (the fold needs the SCALAR \( r'k \), which the point \( k R \) does not give) and, for A2, the issuer's naming salt and \( T \)'s blind \( \gamma \). In the clear each undoes something it travels beside – \( r' \) turns \( eIss \) into plaintext, the salt turns the issuer's naming leaf into a scannable one, \( \gamma \) turns \( T \) into \( r'\,pk_{\text{recv}} \) and so the issuer's Identity. All travel masked by \( \mathrm{keccak}(\mathtt{WRAP\_DOMAIN} \| \mathit{field} \| S) \), with \( S = r' \cdot pk_{\text{recv}} = k \cdot R \): Diffie-Hellman, so only the addressee unwraps, and the minter needs no round trip to wrap. One mask per field, because one pad over two scalars leaks their difference.
The end-to-end test spends all three flavours through those gates, with dev-entropy setups. A production ceremony has not been run.
Additional items tracked across the series:
- Mint-side deferred-approve handshake (shipped). The public-issuer Schnorr
binding (
verifyIssuerSchnorr) and the private-issuer re-encryption binding (verifyIssuerReenc) are deployed and tested. Every mint carries a verified issuer Identity binding. - Variable-
Nbatch size (resolved via pinned set). 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;MintVerifierAdapterdispatches oncms.length. - Prover contention (resolved operationally, not protocolly). Batch
minting 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.
Privacy Audit: Data Revealed Per Step
The whole construction threads one needle: publish enough that each party can name the other under compulsion, while publishing so little that no third party can name either, or link payments in bulk. This section consolidates the per-step disclosure analysis for the shipped Identity-M surface.
Requirements and adversaries
Three properties, against three adversaries:
- (Bulk-tracing resistance.) Mallory, a passive observer with the full chain history and unbounded compute but no party's secrets, cannot reconstruct who paid whom, how much, or link a mint to its spend(s).
- (Third-party non-disclosure.) Nothing on chain reveals either party's KYC-bound Identity point \( M \) to a non-participant – not even to the counterparty's counterparty, and not in aggregate across many payments.
- (Mutual decryptability.) Each of the two participants, from data they hold plus public chain state, can produce a sound receipt naming the other's registered \( M \) – and a colluding pair cannot arrange a payment for which no such receipt exists (the non-deniable-receipt invariant).
The first two are confidentiality against outsiders; the third is accountability between insiders. They pull in opposite directions, so every published datum below is justified against all three.
The on-chain footprint of each step
| Step | Published on chain | Kept off chain (party-held) |
|---|---|---|
register / bindContract |
addr, pk, E_addr (ElGamal ct of \(M\) under own pk), isPublicIdentity, identityRoot |
\(m\), \(M\), \(sk\) |
mint (any flavor) |
Minted(issuer, totalFace, startIndex, count, newRoot); cms[] in calldata (opaque Poseidon) |
per-leaf opening \((flavor,v,\rho,idHash,pred)\); \(eNote\), issuer payload |
mint public (A1/B1) |
+ IssuerBound(issuer, newRoot); one Schnorr over keccak(cms) |
– |
mint private (A2) |
+ IssuerReencBound(issuer, newRoot, count); per-leaf \(eIss\), \(T\) and the blinded binding \((Q,U,A_1..A_5)\) |
\(M_{iss}, r', \beta, \gamma, sk_{iss}\); the recipient point \(M_{rec}\) |
| A1/A2 spend | nullifier, face, recipient, the identity root, \(eEnc\), one fold proof | \(\rho, idHash, m, k, sk, salt\); A2 also \(r'\), \(\gamma\) and the issuer's naming salt |
| B1 spend | SpentCoupledB1(nf, face, recipient, issuer, eDepForIss); the identity root, \(P_{dep}\), the depositor-binding sigma, the membership proof |
\(\rho, idHash, m_{dep}, sk_{dep}, r, b\) |
Two facts do the heavy lifting throughout. First, cms[] are Poseidon
commitments: a leaf reveals nothing about \( (flavor, v, \rho, idHash,
predicate) \) without \( \rho \). Second, the spend nullifier
\( nf = \mathrm{Poseidon}_3(T_{NF}, \rho, idHash) \) is a PRF output over secret
material, so it is unlinkable to any cm without the opening (Proofs,
Theorem 10).
Why bulk-tracing and third-party privacy hold
Mint is opaque; spend is unlinkable to mint. At mint Mallory sees opaque
cms[] and a totalFace; she cannot read any \( v_i \), recipient, or
flavor (Poseidon hiding). At spend she sees a fresh \( nf \), a face, and
a recipient; \( nf \) is a PRF over \( (\rho, idHash) \), so inverting it
to a specific cm is pre-image resistance. No field of a spend points back
to a mint: the spend's \( eEnc \) is a fresh re-encryption of the leaf
ciphertext (uniformly re-randomised; the fold deliberately proves
re-encryption equivalence, never equality with the public mint-time
\( eIss \), and \( T \) never appears at spend). The only residual linkage surfaces are the two that
must be public for the ERC-20 to pay out – face and recipient –
mitigated by fixed denominations and relayed payouts, orthogonal to this
document.
The stronger adversary: someone who already holds an Identity. The arguments above are stated against Mallory, who holds no secrets. The interesting adversary holds several: an issuer knows every Identity scalar it certified, and every past counterparty knows one. Their scan is "is any note in existence addressed to anyone on my list?", and it is defeated by where the addressing key comes from rather than by any of the arguments above. Because a note is keyed to \( pk_{\text{recv}} \) and never to the Identity point, a candidate Identity tests nothing: the residual question is a decisional Diffie-Hellman decision under a key the holder has disclosed to nobody. The Receiving Key above shows the scan running, and finding nothing.
Identities never appear on chain – the load-bearing check. A registered \( E_{addr} \) is an ElGamal ciphertext of \( M \); reading it yields nothing without \( sk \). The danger is in the bindings, which manipulate \( M \) and the recipient point algebraically. Concretely, the A2 mint binding must publish enough to prove "\( eIss \) re-encrypts the issuer's registered \( M_{iss} \) under the recipient's point" without revealing either. This is exactly where the first draft failed – the canonical near-miss, kept here because it is the clearest way to see the invariant:
Near-miss (rejected draft). The binding published T = r' * pk_rec.
But the leaf is C_i = M_iss + r' * pk_rec, so ANY observer computes
M_iss = C_i - T.
At mint msg.sender IS the issuer, so this publicly bound the issuer's
address to its Identity -- collapsing A2 to A1 and breaking BOTH
third-party non-disclosure and bulk unlinkability (every A2 mint by the
same issuer now carries the same recoverable M_iss).
The shipped binding blinds it: it publishes \( T = r' pk_{rec} + \gamma H_{\mathrm{PEDERSEN}} \) for a fresh \( \gamma \). Now \( C_i - T = M_{iss} - \gamma H_{\mathrm{PEDERSEN}} \), a uniformly random offset from \( M_{iss} \) – so \( T \) reveals nothing to anyone without \( \gamma \), which only the recipient receives. The recipient's key is likewise hidden (inside the perfectly-hiding \( Q \) and the uniform \( U, T \)), and soundness is undisturbed because \( \gamma \) cancels in the verification relations. The blind is on the hash-to-curve generator rather than \( G \) because the fold opens \( T \) again: a blind whose logarithm is known would absorb any difference of Identities. An A2 mint's on-chain footprint is, to Mallory, a vector of uniformly-distributed group elements.
The spend-side proofs reveal the spender's action, not any Identity. The
folds are zero-knowledge; their public inputs are the nullifier (PRF),
\( eEnc \) (uniform), the identity root, and the depositor's own registered
key and credential. B1's depositor-binding sigma is HVZK and its only
Identity-derived public value is \( P_{dep} = M_{dep} + bH \), perfectly
hiding for uniform \( b \). msg.sender is visible – exactly as the
sender of any ERC-20 transfer is – but no Identity is.
Why mutual decryptability holds – the final data set
For each flavor, the data each party ends up holding, and the predicate it
satisfies (the receipt verifiers in alberta_buck.wallet are the public
predicate \( \mathsf{RcptVerify} \); see the executable sections above):
| Flavor | Recipient names issuer via | Issuer names depositor via |
|---|---|---|
| B1 | in-clear \(M_{iss}\) + batch Schnorr (verifyIssuerSchnorr) |
SpentCoupledB1: decrypt \(eDepForIss\) with \(sk_{iss}\) (B1 in Code) |
| A1 | in-clear \(M_{iss}\) + batch Schnorr | chose \(M_{rec}\) at mint; the mailbox leaf ties \(pk_{recv}\) to it |
| A2 | decrypt \(eIss\) under \(k\); binding, key tie and membership prove it is the registered minter (A2 in Code) | chose \(M_{rec}\) at mint; the mailbox leaf ties \(pk_{recv}\) to it |
The A2 row is the subtle one. The recipient's receipt composes four facts: the mint anti-framing binding (the ciphertext carries the issuer's registered record), their own verifiable decryption (it opens under \( pk_{recv} \) to the named point), the key tie \( C_i - T + \gamma H_{\mathrm{PEDERSEN}} = M_I \) (the binding's Identity is the one decrypted), and the membership of that point in \( \mathsf{rt_{id}} \). No issuer cooperation is needed – the receipt is unilateral – and no new proof is needed at receipt time.
The colluding pair, and where each escape is closed
Completeness (an honest recipient can produce a receipt) is met off chain. The third clause – no deniability even when payer and payee collude – needs the binding to be unavoidable. Three escapes existed on the road here, each now closed at a specific gate:
- A binding-less leaf. Closed at mint:
Notes.mintrequires a verified issuer binding (Schnorr or re-encryption) for every leaf; the SNARK's per-leafissuerModesignal makes a private-issuer bearer leaf unprovable (no B2 codepoint). - A nameable note deposited by the wrong Identity. Closed at spend by the fold: the depositing account's Identity and the key that reads the note must be paired in one certified leaf (Proofs, Theorem 8).
- An un-nameable note made spendable by ciphertext substitution. Closed
at spend by the note tie inside the fold: the verified \( eEnc \) must
be keyed to the identity material committed behind this spend's
nullifier (A2: re-encrypt the committed \( eIss \); A1: share the
\( eNote \)'s key, the public
facepinning it) (Proofs, Theorem 8) – demonstrated executably in the A2 and A1 in Code counter-examples above. - A misnamed note: a registered puppet in place of the minter. Closed at spend by the key tie: the note commits the binding's \( T \) and the A2 fold opens it against the recipient's own key (Proofs, Theorem 12) – the split mint above is refused by the delivery, the fold and the receipt.
Security Properties and the Tests That Witness Them
The Notes security and identity model (all three flavours with Merkle membership)
is shipped and exercised end-to-end – the on-chain lifecycle is
measured by test/NotesE2E.t.sol (see alberta-buck-notes "On-Chain Gas"). This section
records the shipped surface, the remaining integration, and the
document series.
| Component | Source |
|---|---|
| Notes contract: roots, nullifiers, mint + spendCoupled{A1,A2,B1} | src/Notes.sol |
| Carrying-transfer path in BUCK | src/Buck.sol (_carryingTransfer, buckSeconds apportionment) |
| Mint Groth16 circuits + per-N verifiers (PUBLIC + PRIVATE modes) | circuits/mint_batch.circom, circuits/mint_batch_a2.circom, src/MintGroth16Verifier_*.sol |
| A1/A2 folded deposit gate | circuits/deposit_fold_a1.circom, deposit_fold_a2.circom, DepositFoldVerifierAdapter, test/NotesE2E.t.sol |
| B1 depositor binding (EIP-196 Okamoto sigma, A2 dual) | IdentityRegistry.verifyDepositorBinding, alberta_buck.wallet.b1_binding, test/B1Binding.t.sol (6) |
| A2 issuer re-encryption binding (anti-framing) | IdentityRegistry.verifyIssuerReenc, test/IssuerReenc.t.sol |
| Public-issuer Schnorr binding (A1/B1 mint) | IdentityRegistry.verifyIssuerSchnorr, test/IssuerSchnorr.t.sol |
| B1 membership circuit (salted leaf, \( H_{\mathrm{PEDERSEN}} \)) | circuits/identity_membership_b1.circom, IdentityMembershipB1VerifierAdapter, test/B1MembershipVerifier.t.sol |
| Identity registry prototype (certificates, trees, aggregator) | alberta_buck/registry/, test/test_registry.py (49) |
| Python ↔ Solidity cross-artifact vectors | scripts/gen_unilateral_a2_vectors.py, scripts/gen_b1_binding_vectors.py, test/vectors/ |
| Registry-backed sim identity issuance | alberta_buck/sim/identity.py |
| Poseidon-T3 precompile (in-EVM bytecode) | src/PoseidonT3Bytecode.sol |
| Verifier adapters (proof plumbing, per-N mint dispatch) | src/MintVerifierAdapter.sol, src/MintVerifierA2Adapter.sol, src/SpendVerifierAdapter.sol |
| Identity-M-bound spend entrypoints (A1/A2/B1) + e2e tests | Notes.spendCoupled{A1,A2,B1}, alberta_buck.wallet.unilateral_a1, test/NotesCoupled{A1,A2,B1}.t.sol, test/SpendVerifier.t.sol |
| Prover scripts (witness gen + ptau ceremony) | scripts/snark/prove_*, scripts/snark/setup.sh |
Every property claimed in this document is pinned by an executable witness
– the Python suites exercise the wallet reference, the Forge suites the
on-chain verifiers, and the cross-artifact vectors
(alberta_buck.wallet \( \to \) vm.readFile) keep the two byte-for-byte
aligned.
| Suite | Count | What it demonstrates |
|---|---|---|
alberta_buck/test/test_unilateral_a2.py |
17 | A2 mint binding; delivery decrypt; the fold from any account; unilateral receipt names both parties; collusion un-nameable + un-spendable; the split key refused three ways; identity-hiding; mint unlinkability |
alberta_buck/test/test_unilateral_a1.py |
12 | A1 mint; idHash binds the public issuer + Schnorr; recipient-side receipt names both; tamper rejection |
alberta_buck/test/test_b1_binding.py |
11 | B1 depositor binding; issuer-unilateral receipt names the depositor; collusion-substitution rejected; Mallory blind; unlinkability |
alberta_buck/test/test_e2e_payload.py |
7 | the delivered payload SUFFICES: the shipped randomness opens the shipped ciphertexts, the shipped salt locates the issuer's naming leaf, and the mailbox key – not the Identity scalar – decrypts |
test/B1Binding.t.sol |
6 | on-chain verifyDepositorBinding parity + soundness |
test/NotesCoupledB1.t.sol |
8 | end-to-end spendCoupledB1: the depositor-binding + bound membership path |
test/NotesA2Tie.t.sol |
4 | the A2 mint commits \( T \): the binding and the mint proof must agree on it |
test/IdentityAccumulator.t.sol |
10 | the root authority, subtree enrollment, the root ring and consumer ages, public paths through the aggregator |
test/NotesIdentityMembership.t.sol |
6 | the gate slots: the fold verifier cannot be cleared, and an addressed spend without it reverts before the spend SNARK |
test/NotesE2E.t.sol |
12 | the full lifecycle per flavour with EVERY verifier real (batch mint, spend, and the folded gate or the sigma-plus-membership one); double-spend reverts; per-phase gas |
Build/test entry points: make nix-test (Forge), make nix-test-python
(wallet reference), make nix-snark-e2e-fixtures (the all-real-verifier
lifecycle fixtures).
Cross-References
- alberta-buck-notes.org – architectural overview of the Notes primitive (flavors, Merkle tree, demurrage interaction). The other living half of the Notes documentation pair; this document is the worked, executable half.
- alberta-buck-proofs.org – formal correctness arguments for these gates (Part IV, Theorems 7-12).
- alberta-buck-verifier-implementation.org – SNARK toolchain engineering: the witness-generation root cause, rapidsnark integration, trusted setups.
- 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. - The unified identity-M / "one gadget" / Registry-Identity accumulator design (commit-before-use) is now in alberta-buck-notes.org ("Mutual Decryptability", "one gadget") and here in "The Identity-M Spend Path" + accumulator sections. (Superseded the original memo.)
- Non-deniable-receipt invariant: see alberta-buck-notes.org "The Non-Deniable-Receipt Invariant"; per-step privacy audit and "EOA Transfer Mirror" are above in this document; theorems in alberta-buck-proofs.org. The mint-side issuer bindings are wallet sigmas (
verifyIssuerSchnorr,verifyIssuerReenc); the note <-> eEnc tie is a relation inside the folded gate (see "A2 Identity-Targeted Spend"). circuits/mint_batch.circom– the batch-mint circuit (one compile per pinned N).circuits/spend.circom– the flavor-agnostic spend (note-proof) circuit.circuits/deposit_fold_a1.circom,circuits/deposit_fold_a2.circom– the addressed gate: one witness, four relations for A1 and six for A2, including the note \( \leftrightarrow \) \( eEnc \) tie and A2's key tie.circuits/identity_membership_b1.circom– B1's membership, with its blind proven rather than witnessed on a generator of unknown logarithm.src/Notes.sol,src/MintBatchN*Groth16Verifier.sol,src/MintVerifierAdapter.sol,src/SpendGroth16Verifier.sol,src/SpendVerifierAdapter.sol,src/DepositFoldA1Verifier.sol,src/DepositFoldA2Verifier.sol,src/DepositFoldVerifierAdapter.sol,src/IdentityMembershipB1Verifier.sol,src/IdentityMembershipB1VerifierAdapter.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;scripts/snark/setup_deposit_fold.sh,scripts/snark/setup_b1_membership.sh– the gate circuits + trusted-setup pipelines.test/MintVerifier.t.sol,test/SpendVerifier.t.sol,test/NotesE2E.t.sol– the Foundry tests that pin the soundness stories to executable assertions.