
The cryptography of the Alberta Buck identity layer names an identity point \(
M \in \mathbb{G}_1 \) and binds a transaction; the artifact a human actually
holds is a receipt. This document specifies that receipt: a plain-text slip,
sized for an 80mm thermal printer, that prints the plain-text Identity of both
parties and the transaction details, and embeds – as a wrapped text payload –
everything required to re-verify the payment by re-scanning the slip and
re-running the wallet's verifiers. The receipt closes two gaps the on-chain
proofs leave open: it carries each party's canonical_identity_data so a point
\( M \) resolves to a human name (verified by recomputing \( M = \mathrm{keccak}(
\text{data}) \cdot G \)), and it states which on-chain facts (registry records,
the Transfer / SpentCoupled* event) anchor the proof. Verification is two-tier:
offline, re-running the proof algebra and the point\(\to\)human bridge from the
embedded payload alone; and chain-anchored, confirming the embedded records
against a node. Five receipt kinds are specified – an EOA transfer from a
public or a private counterparty, and the B1, A1, and A2 Note flavors. The
Note kinds are producible by either party – the depositor's copy or the
issuer's "I paid X" copy – because the Identity-M note payload (the idHash
preimage material: the addressed ciphertexts and issuer-signature words) is
held by both, and the receipt's disclosed identity preimages let any verifier
derive the identity scalars and re-check it deterministically. The remaining
proofs are the primitives already shipped (the approve handshake +
verifiable_decrypt for EOA, the batch Schnorr for public issuers, and for
the private-issuer A2 case the recipient-blinded verifyIssuerReenc binding
the mint verified on chain). (Decryptability, Identity)
What a Receipt Is (and Is Not)
A BUCK receipt is a record of a completed payment, generated by a party to
that payment and printable on a commodity thermal printer. For an EOA
transfer the generator is the recipient; for a Note it is either party –
the depositor prints their copy, the issuer prints the payer-side "I paid X"
copy – since the Identity-M note payload travels to both at mint/spend. The
receipt's role field records which side produced it, and the renderer marks
that party "- you". It is the bilateral-disclosure half of the system's
privacy promise made tangible: bulk chain-analytics cannot reconstruct who
paid whom, but either party to a payment can produce a slip that names the
counterparty in the clear and proves it.
- It IS: human-readable; names both parties' plain-text Identity (a display subset on paper, the full KYC datum in the payload); states amount, kind, time, and on-chain references; self-verifying from its own text payload; and extensible with optional informational line items.
- It is NOT: a bearer instrument (it redeems nothing – the value already moved, and any Note nullifier it references is already spent); a chain explorer; or a re-derivation of the KYC binding of \( M \) to a legal person (that rests on the issuer's Pointcheval-Sanders credential, assumed here). The optional line items are not covered by verification.
Two Gaps the Proofs Leave to the Receipt
The point -> human bridge
Every shipped proof names an identity point \( M \). A receipt must name a person. The link is the preimage:
A party's Identity datum is the canonical JSON canonical_identity_data (the
KYC fields, sorted-key, no-whitespace – Identity Example). Its identity
scalar is \( m = \mathrm{keccak}(\texttt{canonical}) \bmod \texttt{ORDER} \)
and its identity point is \( M = m \cdot G \).
The receipt therefore carries each party's canonical_identity_data, and a
verifier recomputes \( \mathrm{keccak}(\texttt{data}) \cdot G \) and checks it
equals the named \( M \). That single check turns a curve point into "Alice
Johnson, Alberta Identity Card, Alberta, Canada."
Requirement (data availability). The generating wallet must hold the
counterparty's canonical_identity_data. For a public identity it is
published (off-chain attestation). For a private identity it is disclosed
bilaterally when the parties approve one another (or when a Note is
delivered) – the same exchange that lets each verify \( M = \mathrm{keccak}(
\cdot)\cdot G \) against the credential they can decrypt. A wallet that never
received the preimage can still print the point and the issuer reference,
but not the human name (see Open Questions).
The trust anchor
Offline math proves the receipt is internally sound; it cannot prove the embedded keys and events are the real on-chain ones. That is a chain read. Hence two tiers, both specified below.
The Two-Tier Verification Model
| Tier | Needs | Confirms |
|---|---|---|
| 1 – offline | the slip's text payload only | the proof algebra verifies; both \(M\) recompute from their canonical_identity_data; for Notes the idHash preimage and addressed ciphertexts re-check; the named \(M\), amount, and parties match the payload |
| 2 – anchored | tier 1 + an RPC node | the embedded pk / E_addr / isPublicIdentity equal the registry's for each named address; the Transfer / SpentCoupled* event at (txhash, logIndex) carries the stated value / recipient / nullifier in a mined block |
Tier 1 says "these proofs are valid and name these humans"; tier 2 says "and those identities and that payment are the genuine on-chain ones." Together they are the non-deniable receipt of Decryptability. Per the design decision, the payload is offline-complete: every value tier 1 needs is embedded, and tier 2's references ride alongside so anchoring is optional, not required, to read a receipt.
(Receipt soundness.) If tier 1 passes, the slip's proofs are valid for the embedded public inputs and each named \( M \) is the hash of the printed Identity datum. If tier 2 also passes, those inputs are the on-chain registry records and the payment is a mined event. No party can forge a passing slip naming a counterparty who did not transact (proof unforgeability + event immutability); a generator may decline to print a receipt, but cannot print a false one.
The AB-RCPT/1 Envelope
The verified core is a deterministic map, serialized canonically (CBOR), and
printed as AB-RCPT/1. followed by base64url of the bytes, wrapped to the
print width. (Text-only by decision – no QR; the payload is literally
plain-text, re-typable and OCR-able, and survives a scanner-less verifier.)
The receipt id is base32(sha256(core)) truncated to a short handle. The
optional line items live outside the core (they are unverified) and are
printed in their own section, never in the payload.
Shown as JSON for readability (the wire form is CBOR):
{
"v": 1,
"type": "eoa-pub" | "eoa-priv" | "note-b1" | "note-a1" | "note-a2",
"role": "recipient" | "issuer", // who generated it ("issuer": Note kinds only)
"chainid": 1,
"contracts": { "registry": "0x..", "buck": "0x..", "notes": "0x.." },
"payer": Party, // funds FROM (for a Note: the issuer)
"payee": Party, // funds TO (the recipient / depositor)
"txn": Txn,
"note": NotePayload, // Note kinds: the Identity-M idHash preimage
"proof": Proof, // type-specific (below)
// role-dependent self-naming / counterparty-naming vd records:
"payee_vd": Vd, // role=recipient: payee's own E_addr -> payee.M
"payer_vd": Vd, // role=issuer, private payer (A2): own E_addr -> payer.M
"vd_payee": Vd // role=issuer, B1: eDepForIss -> payee.M
}
Party {
"addr": "0x..",
"kind": "public" | "private",
"identity": "<canonical_identity_data>", // preimage; keccak->m, M=m*G
"M": ["0x..","0x.."], // named identity point
"pk": ["0x..","0x.."], // registered _pk (tier-2 anchor)
"E_addr": { "R":["..",".."], "C":["..",".."] } // registered ct (private; tier-2)
}
Txn {
"kind": "eoa-transfer" | "note-spend",
"value": 250000000, // BUCK base units (6 dp)
"timestamp": 1779999011, // block time
"event": "Transfer" | "SpentCoupledB1" | "SpentCoupledA1" | "SpentCoupledA2",
"txhash": "0x..", "block": 1234567, "logindex": 3,
// note-spend adds:
"mint_txhash": "0x..", "mint_block": 1234500, "nullifier": "0x.."
}
NotePayload { // per flavor -- the idHash preimage material
// B1: "sigma_R", "sigma_s" (id_hash_b1(m_iss, sigma))
// + "eDepForIss" (the SpentCoupledB1 event ct)
// A1: "eNote", "eRec", "sigma_R", "sigma_s"
// (id_hash_a1(eNote, m_iss, sigma))
// A2: "eNote", "eIss" (id_hash_a2(eNote, eIss))
}
proof is the witness the matching verifier consumes (the approve handshake
for eoa-priv; the note opening + minted cms[] + the public issuer's batch
Schnorr + nullifier/face for the Note kinds; an A2 receipt additionally
carries the mint's issuer_reenc binding). The vd records carry the
generating side's self-naming – a verifiable_decrypt of one's own
registered E_addr revealing one's M – and, for the issuer-side B1
receipt, the issuer's verifiable decryption of the event's eDepForIss
naming the depositor. The Note legs themselves need no party proof: the
disclosed identity preimages let any verifier derive m_iss / m_rec,
recompute the idHash, and decrypt the addressed ciphertexts directly –
which is exactly why both parties can generate the same receipt core.
Per-Scenario Payloads
For the EOA kinds the wallet owner is the payee; a Note receipt is
generated from either side (the role field). Each row names the tier-1
re-check. All Note flavors use the unified spend nullifier
\( \mathrm{Poseidon}(\rho, \texttt{idHash}, 4242) \) (the shipped
spend.circom tag) and anchor at the Minted and SpentCoupled* events.
| type | payer | party-naming (tier-1 re-check) | txn anchor |
|---|---|---|---|
| eoa-pub | EOA, public | none – payer.M recomputed from public identity; pk anchors addr |
Transfer(from,to,value) |
| eoa-priv | EOA, private | approve handshake cp_proof + payee's vd of E_for_spender |
Transfer |
| note-b1 | issuer, public | idHash = id_hash_b1(m_iss, sigma) + batch Schnorr; issuer side: vd of eDepForIss names depositor |
Minted + SpentCoupledB1 |
| note-a1 | issuer, public | idHash = id_hash_a1(eNote, m_iss, sigma) + Schnorr; Dec(eNote, m_rec) = vG, Dec(eRec, m_rec) = M_rec |
Minted + SpentCoupledA1 |
| note-a2 | issuer, private | idHash = id_hash_a2(eNote, eIss); Dec(eNote, m_rec) = vG, Dec(eIss, m_rec) = M_iss; issuer_reenc binding |
Minted + SpentCoupledA2 |
eoa-pub – EOA transfer from a public counterparty
Lightest: the payer's identity is public, so payer.M is recomputed from
it and no payer proof is needed; the payer pk ties the Transfer.from
address to that public record (tier 2). Payload = payer Party (public) +
payee Party + payee vd_proof + the Transfer anchor.
eoa-priv – EOA transfer from a private counterparty
The ApproveReceipt half: at approve time the payer re-encrypted their M for
the payee (E_for_spender) and proved it (cp_proof = verifyApprove); the
payee verifiably decrypts it to payer.M (vd_proof). Payload = both
Parties (private) + ApproveReceipt proofs + payee self vd_proof +
Transfer anchor + registry pk / E_addr for both (tier 2).
note-b1 / note-a1 – Note from a public issuer
The four-link chain, now Identity-M-bound: the opening recomputes cm,
cm is in the mint cms[], the issuer is bound into the leaf itself
(the verifier derives \( m_{iss} \) from the disclosed issuer preimage and
requires opening.idHash to recompute as id_hash_b1(m_iss, sigma) /
id_hash_a1(eNote, m_iss, sigma)) as well as over the batch (the Schnorr,
whose Fiat-Shamir transcript folds in the issuer account + chainid – so
the printed acct is provably the mint's msg.sender, the account whose
BUCK funded the note), and the nullifier was burned in the
SpentCoupledB1 / SpentCoupledA1 event paying the payee.
The payee leg differs by flavor. A1 is addressed: eNote encrypts the
face under the recipient identity point and eRec the recipient identity
under itself, so \( \mathrm{Dec}(eNote, m_{rec}) = vG \) and
\( \mathrm{Dec}(eRec, m_{rec}) = M_{rec} \) bind the named recipient into
the leaf – deterministically, from the disclosed preimage; only the
addressed identity satisfies the eNote relation (the face-pinning
principle). B1 is bearer: the depositor is named at spend – the
SpentCoupledB1 event publishes eDepForIss, the depositor's Identity
re-encrypted under the public issuer's registered key (proven well-formed
on chain by verifyDepositorBinding) – so the issuer-side receipt names
the depositor by verifiably decrypting it (vd_payee), while the
recipient-side receipt self-names via payee_vd as usual.
note-a2 – Note from a private issuer
The hard case: both parties are encrypted-Identity. Identity-M targeting
makes it symmetric and proof-free on the note legs: the issuer encrypted
the face under the recipient identity point (eNote) and their own
registered Identity under the same point (eIss), and
opening.idHash = id_hash_a2(eNote, eIss) commits both. The verifier
derives \( m_{rec} \) from the disclosed payee preimage and checks
\( \mathrm{Dec}(eNote, m_{rec}) = vG \) and
\( \mathrm{Dec}(eIss, m_{rec}) = M_{named} = \texttt{payer.M} \) – the
second equation is the coupling: it algebraically forces eIss's key to
be \( M_{rec} \), so the note was addressed to the payee. Anti-framing –
that eIss re-encrypts the issuer's registered credential, so the
recovered Identity is the true minter, never a victim – is the
issuer_reenc binding the issuer shipped at mint, verified on chain by
the A2 mint overload (IdentityRegistry.verifyIssuerReenc + the per-leaf
eIss leaf-tie) and re-verified offline here; its transcript folds in the
issuer account + chainid, anchoring the printed acct to the mint's
msg.sender.
Payload = both Parties (private) + the note payload (eNote, eIss) +
the issuer_reenc binding + the generator's self-naming (payee_vd or
payer_vd) + mint and spend anchors. The chain saw only the blinded
binding (\( \hat{T} \) hides \( M_{iss} \), \( Q \) hides \( M_{rec} \));
no un-blinding scalar is ever disclosed – the earlier account-keyed design
needed a disclosed \( \gamma \) here, but identity-targeting lets the
verifier decrypt directly, since the receipt already discloses the payee
preimage. A receipt assembled without the binding still prints, with
M_iss recovered but stamped UNVERIFIED ISSUER – a narrow case, since
every chain-minted A2 leaf carries a verified binding (the mint reverts
otherwise).
Why there is no "B2" (private-issuer bearer) receipt
A2 is the only private-issuer receipt kind because A2 is the only
private-issuer note kind. A private-issuer bearer note ("B2") is
impossible, not merely unimplemented: eIss is the issuer's Identity
re-encrypted under the recipient's identity point, openable only with
\( m_{rec} \) – and a bearer note has no recipient at mint for the issuer
to address. Any
binding the issuer could instead attach to "whoever bears the note" would be
openable by every holder of the freely-circulating note (no privacy over a
public B1 issuer) or by no one (no receipt). So a private issuer must address
the note (A2) and a bearer note must have a public issuer (B1); the mint SNARK
now binds this (issuerMode, b14e0f0, 68e5b55). See Why B2 is
impossible in the Decryptability companion.
The Physical Artifact (ESC/POS, 80mm, 203dpi, 576px)
576 dots wide; Font A (12x24) gives 48 columns, Font B (9x17) gives 64. The
body prints at 48 cols; the base64url payload prints at Font B / 64 cols to
cut its line count. Output is 7-bit ASCII only (names transliterated if
needed – see Open Questions). ESC/POS control: ESC @ init, ESC a n
align, ESC E n emphasis, GS V cut; no graphics.
The human-readable parties and amount are derived from the verified core – a single source of truth – so what a person reads cannot drift from what a verifier checks. Only the NOTES section is free text.
ALBERTA BUCK
P A Y M E N T R E C E I P T
------------------------------------------------
Receipt a2cg5sgjwfur
------------------------------------------------
FROM (payer) (PRIVATE IDENTITY)
Alice Johnson
Alberta Identity Card - Alberta, Canada
acct 0x0a11ce00000000000000000000000000000a11ce
idpt 0x07a0b762be40..d418d7bf5e7c
------------------------------------------------
TO (payee - you) (PRIVATE IDENTITY)
Bob Smith
Corporate Registration - Alberta, Canada
acct 0x0b0b000000000000000000000000000000000b0b
idpt 0x0cef52e77ea5..0ae1f916d96b
------------------------------------------------
TRANSACTION
Type EOA xfer (Identity-bound, private payer)
Amount 500.000000 BUCK
When 2026-05-28 20:10 UTC
Chain 1
Block 1234567
Log 2
Tx 0xeeeeeeeeeeee..eeeeeeeeeeee
------------------------------------------------
VERIFICATION
status @ issue: VALID
receipt a2cg5sgjwfur
------------------------------------------------
a record, not a bearer instrument
github.com/pjkundert/alberta-buck
Verification Procedure
albertabuck verify [-|FILE] [--rpc URL]:
- Parse. Strip to
AB-RCPT/1.….END, base64url-decode, decode the canonical bytes to the core map; reject unknownv/type/role. - Tier 1 (always). For
payerandpayee: recompute \( M' = \mathrm{keccak}(\texttt{identity}) \cdot G \) and require \( M' = M \). Check the generating side's self-naming perrole(recipient:payee_vd; issuer:payer_vdfor a private payer). Dispatch ontype: eoa-pub needs no payer proof; eoa-priv re-runs the approve handshake + the payee'svdofE_for_spender. For the Note kinds derive \( m_{iss}, m_{rec} \) from the disclosed preimages and require: the opening'scm\( \in \)cms[];opening.idHashrecomputes from thenotepayload (id_hash_b1/a1/a2); the addressed ciphertexts decrypt correctly under \( m_{rec} \) (A1:eNote,eRec; A2:eNote,eIss– the latter topayer.M, the coupling); the public issuer's batch Schnorr (B1/A1) or theissuer_reencbinding (A2) verifies against the embedded registry record and the printed issueracct; the nullifier equals \( \mathrm{Poseidon}(\rho, \texttt{idHash}, 4242) \); andface=opening.v=txn.value. For an issuer-side B1 receipt also verifyvd_payeeover the event'seDepForIss. Require the named \( M \),value, and addresses to match the human-readable fields the renderer derived. - Tier 2 (
--rpc). For each named address read_pk,_E_addr,isPublicIdentityand assert equality with the payload; read the log at(txhash, logIndex)and assert theevent(Transfer/SpentCoupled*),value/face,recipient/from/to, and (for Notes)nullifiermatch, theMintedevent atmint_txhashcarriescms[]from the printed issueracct(= thetransferFromsource that funded the note), and (B1) the spend'seDepForIssequals the payload's; assert the block is mined and final. - Report.
VALID(+ named identities, amount) /INVALID (reason)/UNVERIFIED ISSUERfor an A2 slip carrying no binding.
Wallet UX and Storage
The receipt is generated from a selected payment:
- Select a row in tx history where the owner is
to(aTransfer) orrecipient(aSpentCoupled*); an issuer instead selects a spend of a note it minted (for B1, scanningSpentCoupledB1logs for its ownissueraddress and decrypting eacheDepForIss). - Gather per the scenario table: a registry read (public payer), stored
approve material (private EOA), or the stored note artifact – the
opening, the Identity-M payload (eNote/eRec/eIss,sigma), and the mint reference; plus each party'scanonical_identity_datafrom wallet storage. - Self-name:
verifiable_decryptthe owner's ownE_addr(and, B1 issuer side, the event'seDepForIss). - Anchor: read the
Transfer/SpentCoupled*event and registry records. - Prove + assemble: generate the
vdrecord(s) withsk_owner, build the core, verify it locally, set the status banner. - Render + print: ESC/POS at the configured width; or emit plain text.
Storage the wallet must retain (beyond keys): the owner's (sk,
identity_data); per approved counterparty (addr, identity_data,
E_for_me, cp_proof); per received or minted Note the opening + the
Identity-M payload and its mint reference (mint_txhash, cms or cm index,
issuer addr, issuer_sig | issuer_reenc binding); and an event source
(indexer or RPC) for the Transfer / SpentCoupled* logs and registry
reads. Note that this is exactly the material that already travels with a
note off chain – the issuer keeps what it created at mint, the recipient
what it was handed – so both-party generation needs no extra exchange.
CLI surface (proposed). albertabuck receipt <txref> [--width 48|64]
[--escpos|--text] to produce a slip; albertabuck verify [-|FILE] [--rpc] to
check a scanned payload. Both reuse the wallet library; verify has no
wallet-secret dependency (a third party runs it).
The Whole Flow, Executed: Live EVM, Real Proofs, Both Receipts
Everything this document specifies runs. The blocks below are org-babel
source blocks sharing one Python session: re-exporting this document (make
nix-venv-doc-receipt) re-runs them top to bottom against a freshly spawned
local EVM, and the printed RESULTS are regenerated. The worlds come from
the committed end-to-end fixtures
(alberta_buck/test/vectors/e2e/{a1,a2,b1}.json, built by make
nix-snark-e2e-fixtures and shipped as package data): one mutually-consistent world per Note
flavor with a real Groth16 proof at every gate – batch mint, spend,
bound G1-tie membership, and (A1/A2) the note\(\leftrightarrow\)=eEnc= tie
– and real named identities: the canonical Alice Johnson / Bob Smith KYC
data of the wallet's identity vectors. Proof generation happened when the
fixtures were built (a wallet does it the same way; the measured prover
wall-times ride along in each fixture, and the flow companion's Prover
Bill of Materials prices the artifacts and proving per flavor and role);
everything below – registry Merkle updates, the identity-bound approve
handshake, Groth16 verification, escrow and payout – is live EVM
execution, exactly the path test/NotesE2E.t.sol drives under forge.
Boot: a chain whose id the proofs demand
Every Fiat-Shamir transcript and SNARK public input in the fixtures binds
chainid=1, so the session runs anvil with chain-id 1; the pinned genesis
timestamp (plus a fixed 12s block cadence below) makes the whole
transcript – including the receipt bytes – reproducible run to run.
import random
from alberta_buck.sim.anvil import Anvil
from alberta_buck.sim.notes_stack import E2EFixture, NotesStack
from alberta_buck.wallet.envelope import serialize_core, deserialize_core, receipt_id
from alberta_buck.wallet.verify_receipt import verify_receipt
from alberta_buck.wallet.render import render_receipt, TextDriver
_seed = random.Random(0xAB12C97) # ONE deterministic transcript
rng = lambda: _seed.getrandbits(256)
anvil = Anvil(chain_id=1, auto_impersonate=True, timestamp=1780000000).start()
blk = anvil.w3.eth.get_block("latest")
print(f"anvil up: chain-id {anvil.w3.eth.chain_id}, genesis timestamp {blk['timestamp']}")anvil up: chain-id 1, genesis timestamp 1780000000
The A2 world: a private issuer pays an addressed recipient
The hardest flavor first. Bob Smith (a private Corporate Identity) minted a 250-BUCK note addressed to Alice Johnson's identity point – not to any account she owns.
import json
fx = E2EFixture.load("a2")
iss, dep = json.loads(fx.issuer.identity), json.loads(fx.depositor.identity)
print(f"flavor {fx.flavor} (addressed, PRIVATE issuer); face {fx.face/1e6:.6f} BUCK")
print(f"issuer {iss['given_name']} {iss['family_name']} ({iss['id_type']})"
f" acct 0x{fx.issuer.addr:040x}")
print(f"depositor {dep['given_name']} {dep['family_name']} ({dep['id_type']})"
f" acct 0x{fx.depositor.addr:040x}")
print(f"prover wall-times (offline, at mint/spend): {fx.timings}")
flavor a2 (addressed, PRIVATE issuer); face 250.000000 BUCK
issuer Bob Smith (Corporate Registration) acct 0x0a11ce00000000000000000000000000000a11ce
depositor Alice Johnson (Alberta Identity Card) acct 0xde9051700000000000000000000000000de90517
prover wall-times (offline, at mint/spend): {'mint_prove_s': 0.959, 'spend_prove_s': 0.76, 'membership_prove_s': 1.556, 'note_binding_prove_s': 11.7}
Deploy the real stack; bind the Identities (Merkle updates on the EVM)
The full contract stack – IdentityRegistry with the incremental
Poseidon identity accumulator, the Buck stack, Notes, and the real
generated Groth16 verifiers behind their adapters – then the two
identity binds. Each bind appends the party's identity leaf; the
contract's incrementally-maintained root must replay the wallet tree's.
stack = NotesStack(anvil, fx, rng=rng, block_time=12)
binds = stack.bind_identities()
for s in binds:
print(f"{s.name:26s} gas={s.gas:>9,}")
onchain = stack.reg.functions.identityRoot().call()
print(f"identityRoot (EVM-incremental Poseidon) = {hex(onchain)[:20]}...")
print(f" == the wallet's IdentityTree root: {onchain == int(fx.raw['identityRoot'])}")bind issuer gas= 594,779 bind depositor gas= 557,628 identityRoot (EVM-incremental Poseidon) = 0xab29ff540f4d0a25d9... == the wallet's IdentityTree root: True
Fund, and the identity-bound approve (the real CP handshake)
The issuer is granted BuckCredit and mints BUCK; then each private party
identity-approves the Notes pool with the real Chaum-Pedersen
re-encryption proof (Buck.approve / IdentityRegistry.verifyApprove) –
the same mutual-decryptability handshake an EOA transfer uses, here
providing the escrow allowance and the pool's receipt fragments. No
storage hacks: every step is the legitimate path.
for s in stack.fund_issuer():
print(f"{s.name:26s} gas={s.gas:>9,}")
for party, amount, name in ((fx.issuer, 2 * fx.face, "approve issuer->pool"),
(fx.depositor, 0, "approve depositor->pool")):
s = stack.approve_pool(party, amount, name)
print(f"{s.name:26s} gas={s.gas:>9,} (CP proof: verifyApprove)")createCredit gas= 202,935 activateCredit gas= 42,349 buck.mint gas= 97,850 approve issuer->pool gas= 179,302 (CP proof: verifyApprove) approve depositor->pool gas= 137,964 (CP proof: verifyApprove)
Mint (the issuer's act)
The issuer submits the real mint_batch_a2 Groth16 proof with the
per-leaf re-encryption binding; the contract verifies both, escrows the
face, and installs the SNARK-attested new note root.
mint = stack.mint()
print(f"Notes.mint tx {mint.txhash[:20]}.. block {mint.block} "
f"gas {mint.gas:,} ({mint.seconds:.3f}s on anvil)")
print(f" proof was generated offline by the issuer in "
f"{fx.timings['mint_prove_s']}s (Groth16, mint_batch_a2 N=1)")
pool = stack.buck.functions.balanceOf(stack.notes.address).call()
print(f" escrowed: pool balance = {pool/1e6:.6f} BUCK")Notes.mint tx 0xc2fba76c99295fee9e.. block 32 gas 650,915 (0.106s on anvil) proof was generated offline by the issuer in 0.959s (Groth16, mint_batch_a2 N=1) escrowed: pool balance = 250.000000 BUCK
Transmission verification (the recipient's act, offline)
Alice receives the note off chain – the opening, the Identity-M payload
(eNote, eIss), the binding, and the mint reference – and verifies it
with one secret she already owns: her identity scalar \( m_{rec} \).
These are the same deterministic checks any receipt verifier later
re-runs.
from alberta_buck.wallet.bn254 import G1, mul, eq
from alberta_buck.wallet.elgamal import elgamal_decrypt
from alberta_buck.wallet.notes import id_hash_a2, note_commitment
from alberta_buck.wallet.issuer_reenc import issuer_reenc_verify
nc = fx.note_cts()
m_rec = fx.depositor.m
print(f"idHash == Poseidon8(eNote, eIss): "
f"{id_hash_a2(nc['eNote'], nc['eIss']) == fx.opening.id_hash}")
print(f"Dec(eNote, m_rec) == v*G: "
f"{eq(elgamal_decrypt(nc['eNote'], m_rec), mul(G1, fx.opening.v))}")
print(f"Dec(eIss, m_rec) == issuer's M: "
f"{eq(elgamal_decrypt(nc['eIss'], m_rec), fx.issuer.M)} (the coupling)")
print(f"mint binding (anti-framing) verifies: "
f"{issuer_reenc_verify(fx.issuer.pk, fx.issuer.E, nc['eIss'], fx.binding, fx.issuer.addr, fx.chainid)}")
print(f"cm in the Minted batch: "
f"{note_commitment(fx.opening) in fx.cms}")idHash == Poseidon8(eNote, eIss): True Dec(eNote, m_rec) == v*G: True Dec(eIss, m_rec) == issuer's M: True (the coupling) mint binding (anti-framing) verifies: True cm in the Minted batch: True
Deposit (the recipient's act, on chain)
Alice spends from her registered account: the real spend Groth16 against
the replayed note tree, the deposit-coupling sigma, the bound G1-tie
membership proof over the same committed point, and the
note\(\leftrightarrow\)=eEnc= tie – one atomic spendCoupledA2.
spend = stack.spend()
t = fx.timings
print(f"Notes.spendCoupledA2 tx {spend.txhash[:20]}.. block {spend.block} "
f"gas {spend.gas:,} ({spend.seconds:.3f}s on anvil)")
print(f" proofs offline: spend {t['spend_prove_s']}s + membership "
f"{t['membership_prove_s']}s + note-tie {t['note_binding_prove_s']}s")
print(f" nullifier burned: "
f"{stack.notes.functions.nullifiers(fx.nullifier).call()}")
from web3 import Web3
bal = stack.buck.functions.balanceOf(
Web3.to_checksum_address(f'0x{fx.payout:040x}')).call()
print(f" paid out: depositor balance = {bal/1e6:.6f} BUCK")Notes.spendCoupledA2 tx 0xed88f3c35b57eefcb8.. block 33 gas 1,076,632 (0.110s on anvil) proofs offline: spend 0.76s + membership 1.556s + note-tie 11.7s nullifier burned: True paid out: depositor balance = 249.999999 BUCK
The two receipts, side by side
Both parties now print their slips from the material each already holds – Alice her copy, Bob his payer-side "I paid X" copy – anchored to the live chain's real transaction hashes, blocks, and timestamps. The deterministic note legs are byte-identical between the two; only the self-naming proof (and the "- you" marker) differs.
anchors = stack.anchors(mint, spend)
dep_core = fx.build_receipt("recipient", stack.contracts, rng=rng, **anchors)
iss_core = fx.build_receipt("issuer", stack.contracts, rng=rng, **anchors)
for label, core in (("depositor (Alice)", dep_core), ("issuer (Bob)", iss_core)):
b = serialize_core(core)
res = verify_receipt(deserialize_core(b))
print(f"{label:18s} receipt {receipt_id(b)} tier-1: {res.reason}"
f" ({len(b):,} B payload)")depositor (Alice) receipt 4egxeqphrl63 tier-1: VALID (6,606 B payload) issuer (Bob) receipt 4kymncsgb4jc tier-1: VALID (6,603 B payload)
def side_by_side(a, b, gap=4):
la, lb = a.rstrip().split("\n"), b.rstrip().split("\n")
w, n = max(map(len, la)), max(len(la), len(lb))
la += [""] * (n - len(la)); lb += [""] * (n - len(lb))
return "\n".join(f"{x:<{w}}{' ' * gap}{y}".rstrip() for x, y in zip(la, lb))
render = lambda c: TextDriver(48).render(render_receipt(c))
print(side_by_side(render(dep_core), render(iss_core)))
ALBERTA BUCK ALBERTA BUCK
P A Y M E N T R E C E I P T P A Y M E N T R E C E I P T
------------------------------------------------ ------------------------------------------------
Receipt 4egxeqphrl63 Receipt 4kymncsgb4jc
------------------------------------------------ ------------------------------------------------
FROM (payer) (PRIVATE IDENTITY) FROM (payer - you) (PRIVATE IDENTITY)
Bob Smith Bob Smith
Corporate Registration - Alberta, Canada Corporate Registration - Alberta, Canada
acct 0x0a11ce00000000000000000000000000000a11ce acct 0x0a11ce00000000000000000000000000000a11ce
idpt 0x0cef52e77ea5..0ae1f916d96b idpt 0x0cef52e77ea5..0ae1f916d96b
------------------------------------------------ ------------------------------------------------
TO (payee - you) (PRIVATE IDENTITY) TO (payee) (PRIVATE IDENTITY)
Alice Johnson Alice Johnson
Alberta Identity Card - Alberta, Canada Alberta Identity Card - Alberta, Canada
acct 0xde9051700000000000000000000000000de90517 acct 0xde9051700000000000000000000000000de90517
idpt 0x07a0b762be40..d418d7bf5e7c idpt 0x07a0b762be40..d418d7bf5e7c
------------------------------------------------ ------------------------------------------------
TRANSACTION TRANSACTION
Type Note spend — A2 addressed, private issuer Type Note spend — A2 addressed, private issuer
Amount 250.000000 BUCK Amount 250.000000 BUCK
When 2026-05-28 20:28 UTC When 2026-05-28 20:28 UTC
Chain 1 Chain 1
Block 33 Block 33
Log 2 Log 2
Tx 0xed88f3c35b57..d91308f0aced Tx 0xed88f3c35b57..d91308f0aced
Mint 0xc2fba76c9929..f00d5f89e9ac Mint 0xc2fba76c9929..f00d5f89e9ac
------------------------------------------------ ------------------------------------------------
VERIFICATION VERIFICATION
status @ issue: VALID status @ issue: VALID
receipt 4egxeqphrl63 receipt 4kymncsgb4jc
------------------------------------------------ ------------------------------------------------
a record, not a bearer instrument a record, not a bearer instrument
github.com/pjkundert/alberta-buck github.com/pjkundert/alberta-buck
Tier-2: anchoring both slips to the live chain
A verifier with an RPC node confirms the embedded registry records and the event anchor are the genuine on-chain ones.
from web3 import Web3
dep_addr = Web3.to_checksum_address(f"0x{fx.depositor.addr:040x}")
pk_onchain = stack.reg.functions.pkOf(dep_addr).call()
print(f"registry _pk[depositor] == receipt payee.pk: "
f"{pk_onchain[0] == int(dep_core.payee.pk['x'], 16)}")
rcpt = anvil.w3.eth.get_transaction_receipt(dep_core.txn.txhash)
print(f"spend tx mined in block {rcpt['blockNumber']} "
f"(receipt says {dep_core.txn.block}); "
f"log #{dep_core.txn.logindex} present: "
f"{dep_core.txn.logindex in [l['logIndex'] for l in rcpt['logs']]}")
print(f"issuer acct in receipt == Minted.issuer: "
f"{dep_core.payer.addr[-40:] == f'{fx.issuer.addr:040x}'}")registry _pk[depositor] == receipt payee.pk: True spend tx mined in block 33 (receipt says 33); log #2 present: True issuer acct in receipt == Minted.issuer: True
All three flavors: the cost of each step
The same lifecycle for B1 (bearer, public issuer) and A1 (addressed, public issuer), then the per-flavor cost table: on-chain gas for the two lifecycle transactions (full-call, as a node bills them) and the offline prover wall-times each party paid. Every flavor yields a VALID receipt for both parties.
runs = {"a2": (stack, mint, spend)}
for flavor in ("b1", "a1"):
f2 = E2EFixture.load(flavor)
st = NotesStack(anvil, f2, rng=rng, block_time=12)
steps = st.run_lifecycle()
runs[flavor] = (st, steps["mint"], steps["spend"])
hdr = (f"{'flavor':8s} {'mint gas':>10s} {'spend gas':>10s} "
f"{'mint prove':>11s} {'spend proves':>13s} {'receipts (dep / iss)':>22s}")
print(hdr); print("-" * len(hdr))
for flavor in ("b1", "a1", "a2"):
st, m, s = runs[flavor]
f2, t = st.fx, st.fx.timings
prove_spend = (t["spend_prove_s"] + t["membership_prove_s"]
+ t.get("note_binding_prove_s", 0.0))
a = st.anchors(m, s)
rcpts = []
for role in ("recipient", "issuer"):
core = f2.build_receipt(role, st.contracts, rng=rng, **a)
rcpts.append(verify_receipt(deserialize_core(serialize_core(core))).reason)
print(f"{flavor:8s} {m.gas:>10,} {s.gas:>10,} "
f"{t['mint_prove_s']:>10.2f}s {prove_spend:>12.2f}s "
f"{rcpts[0]:>11s} / {rcpts[1]}")flavor mint gas spend gas mint prove spend proves receipts (dep / iss) ------------------------------------------------------------------------------- b1 520,944 751,313 0.94s 2.32s VALID / VALID a1 520,908 1,083,066 1.07s 16.39s VALID / VALID a2 650,915 1,076,632 0.96s 14.02s VALID / VALID
The mint gas difference (A2 over B1/A1) is the on-chain verification of the per-leaf re-encryption binding – the price of an anonymous issuer; the spend difference (A1/A2 over B1) is the note\(\leftrightarrow\)=eEnc= tie – the price of addressed ("only \( M_{rec} \) can spend"). The prover cost is dominated by the tie circuits (~2.4M-2.9M constraints); all on-chain verification is constant-gas.
Shutdown
anvil.stop()
print("anvil stopped. The receipts above remain verifiable offline --")
print("tier 1 needs no chain at all, only the slip's own text payload.")anvil stopped. The receipts above remain verifiable offline -- tier 1 needs no chain at all, only the slip's own text payload.
Security Considerations
- PII on paper. The slip is a deliberate disclosure: anyone holding it can
name both parties. The display subset (name / id_type / jurisdiction) keeps
the printed face modest, but the payload carries each party's full
canonical_identity_datain base64 – base64 is encoding, not encryption. A receipt must be handled like a signed KYC disclosure, not like cash. - No value, no replay. The slip redeems nothing; a referenced Note nullifier is already burned, an EOA transfer already mined. Re-printing or copying a slip moves no funds.
- Generator honesty bounds. Tier-1/2 bind the parties, amount, and event; the free-text NOTES are unbound, so a generator may write anything there – hence they are labelled informational, not verified, and excluded from the receipt id.
- A2 issuer privacy. The A2 binding is blinded: the chain sees \( \hat{T} \)
and \( Q = M_{rec} + \beta H \), revealing neither the issuer's
\( M_{iss} \) nor the recipient's \( M_{rec} \). An earlier draft
published an unblinded \( T \), which leaks \( M_{iss} = C_i - T \) – and
msg.senderat mint is the issuer – so the blind is load-bearing. The receipt discloses no un-blinding scalar at all: naming flows through decryption ofeIssunder \( m_{rec} \), derivable from the payee preimage the receipt already disclosed – so the only new disclosure is the deliberate one. The mint-SNARKissuerModesignal is bound, the per-leafeIsstie is on chain (themint_batch_a2circuit exposes each committed leaf'seIss, whichNotes.mintmatches to its binding), and the Identity-M spend gate (the deposit-coupling sigma + the bound membership proof over the same committed point + the note\(\leftrightarrow\)=eEnc= tie) makes an un-nameable note un-spendable:eIsskeyed to a throwaway key decrypts, under the depositor's authenticated \( m_{rec} \), to a non-member, and the spend reverts.UNVERIFIED ISSUERtherefore narrows to a receipt assembled without its binding – never a spendable chain-minted leaf. - Account provenance. The printed
acctlines are not free text. The issuer account is folded into the Fiat-Shamir transcript of its binding (the batch Schnorr for B1/A1,issuer_reencfor A2), so tier 1 already pins it; tier 2 confirms it is theMintedevent'sissuer– themsg.senderwhosetransferFromfunded the note. The payee account is theSpentCoupled*event's payoutrecipient; the recipient-side receipt ties it to the named Identity bypayee_vd(mirroring the on-chain deposit-coupling/depositor-binding sigma that authorized the spend from a registered account bound to the same identity scalar). Under Identity-M the spend account need not exist at mint – authorization keys on the identity – so the receipt names whichever registered account the recipient actually used, anchored by the event. - Trust roots. Soundness rests on the proof algebra (tier 1), the on-chain registry + event (tier 2), and the KYC issuer's \( m \leftrightarrow \)person binding (out of scope; assumed via the PS credential).
Phasing
- Phase A (shipped). Envelope +
verifyCLI + the plain-text renderer for all five kinds.eoa-pub,eoa-priv,note-b1,note-a1are soundly bound; the receipt core (envelope.py/build_receipt.py/verify_receipt.py/render.py) is the shipped wallet surface. - Phase B (shipped, off-chain + on-chain issuerMode + A2 leaf-tie).
note-a2is soundly bound: theissuer_reencbinding names the private issuer's registered Identity; the slip readsVALID. The mint-SNARKissuerModegate is bound on chain (b14e0f0/584bb52/68e5b55), so a bearer leaf cannot be private at all; and the per-leafeIsstie (mint_batch_a2+MintVerifierA2Adapter) pins each binding to its committed leaf, so a PRIVATE batch cannot float or duplicate bindings. - Phase C (shipped, Identity-M). The note kinds re-built on the
identity-targeted payloads:
idHashpreimages (id_hash_b1/a1/a2) are conveyed in the envelope and re-checked against the disclosed identity preimages; the addressed ciphertexts (eNote/eRec/eIss) decrypt under the derivable \( m_{rec} \), closing the recipient-key coupling at the receipt while the on-chain Identity-M spend gate (deposit-coupling + bound membership + note\(\leftrightarrow\)=eEnc= tie) closes it at spend – a spendable note is always nameable, so no \( \gamma \) disclosure remains andUNVERIFIED ISSUERsurvives only for a receipt missing its binding. Receipts are generated by both Note parties (role): recipient-side and issuer-side ("I paid X") for B1/A1/A2, the B1 issuer naming its depositor from theSpentCoupledB1event'seDepForIss. Anchors moved to the unified 4242 nullifier and theSpentCoupled*events. - Later. The ESC/POS byte driver (Phase A shipped the plain-text driver;
a port-9100 thermal driver is a new
Driversubclass); optional QR rendering; payer-side EOA receipts; on-chainRcptVerifyif a contract ever needs to gate on a receipt.
Open Questions
- Encoding for OCR. base64url is compact but OCR-hostile (l/1/I, O/0); RFC 4648 base32 is ~20% larger but robust to hand entry and OCR. Default base64url, or base32 for paper? (The crypto payload is high-entropy, so compression buys little.)
- Unicode names. Thermal output is ASCII; non-Latin
given_name/family_nameneed transliteration for the printed face, while the payload keeps the exact UTF-8canonical_identity_data(the hash preimage). How to transliterate without implying the printed name is the canonical one? - Point-only fallback. When the wallet lacks a private counterparty's
identity_datapreimage, should it print the \( M \) fingerprint + issuer reference (de-anonymizable by the issuer under subpoena), or refuse? - Payload length. Offline-complete + text-only makes Notes / private-EOA
payloads ~2-2.7 KB of base64 (~30-50 Font-B lines). Acceptable for thermal,
or offer a
--refs-onlycompact mode that requires--rpcto verify? - Time and locale. Print block timestamp (consensus truth) or wall clock, and in UTC or the wallet's locale? The core stores unix block time; the printed string is a render choice.