
This document presents a concrete Ethereum implementation of the Alberta Buck Architecture, specifying the five core smart contracts:
- BUCK_CREDIT (ERC-721): An NFT representing an insurer's offer of parametric insurance on a real-world asset, with deterministic depreciation curves and piecemeal client activation.
- IdentityRegistry (custom): An on-chain registry of per-address Pointcheval-Sanders signature credentials and ElGamal-encrypted identity points, verified via a BN254-precompile NIZK at registration time. This is the trust anchor every BUCK transfer is gated on – see the companion Identity and Proofs documents for the full cryptographic design.
- BUCK (ERC-20): A fungible token minted against aggregated BUCK_CREDIT values, with on-chain
credit-limit enforcement, default insurance premiums, and identity-aware
approveandtransferflows that enforce the BUCK mutual-decryptability invariant on every counterparty pair via Chaum-Pedersen NIZK re-encryption proofs. - Notes (custom): A privacy-preserving denomination layer on top of BUCK. Clients escrow BUCK
into a commitment pool by posting a Groth16 SNARK that ties the deposited
totalFaceto a batch of Poseidon-hash note commitments; later, any holder can redeem a single note by presenting a second Groth16 SNARK proving membership in the on-chain Tornado-style incremental Poseidon Merkle tree under a fresh nullifier. Redemption pays out viaBUCK.transferCarrying, which weighted-merges the recipient's demurrage age with the pool's so the SNARK'sface === vvalue-conservation invariant holds at the ERC-20 boundary. See Notes and Proofs for the circuit design. - BUCK_K (custom): A controller that supplies the dynamic Value Stabilization Factor used in
credit-limit computation. The first deployment uses
BuckKControllerStatic, a governance-settable constant, so the identity and notes layers can land first; the eventual on-chain PID + commodity-basket oracle implementation (BuckKController) is fully specified below and slots in at the same interface.
This is the first of several planned implementation approaches. The ERC-721 approach is chosen for its clarity, wide tooling support, and natural fit for unique insured assets. Subsequent documents will examine ERC-1155 (semi-fungible batch credits), ERC-3643 (regulated security token with identity compliance), and ERC-4626 (vault-based credit pooling). The Implementation Plan at the end of this document specifies the concrete phased build order and tracks completion status.
Contract Architecture Overview
The Alberta Buck Ethereum implementation comprises five interacting contracts. The IdentityRegistry
is the trust anchor of every BUCK transfer: mint, approve and transfer are all gated on the
sender (and, where relevant, the recipient) holding a verified, registered identity credential.
The Notes contract sits on top of BUCK as a Public-Identity counterparty
(bound via IdentityRegistry.bindContract(notes, pk, E, true)) whose
commitments pool is extended only when the caller supplies a Groth16 SNARK
binding the deposited totalFace to a batch of Poseidon note commitments.
Every account and contract that operates on BUCKs is required to carry an
Identity binding – EOAs via register, contracts via bindContract – so
that bilateral identity-checked transfer / transferFrom calls have a
verifiable counterparty on both sides.

The flow is:
- An insurer creates a
BUCK_CREDITNFT representing their offer to insure a client's asset. - The client (asset owner) activates all or part of their credit, paying the current premium.
- Before the client can interact with
BUCKat all, theyregistera Pointcheval-Sanders identity credential and ElGamal-encrypted identity point withIdentityRegistry. The contract verifies the issuer's PS signature and the binding NIZK proof on-chain via the BN254 pairing precompile. - When the client calls
BUCK.mint(), the contract requiresIdentityRegistry.isVerified(sender), then aggregates the client's active credits, applies depreciation, multiplies by the currentBUCK_K, and mints BUCKs up to the resulting limit. - To send BUCKs, the sender first calls
BUCK.approve(spender, amount, E_bob, pi_CP), which routes the recipient's re-encrypted identity ciphertext and Chaum-Pedersen proof through theIdentityRegistry; the proof bindsE_bobto the sender's registered credential without ever revealing the underlying identity point \( M \). transfer/transferFromthen perform a bilateral identity check: both parties must be registered (isVerified). Per-pair receipt fragments stored atapprovetime are the primary identity material in the receipt; if no fragment exists and at least one party is bound under a Public Identity (isPublicIdentity), the bound_identityHashfalls in as the fragment for that side. EOA<->EOA Encrypted-Identity transfers MUST have a prior CP receipt. The contract emits aBuckTransferReceiptevent carrying only ciphertext hashes.- To denominate holdings as private notes, the client calls
Notes.mint(proofBytes, cm[], totalFace). TheMintVerifierAdapterdecodes the proof as(uint256[2], uint256[2][2], uint256[2])and forwards to the snarkjs-generatedMintGroth16Verifier, which checks a Groth16 proof with public signals[totalFace, cm[0], cm[1]]. The circuit enforcestotalFace =∑ v_i= andcm_i =Poseidon(flavor_i, v_i, rho_i, idHash_i, predicate_i)= per note. TheNotescontract was bound under a Public Identity at deployment viaIdentityRegistry.bindContract(notes, pk, E, true)(or viaBuckAwareDeployer.deployAndBindfor atomic deploy+bind), so itstransferFrompull from the minter satisfies the identity-awareBUCKtransfer rules: both parties areisVerified, and the Notes-side receipt fragment falls back to the deterministic_identityHashbecause the pool's operator has publicly disclosedmoff-chain. BUCK_Kships in two stages. Phase 0 usesBuckKControllerStaticwith a governance-settable value, so the identity and notes layers can land first. The on-chain PID + commodity-oracle implementation (BuckKController, described in the BUCK_K section) is the eventual replacement.
ERC Standard Analysis for BUCK_CREDIT
Before diving into the ERC-721 implementation, we compare candidate standards against the BUCK_CREDIT requirements:
| Requirement | ERC-721 | ERC-1155 | ERC-3643 (T-REX) | ERC-4626 (Vault) |
|---|---|---|---|---|
| Unique per-asset credit | Native | Possible (NFT mode) | Native | Poor fit |
| Insurer-mutable parameters | Custom role | Custom role | Built-in agent roles | Not designed for |
| Deterministic on-chain valuation | Custom view | Custom view | Custom view | share price model |
| Piecemeal client activation | Custom state | Fungible sub-amounts | Custom state | deposit() / shares |
| Transfer restrictions | Custom | Custom | Built-in compliance | Open transfers |
| Enumerable (for mint aggregation) | ERC721Enumerable | balanceOf per ID | Inherits ERC-20 | Single vault |
| Regulatory identity / KYC | Not built-in | Not built-in | ONCHAINID native | Not built-in |
| Tooling / ecosystem maturity | Excellent | Very good | Moderate (niche) | Excellent |
Recommendation
ERC-721 is the strongest first choice:
- Each
BUCK_CREDITis genuinely unique (specific asset, depreciation curve, insured value, premium schedule). This is precisely what ERC-721 models. - The
ERC721Enumerableextension providestokenOfOwnerByIndex(), required forBUCK.mint()to aggregate all credits in an account. - Custom extensions for dual-role access (owner activates, insurer updates) and deterministic valuation are straightforward.
- Widest tooling support, most audited implementations, lowest learning curve.
ERC-3643 is the strongest future candidate, especially if regulatory compliance (KYC, transfer restrictions, identity registry) becomes a deployment requirement. Its agent-role system maps naturally to the insurer/client relationship. However, its dependency chain (ONCHAINID, compliance modules, claim topics) adds significant complexity that is better deferred.
ERC-1155 is attractive if standardized credit "classes" emerge (e.g., all Calgary residential properties with the same depreciation model), allowing semi-fungible batching. For the initial system where each credit is individually appraised, the fungibility benefit doesn't apply.
ERC-4626 is a poor fit for individual credits but may be useful for the InsurancePool (where
mutual insurance shares are fungible vault interests).
BUCK_CREDIT: ERC-721 Insured Asset NFT
Data Model
Each BUCK_CREDIT token is an ERC721Enumerable whose per-token CreditParams struct
(src/BuckCredit.sol) carries all the insurer's current offer and the client's activation state.
All monetary values are 18-decimal BUCK-equivalent units. Fields group naturally into three
zones of mutability:
- Immutable at mint:
insurer(the vendor address that may update this token),assetClass,createdAt. - Insurer-mutable via
updateCredit:faceValue,depreciationFloor,depType,depRate,depStartAt,premiumRate,lastUpdated. - Client-mutable via
activate:activatedValue(<=faceValue),lastActivatedAt.
DepreciationType is one of NONE (land, gold), LINEAR (constant annual reduction), or
DECLINING_BALANCE (percentage of remaining value). Three events – CreditCreated,
CreditUpdated, CreditActivated – record every state change. The per-token insurer field
lets a single deployment host many insurers; assetClass cannot be reclassified after mint.
Depreciation Models
Depreciation is computed deterministically from the NFT's on-chain parameters in
currentValue(tokenId) – a pure view with no oracle input, gas-free to query. The
model branches on depType:
NONE:currentValueis the activated face value, unchanged.LINEAR: loss accrues atdepRatebasis points per year on the depreciable portion (faceValue - floor), clamped at thedepreciationFloor.DECLINING_BALANCE: continuous-compounded version using an in-contract_expNeg(6th-order Taylor series, accurate to <0.01% for x < 3.0 – i.e. up to ~300% cumulative depreciation). A production deployment should swap inPRBMath.exp()orABDKMath64x64for wider-range precision.
In every case the activated portion depreciates proportionally: currentValue =
depreciatedFace * activatedValue / faceValue.
Depreciation Examples
| Asset | Type | Rate | Floor | 30yr value (of $100K) |
|---|---|---|---|---|
| Land | NONE | – | – | $100,000 |
| House (bldg) | LINEAR | 200bp | 20% | $52,000 |
| Vehicle | DECLINING_BALANCE | 1500bp | 5% | $5,633 |
| Farm equip | DECLINING_BALANCE | 1000bp | 10% | $16,791 |
| Gold in vault | NONE | – | – | $100,000 |
A residential property would typically have two BUCK_CREDITs: one for the land (NONE, full face value indefinitely) and one for the structure (LINEAR or DECLINING_BALANCE, with a floor representing salvage/lot-clearing value).
Activation (Execution) Model
The client executes (activates) their credit piecemeal through activate(tokenId, amount) –
gated on ownerOf(tokenId) == msg.sender and capped at the token's faceValue. Activation
increases the activatedValue and stamps lastActivatedAt; the premium for the newly
activated amount is collected by BUCK.mint(), not here.
totalCurrentValue(account) walks ERC721Enumerable.tokenOfOwnerByIndex and sums
currentValue(tokenId) across all tokens owned by account – this is the single call
BUCK.mint() makes to compute the credit limit.
The activation model separates the decision to use credit from the act of minting BUCKs. A client can activate $200K of their $500K home credit today, and months later activate $100K more – possibly at a different premium rate if the insurer has updated the NFT in the interim.
Insurer Updates
The insurer can reappraise the asset, change depreciation parameters, and adjust premium schedules. These changes affect future credit limit computations, including already-activated amounts (since the depreciation function is computed from current parameters, not historical ones).
updateCredit(tokenId, newFaceValue, newDepreciationFloor, newDepType, newDepRate,
newDepStartAt, newPremiumRate) is gated on msg.sender == credits[tokenId].insurer. If
newFaceValue < activatedValue the contract caps activatedValue downward in the same call;
assetClass and insurer are never changed.
Note that if the insurer reduces faceValue below the current activatedValue, the activated
amount is capped downward. This could push the client's outstanding BUCK balance above their credit
limit, triggering the default insurance mechanism described below.
IdentityRegistry: On-Chain Trust Anchor
The IdentityRegistry is the binding between Ethereum addresses and the privacy-preserving identity
machinery described in the companion documents Identity with Anonymity,
Identity Example – Data Flow, and Cryptographic Proofs.
Every BUCK mint, approve and transfer consults this registry; an unregistered address cannot
hold or move BUCKs.
The registry stores four pieces of state per address:
| Field | Type | Source |
|---|---|---|
pk[a] |
G_1 point |
Identity public key generated by the wallet (EOA) or by the contract operator |
E_addr[a] |
(R, C) in G_1^2 |
ElGamal encryption of \( M = m \cdot G \) under pk (or (g_1, g_1) for contracts disclosing m) |
isVerified |
bool |
True after PS + NIZK proofs verify on-chain (EOA) or after bindContract (contract) |
isPublicIdentity |
bool |
True only for contracts bound with the public-disclosure flag via bindContract |
It also holds a small set of trusted issuer public keys trustedIssuers[issuerAddr] = (X, Y) in
\( G_2^2 \), populated by governance. These are the only PS public keys whose signatures the
registry will accept at register() time.
EOAs always self-register via register; the isPublicIdentity flag stays
false for them (an EOA always carries an Encrypted Identity). Contracts
bind via bindContract, which has no PS / NIZK requirement – trust derives
either from atomic deploy+bind via BuckAwareDeployer, or from
first-binder-wins for already-deployed contracts. The flag captures
whether the contract operator has chosen to publicly disclose m off-chain
(typical for AMM pools and other BUCK-unaware contracts whose operator
wants the on-chain audit trail to be openable on subpoena).
Cryptographic Surface
The registry implements three on-chain verifications, all reducible to BN254 precompile calls:
- PS signature verification at registration:
e(sigma'_1, X + m * Y) = e(sigma'_2, g_2), where the verifier reconstructs the relation linearly inmvia the binding NIZK in step 2. Costs ~147K gas (3-4ecPairingpairs). - NIZK proof binding the rerandomized PS signature to the ElGamal ciphertext. A Schnorr-family
sigma protocol with three response scalars and a Fiat-Shamir challenge bound to
(sigma', E_new, pk_new, registrant_address). Costs ~30K gas ofecMul=/=ecAddon top of the pairing check. Total registration cost is ~235K gas (one-time). - Chaum-Pedersen proof of re-encryption correctness at
approvetime. The recipient identity ciphertextE_boband proofpi_CP = (e, s_1, s_2)arrive inapprove()calldata; the registry readsE_alicefrom storage (anchoring the proof to the registered credential) and verifies via 3-4ecMul+ 3-4ecAddoperations and a Fiat-Shamir reconstruction. Costs ~29K gas per counterparty pair.
Solidity Surface
src/IdentityRegistry.sol uses the BN254 helper library for all curve math. The shape of the
interface is what matters for the rest of this document:
struct G1Point { uint256 X; uint256 Y; }
struct G2Point { uint256[2] X; uint256[2] Y; }
struct ElGamalCT { G1Point R; G1Point C; } // (R, C) = (r*G, r*pk + M)
struct PSSig { G1Point s1; G1Point s2; } // rerandomized PS signature
struct PSPubKey { G2Point X; G2Point Y; } // issuer (X, Y) in G_2
struct RegistrationProof { uint256 e, s_m, s_r; G1Point A_ps, T_C, T_R; }
struct CPProof { uint256 e, s1, s2; G1Point T1, T2, T3; }
function register(address issuer, G1Point pk, ElGamalCT E, PSSig sigma, RegistrationProof proof);
// Bind a (pk, E_addr) Identity to a deployed contract address (target.code.length > 0).
// First binder wins for pre-existing contracts; for atomic deploy+bind use
// BuckAwareDeployer.deployAndBind. isPublicIdentity_=true means the operator
// has chosen to publicly disclose m off-chain.
function bindContract(address target, G1Point pk, ElGamalCT E, bool isPublicIdentity_);
function trustIssuer(address issuer, PSPubKey ipk); // governance
function verifyApprove(address sender, address spender,
ElGamalCT E_bob, CPProof pi) view returns (bool);Design notes on the surface:
- Per-address state is
pk,E_addr((R,C) ciphertext of the identity pointM),isVerified,isPublicIdentity, andissuerOf– all public-readable mappings. registerruns three checks in order: (1) recompute the Fiat-Shamir challenge withmsg.senderbinding, (2) verify the Schnorr-family NIZK inG_1, (3) verify the PS pairing relation inG_2. Analready registered/untrusted issuerguard precedes them. Total cost ~235K gas (one-time per address).bindContractis the only post-registration mutator and is permissioned by the deployment pattern, not by governance: any caller may bind any yet-unbound deployed contract. The intended discipline is operator OPSEC – either the operator is the contract's deployer and usesBuckAwareDeployer.deployAndBindfor atomic deploy+bind (no front-running window), or the operator races to bind a pre-existing contract before any other party. Once bound, the binding is immutable.verifyApprovereadsE_alicefrom storage so the caller cannot substitute a fake credential; theCPProofstructure carries T1/T2/T3 because the transcript binds T1 and T2 separately (only T3 is recoverable from the verification equations).- The
RegistrationProofcarries the PS-side commitmentA_ps = m_tilde * sigma'_1alongside the two ElGamal-side commitmentsT_C,T_R–A_psis indispensable for the pairing-product check and cannot be reconstructed from the other responses. - Governance hooks round out the surface:
revokeIssuer,transferGovernance. Revocation does not un-register existing accounts; they were verified once at registration and are never re-verified.
Public-Identity Contracts
DeFi infrastructure – AMM pools, vaults, oracles, the Notes pool – cannot
generate Chaum-Pedersen proofs at approve time because the contract has no
private key. Such contracts are bound with bindContract(target, pk, E,
isPublicIdentity_=true) – the binding has the same (pk, E_addr) shape
as a self-registered EOA, plus the isPublicIdentity flag that records the
operator's commitment to publicly disclose m off-chain. Counterparties
of a Public-Identity contract still call the identity-bound approve, but
the bilateral receipt-fragment check on the contract side is satisfied by
the deterministic _identityHash rather than a per-counterparty CP receipt
(see the next section). Public-Identity contracts forfeit content
privacy: their identity \( M \) is openable on subpoena via the operator's
off-chain attestation, but they retain unforgeability and the audit trail.
A second, deferred mode binds a contract under an Encrypted Identity
(isPublicIdentity_=false). This is intended for BUCK-aware contracts
where the operator runs an off-chain pre-approval flow per counterparty
(decrypting each user's E_user to attest that the contract is a willing
counterparty), and ships the resulting CP material on-chain so per-user
approve calls can succeed without divulging the contract's m. The
bindContract surface supports it; no production contract uses it yet.
Issuer Trust
trustedIssuers is the only governance-controlled trust anchor in the system. Adding a new issuer
admits a new origin of registrable identities; removing one stops new registrations under that
issuer (existing registrations are unaffected – they were verified at registration time and are
never re-verified). Epoch-based credential renewal, key revocation, and issuer rotation are
described in the Identity: Epoch-Based Credential Renewal document.
BUCK: ERC-20 Token
The BUCK token is an identity-aware ERC-20. Beyond the usual mint / transfer / approve surface,
every state-changing call routes through the IdentityRegistry:
mint()refuses to issue BUCKs to an unverified address.approve()takes the recipient's re-encrypted identity ciphertext and a Chaum-Pedersen NIZK proof, and stores a receipt fragment binding the (sender, spender) pair to that ciphertext.transfer()andtransferFrom()enforce a bilateral identity check using the receipt fragments; if a fragment is missing and at least one party is bound under a Public Identity (isPublicIdentity), the bound_identityHashfalls in for that side. The contract then emits aBuckTransferReceiptcarrying only ciphertext hashes – no plaintext identity ever touches the chain.
This is the on-chain enforcement of the mutual-decryptability invariant: every transfer leaves behind a permanent record from which both counterparties (and only those counterparties, by decrypting their respective ciphertext) can reconstruct the identity of the other.
mint() Implementation
src/Buck.sol is constructed with (buckCredit, buckK, identity, insurancePool). mint(amount)
performs the seven-step sequence:
- Require
identity.isVerified(msg.sender). - Read
creditValue = buckCredit.totalCurrentValue(msg.sender). - Read
k = buckK.currentBuckK()(Phase-0 static getter or eventual PIDcompute()). maxLimit = creditValue * k / 1e18; the stored per-accountstoredLimitratchets upward only.- Refuse if
balanceOf(sender) + amount > storedLimit. - Compute the default-insurance premium (see below).
_mint(sender, amount - premium),_mint(insurancePool, premium), emitMinted(account, amount, premium, creditValue, buckKValue, newLimit).
A symmetric burn(amount) just calls _burn(msg.sender, amount) for repayment.
Identity-Aware approve()
The shipped signature is
approve(address spender, uint256 amount, IdentityRegistry.ElGamalCT E_bob, IdentityRegistry.CPProof pi_CP).
The parameterless ERC-20 approve(address, uint256) reverts with BUCK: use
identity-bound approve – the identity-bound form is mandatory. Both
sender and spender must be isVerified (i.e. registered EOAs or
contracts bound via bindContract). Critically, the CP proof is always
required regardless of the spender's Identity flavor: even when the spender
is a Public-Identity contract, the ERC-20 allowance write must be
accompanied by a CP proof. On success the receipt fragment
keccak256(abi.encode(E_bob.R.X, E_bob.R.Y, E_bob.C.X, E_bob.C.Y)) is
stored at _receiptFragments[sender][spender] and the ERC-20 allowance is
written via _approve(sender, spender, amount).
The full rationale for the always-CP rule – consent vs. execution, BN254-key engagement, per-pair freshness, uniform audit semantics, and defense against future identity rotation – is laid out in Why approve Is Always CP-Bound in the BUCK Identity document.
For Public-Identity spenders the operator constructs E_bob and pi_CP by
re-encrypting against the spender's published m; for Encrypted-Identity
spenders the operator must obtain E_spender and pre-approval material
from the spender's wallet (the standard EOA-to-EOA flow).
The approve call costs roughly 29K (CP verify) + 46K (standard ERC-20 approve) + 22K (receipt
SSTORE) = ~97K gas, paid once per (sender, spender) pair. Subsequent transfers between the same
counterparties incur only the bilateral check and the receipt event (~5K gas of additional work
above a baseline ERC-20 transfer).
Identity-Aware transfer() and transferFrom()
Both transfer(to, amount) and transferFrom(from, to, amount) route through
_identityCheckedTransfer, which enforces:
isVerified(from)andisVerified(to)– both parties must carry an Identity binding (EOA viaregister, contract viabindContract).- The
toside: try_receiptFragments[from][to]; if zero, fall back to_identityHash(to)only ifisPublicIdentity(from) || isPublicIdentity(to)– otherwise revert withBUCK: missing identity receipt. EOA-to-EOA Encrypted transfers MUST have a prior approve-time CP receipt; transfers where at least one party is a Public-Identity contract are allowed to use the deterministic fallback. - The
fromside is best-effort: try the mirrored fragment_receiptFragments[to][from]for a reciprocal approve, otherwise fall back unconditionally to_identityHash(from). The receipt still records the asymmetric attestation – auditors reading the event know which side was CP-bound vs. registry-derived.
After the underlying _transfer settles, BuckTransferReceipt(from, to, amount, fromHash,
toHash) is emitted. _identityHash(account) is keccak256(abi.encode(pk.X, pk.Y, R.X, R.Y, C.X,
C.Y)) over the account's stored public key and ElGamal ciphertext –
identical for the same account across every transfer it participates in.
The BuckTransferReceipt event is the auditable trail: fromCipherHash and toCipherHash are
keccak256 commitments over the corresponding ElGamal ciphertexts. Counterparties can match a
receipt to their off-chain copies of \( E_{alice}, E_{bob} \) by recomputing the hash; observers
without the underlying ciphertexts learn nothing beyond the fact of a transfer.
Demurrage and transferCarrying()
BUCK carries a continuous on-chain demurrage accrual to drive a perpetual Jubilee schedule. All
balance accounting is in raw units; the displayed balanceOf(a) subtracts a per-account
feeOwing(a) = raw_balance(a) * (cumIndexNow - _indexAtLastTouch[a]) / SCALE. cumIndex grows
linearly at BASE_RATE_PER_SEC * (now - genesis). Two transfer flavours coexist:
- Deducting (default ERC-20 path): the sender's accumulated fee is burned to the Jubilee
recipient's index slot; sender's
_indexAtLastTouchresets tocumIndex; the recipient's index is weighted-merged towardcumIndexso its prior fee debt is preserved. - Carrying (
transferCarrying(to, amount)): no fee burn.amountraw units move atomically with the sender's accrued demurrage age – the recipient's index becomes the amount-weighted average of its old index and the sender's_indexAtLastTouch:_indexAtLastTouch[to] = (br * _indexAtLastTouch[to] + amount * idxFrom) / (br + amount). The sender's index is unchanged; sender keeps the residual age on its remaining balance.
transferCarrying is the entry point Notes.spend and Notes.spendACP call when paying out a redeemed face value:
it is the reason a recipient absorbs the pool's average demurrage age (the anonymity cost –
and upside, for anyone who held a note longer than the pool average) rather than a discontinuous
spike of settled fee. The Jubilee accumulates fee burns into a single sink whose own
_indexAtLastTouch is itself weighted-merged so disbursements remain age-correct.
Default Insurance Premium
The premium scales with utilization: the closer the account's outstanding BUCKs approach the credit limit, the more expensive each additional BUCK becomes. This mirrors the architecture document's description of Parametric Default Insurance.
The shipped curve is quadratic in utilization: premium_rate = BASE_RATE + utilization^2 *
SCALE_RATE, with BASE_RATE = 50bp (0.50%) and SCALE_RATE = 450bp (4.50% additional at 100%
utilization). Concretely: ~0.5% at 0% utilization, ~1.75% at 50%, ~4.55% at 90%; 100% is
unreachable because the limit check itself rejects. The _computePremium(account, mintAmount,
limit) helper computes utilization on the post-mint balance and returns the BUCK amount due
to the insurance pool.
The quadratic scaling creates a natural disincentive against over-leveraging. A client using 50% of
their credit pays roughly 1.75% of each minted BUCK to the insurance pool; at 90% they pay 4.55%.
The insurance pool accumulates these premiums to cover defaults (accounts where asset depreciation or
BUCK_K changes push outstanding BUCKs above the credit limit).
BUCK_K: Value Stabilization Controller
The BUCK_K contract maintains purchasing-power parity between the BUCK and its commodity basket.
It ships in two stages behind a shared one-function interface (currentBuckK() external view returns
(uint256)) so the Buck contract is invariant to which stage is deployed:
- Phase 0 –
BuckKControllerStatic: a governance-settable constant. Constructor(uint256 _buckK, address _governance); functionscurrentBuckK(),compute()(no-op, returns the stored value),setBuckK(uint256)(governance-only),transferGovernance(address). This is the version currently deployed alongside the identity and notes layers so those layers can land first. - Phase Later –
BuckKController: a full PID implementation, identical in structure to the project's ownercredit PID controller, ported to Solidity fixed-point arithmetic. It reads a commodity basket from Chainlink feeds and the BUCK/USDC price from a Uniswap V3 TWAP, runs a P/I/D update oncompute()once perdTseconds, and writes the newbuckK.
The PID implementation is documented in full below; the Buck ERC-20 reads only currentBuckK()
from either variant, so swapping one for the other is a constructor argument.
Process Variable and Setpoint
| PID Term | Source | Meaning |
|---|---|---|
| Process Variable | BUCK/USDC Uniswap V3 TWAP | What a BUCK actually trades for |
| Setpoint | Commodity basket oracle sum | What a BUCK should be worth (basket cost) |
| Error | Setpoint - Process | Positive = BUCK undervalued, negative = overvalued |
| Output (BUCK_K) | PID computation | Credit limit multiplier (1.0 = neutral) |
When BUCK trades below basket value (positive error), BUCK_K increases, expanding everyone's credit limit. This floods the market with cheap BUCKs, which arbitrageurs buy to acquire underpriced commodities, driving the BUCK price back up. The converse reduces BUCK_K when BUCKs are overvalued.
Solidity Implementation
src/BuckKController.sol is a straight Solidity port of the JavaScript PID controller in
ownercredit, with 18-decimal fixed-point arithmetic and BN254-friendly types. The shipped
shape is:
- Configuration:
int256 Kp, Ki, Kd(gains),uint256 dT(minimum update interval),uint256 buckKMin, buckKMax(output clamps),buckUsdcPool,twapInterval, governance. - State:
int256 P, I, D,uint256 lastUpdate,uint256 buckK(output,1e18= neutral).basket[]is an array ofBasketComponent { AggregatorV3Interface feed; uint256 weight; uint8 feedDecimals; }. -
Entry point:
compute() returns (uint256). Ifnow - lastUpdate < dTit returns the cachedbuckKwithout state change. Otherwise it readsbasketCost(Chainlink-weighted setpoint),buckPrice(Uniswap V3 TWAP, process variable), runs one PID cycle ~rawOutput = 1e18 + newP*Kp- newI*Ki + newD*Kd~ (each term divided by
1e18), clamps to[buckKMin, buckKMax]with
anti-windup on the integral term, writes the new state, and emits
BuckKUpdated. - newI*Ki + newD*Kd~ (each term divided by
- View:
currentBuckK() view returns (uint256)– the Buck contract calls this for non-mutating reads. - Governance:
setGains,setDT,addBasketComponent.
The TWAP price helper _getBuckPrice() uses Uniswap's OracleLibrary.consult(pool, twapInterval)
to convert a tickCumulatives delta into an 18-decimal price; omitted here because it is
mechanical Uniswap V3 boilerplate. The basket helper sums price * weight with per-feed decimal
normalisation.
PID Tuning Considerations
The PID gains determine how aggressively BUCK_K responds to price deviations:
| Gain | Effect of Increasing | Risk of Over-tuning |
|---|---|---|
| Kp | Faster response to price deviations | Oscillation, over-correction |
| Ki | Eliminates steady-state error (persistent mispricing) | Integral windup, slow recovery |
| Kd | Dampens oscillation, anticipates trends | Noise amplification |
Initial gains should be conservative (high Kd relative to Kp, low Ki) to favor stability over
responsiveness. The dT parameter (minimum update interval) acts as an additional dampening
mechanism – a 1-hour dT means BUCK_K changes at most once per hour regardless of how many mints
occur, preventing high-frequency oscillation.
The TWAP interval (twapInterval) provides manipulation resistance: a 30-minute TWAP means an
attacker would need to sustain a price manipulation for 30 minutes to meaningfully affect BUCK_K – a
prohibitively expensive proposition on a liquid pool.
Adaptive Gain Scheduling (Future Enhancement)
The current implementation uses governance-set fixed gains. A future enhancement could implement on-chain gain scheduling where Kp, Ki, and Kd are adjusted based on observed error dynamics:
- Low volatility regime (error variance below threshold): reduce Kp, increase Ki for tighter long-term tracking.
- High volatility regime (error variance above threshold): increase Kp and Kd for faster response, reduce Ki to prevent windup.
- Regime detection: maintain a rolling variance of the error signal over a window of N updates.
This maps directly to the PIDController.reset() pattern in the JavaScript implementation, where
gains can be hot-swapped without spiking the derivative term.
Notes: Privacy-Preserving Denomination
BUCK transfers carry a public (from, to, amount) quartet. For private value movement, holders
denominate their BUCKs as notes: a pair of Poseidon-hash commitments posted to the Notes
contract, backed by an equal amount of BUCK locked in that contract. Notes do not leak values,
flavours, or identity bindings – the public state is just a list of opaque 32-byte commitments and
a running face-value total.
The design is specified in full in Notes and the underlying SNARK construction in
Proofs. This section documents the Ethereum surface: the Notes contract, the IMintVerifier
interface, the circom Groth16 mint circuit, and the adapter that bridges the two.
Mint Circuit (circom, Groth16 on BN254)
Phase-7-bis ships circuits/mint_batch.circom, a parameterised batch-mint circuit
that subsumes the per-leaf on-chain Poseidon insertion of Phase 7. One compile per
pinned N (recommended {16, 128, 1024}); each pin produces its own
MintGroth16Verifier_N*.sol and registers in mintVerifiers[N] on Notes. The
public-signals arity is 5 regardless of N.
| Signal | Role | Description |
|---|---|---|
oldRoot |
public | Live note Merkle root the prover read from chain |
newRoot |
public | Root after folding cm[0..N-1] starting at nextLeafIndex |
nextLeafIndex |
public | Live tree size the prover read from chain |
totalFace |
public | Sum of note values, range-bounded to 128 bits |
cmBatchHash |
public | keccak256(abi.encodePacked(cm[0..N-1])) – ties calldata to the SNARK |
flavor[i] |
private | Note flavour (A1/A2/B1 from Notes design) |
v[i] |
private | Note value (18-decimal integer, range-bounded to 128 bits) |
rho[i] |
private | Per-note randomness |
idHash[i] |
private | Hash of the identity binding for this note |
predicate[i] |
private | Predicate commitment (zero for the plain mint path) |
pathSiblings[i][d] |
private | Depth-20 sibling hashes used to fold leaf i into the rolling tree |
Constraints: N Poseidon-5 opening checks, Num2Bits(128) range bounds on each v[i]
and on totalFace (closing the field-overflow attack), one sum check
(totalFace === sum v[i]), N x TREE_DEPTH Poseidon-2 hashes implementing the
rolling filled-subtrees insertion from oldRoot to newRoot, and one keccak-style
hash check binding cmBatchHash to the per-leaf commitments. Total constraints
work out to ~6K R1CS per leaf (~1.7K opening + ~4.3K insertion); see
alberta-buck-notes-rollup-mint.org for per-N R1CS / PTAU sizing.
scripts/snark/setup.sh now runs one phase-2 setup per pinned N, exporting
src/MintGroth16Verifier_N*.sol for each. src/SpendGroth16Verifier.sol is
unchanged from Phase 7.
interface IMintGroth16 {
function verifyProof(
uint256[2] calldata a,
uint256[2][2] calldata b,
uint256[2] calldata c,
uint256[3] calldata pubSignals // [totalFace, cm[0], cm[1]]
) external view returns (bool);
}
The trusted-setup contributions in setup.sh use a fixed dev-only entropy string. A production
deployment must run a real multi-party ceremony; the same script is the scaffolding.
Spend Circuit (circom, Groth16 on BN254)
circuits/spend.circom proves the redemption of a single B-shape note under an on-chain Merkle
root. Public inputs are [noteRoot, nullifier, face, recipient, chainId]; private witness is
(flavor, v, rho, idHash, predicate, pathElements[20], pathIndices[20]). The circuit enforces
five constraint groups:
- (R)
Num2Bits(128)onfaceand onv. - (C)
cm === Poseidon(5)(flavor, v, rho, idHash, predicate). - (M) Tornado-style Merkle inclusion of
cmundernoteRootat path(pathElements, pathIndices): a chain ofPoseidon(2)hashes withSwitcherselectors ordering the left/right inputs at each level. - (N) B-shape nullifier
nullifier === Poseidon(3)(rho, idHash, 4242)– the constant tag 4242 domain-separates the B-path; the shippedspend_a.circomV2 uses tag 4243 to keep A and B nullifiers structurally disjoint. - (B) Ghost bind
face * 1 === v * 1and a quadratic anchorx*xguarantees the prover committed to the value actually being redeemed (not an arbitrary smaller face).
snarkjs compiles the circuit to roughly 12,000 R1CS constraints; the 2^15 pot is shared with
mint. The on-chain SpendGroth16Verifier accepts a 5-element pubSignals tuple; the
SpendVerifierAdapter presents the narrower ISpendVerifier.verifySpend(proof, root, nullifier,
face, recipient, chainId) to the Notes contract.
Verifier Interfaces and Adapters
Notes is decoupled from the specific SNARK toolchain via two narrow interfaces:
interface IMintVerifier {
function verifyMint(bytes proof, uint256 totalFace,
uint256[] commitments, address issuer) external view returns (bool);
}
interface ISpendVerifier {
function verifySpend(bytes proof, uint256 root, uint256 nullifier,
uint256 face, address recipient, uint256 chainId)
external view returns (bool);
}Two implementations of each interface ship:
- Stubs (
StubMintVerifier,StubSpendVerifier): governance-controlled booleans. Used in deployment bootstraps and unit tests that exercise state transitions unrelated to the SNARK itself. - Adapters (
MintVerifierAdapter,SpendVerifierAdapter): decodeproofasabi.encode(uint256[2], uint256[2][2], uint256[2]), build the public-signals tuple (5 entries for mint –[oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash]– and 5 entries for spend), and forward to the snarkjs-generatedMintGroth16Verifier_N*/SpendGroth16Verifier. The mint adapter is per-N:mintVerifiersis amapping(uint256 =>IMintVerifier)= keyed oncms.length, with one verifier registered per pinned batch size (recommended {16, 128, 1024}).
Swapping either verifier is a single governance call
(Notes.setMintVerifier(N, address) / Notes.setSpendVerifier(address)); the Notes
contract's own storage and event surface are invariant.
Notes Contract
src/Notes.sol is constructed with (buck, mintVerifierRegistry, spendVerifier,
governance). Its state is intentionally lean – the per-leaf Poseidon insertion math
that Phase 7 had on chain now lives inside the mint SNARK, and the contract just
records the SNARK-attested newRoot:
- Verifier handles: per-N
mintVerifiers[N](governance-swappable per pinned N),ISpendVerifier spendVerifier(governance-swappable singleton). No on-chain Poseidon precompile is required for mint – it remains relevant for legacy spend- side off-chain tree reconstruction tools, but the contract no longer calls it. - Audit and double-spend state:
mapping nullifiers,uint256 noteFaceSum. - Tree state:
uint32 nextLeafIndexanduint256[ROOT_HISTORY_SIZE=30] rootswithuint8 currentRootIndexselecting the live root.filledSubtrees,zeros[], andcommitmentExistsfrom Phase 7 are retired – the rolling tree state lives in the wallets' local mirrors and inside the SNARK; duplicate commitments are economically self-punishing via the deterministic nullifierPoseidon3(rho, idHash, 4242)(one spend nullifies every copy of the same opening; Alice loses any BUCK she paid to mint duplicates).
mint(proof, oldRoot, newRoot, nextLeafIndex, totalFace, cms) asserts cms.length > 0,
checks oldRoot == roots[currentRootIndex] and nextLeafIndex == self.nextLeafIndex
(stale-state guards), recomputes cmBatchHash = keccak256(abi.encodePacked(cms)),
dispatches to mintVerifiers[cms.length].verifyMint(proof, oldRoot, newRoot, nextLeafIndex, totalFace, cmBatchHash),
pulls totalFace BUCK via transferFrom(caller, this, totalFace), writes newRoot into the ring buffer at
(currentRootIndex + 1) % ROOT_HISTORY_SIZE, advances nextLeafIndex by cms.length,
bumps noteFaceSum by totalFace, and emits a single Minted(issuer, totalFace,
startIndex, count, newRoot) event. Per-leaf Appended events from Phase 7 are gone:
the commitments themselves live in the transaction calldata for off-chain tree
replayers and indexers to read.
spend(proof, root, nullifier, face, recipient) is unchanged from Phase 7: requires
recipient ! 0=, face > 0, that root is still in the recent-roots window
(isAcceptedRoot(root)), and that nullifier has not been seen. It forwards
(proof, root, nullifier, face, recipient, block.chainid) to
spendVerifier.verifySpend, marks the nullifier burned and decrements noteFaceSum
before calling IBuckCarrying(address(buck)).transferCarrying(recipient, face) – the
carrying path moves the pool's average demurrage age to the recipient atomically with
the face value. A Spent(nullifier, face, recipient) event closes the spend.
Phase 7-bis closes the same loop as Phase 7 but at N-independent on-chain cost:
credit-backed BUCKs in via mint (~380K + ~500 gas/leaf calldata) -> opaque
commitments fold into the rolling root via the SNARK -> spend redeems any committed
commitment against any recent root with a fresh nullifier, paying the recipient via
transferCarrying so the SNARK's face === v invariant is preserved at the ERC-20
boundary. The pivot is documented in
alberta-buck-notes-rollup-mint.org.
Integration: Full mint() and transfer() Walkthrough
Consider Alice, who owns a $500K Calgary home (land: $200K, structure: $300K). Two insurers (LandCo, HomeCo) have issued BUCK_CREDITs:
| Token | Insurer | Face Value | Depreciation | Activated | Current Value |
|---|---|---|---|---|---|
| #42 | LandCo | 200,000 | NONE | 200,000 | 200,000 |
| #43 | HomeCo | 300,000 | LINEAR, 200bp, 20% fl | 150,000 | 141,000 |
| Total | 341,000 |
The structure has depreciated 6% over 3 years (LINEAR at 2%/yr applied to the 80% depreciable portion). Alice activated all of the land credit but only half of the structure credit.
Step 0: Identity Registration (One-Time)
Before Alice can interact with BUCK at all, she must hold a verified identity credential:
- KYC ceremony (off-chain): a trusted issuer (the Alberta government, ATB Financial, etc.) verifies Alice's documents and signs her identity scalar \( m = H(identity\_data) \) with their PS key, producing \( \sigma = (\sigma_1, \sigma_2) \). Alice stores \( (m, \sigma) \) in her wallet.
- Derivation (off-chain): Alice's wallet rerandomizes \( \sigma \) into \( \sigma' \), generates a fresh identity key pair \( (sk_{alice}, pk_{alice}) \), encrypts her identity point \( M = m \cdot G \) under \( pk_{alice} \), and produces a NIZK proof \( \pi \) binding \( \sigma' \) to that ciphertext.
- On-chain registration: Alice calls
IdentityRegistry.register(issuer, pk_alice, E_alice, sigma', pi). The contract verifies \( \pi \) and the PS pairing relation via the BN254 precompiles (235K gas, paid once for the lifetime of this Ethereum address). After this call, ~isVerified[alice] == trueandissuerOf[alice] == issuer.
Alice repeats steps 2-3 for every Ethereum account she controls; the issuer never sees those accounts.
Step 1: Mint
Alice calls BUCK.mint(50000e18):
- The contract first checks
identity.isVerified(alice) == true. buckCredit.totalCurrentValue(alice)returns341,000e18.buckK.currentBuckK()returns the current stabilization factor. Phase 0: this is just the static value the governance multisig last wrote. Eventual PID build: the controller fetches oracles – e.g. commodity basket at 1.02 USDC, BUCK at 0.99 USDC, error +0.03 -> BUCK_K = 1.015.maxLimit = 341,000 * BUCK_K. Assume BUCK_K = 1.015, so maxLimit = 346,115.- Alice's current balance is 280,000 BUCKs. After mint: 330,000 < 346,115. OK.
-
Utilization after mint: 330,000 / 346,115 = 95.3%.
- Premium rate: 0.50% + (0.953^2 * 4.50%) = 4.59%.
- Premium: 50,000 * 4.59% = 2,295 BUCKs to insurance pool.
- Alice receives 47,705 BUCKs. Insurance pool receives 2,295 BUCKs.
Step 2: Approve Bob (Identity Handshake)
Alice wants to pay Bob 10,000 BUCKs through the same router pattern most ERC-20 flows use
(approve + transferFrom). Bob is also registered.
- Alice's wallet reads Bob's
pk_bobfromIdentityRegistry. - Off-chain, Alice's wallet decrypts
E_alice(it knowssk_alice), recovers \( M \), and re-encrypts under \( pk_{bob} \) with fresh randomness \( r_b \): \( E_{bob} = (r_b \cdot G,\ r_b \cdot pk_{bob} + M) \). - The wallet produces a Chaum-Pedersen NIZK proof \( \pi_{CP} = (e, s_1, s_2) \) that
E_bobencrypts the same \( M \) registered inE_alice. The Fiat-Shamir challenge bindsmsg.sender = alice,spender = bob, andblock.chainid. -
Alice calls
BUCK.approve(bob, 10000e18, E_bob, pi_CP). The contract:- checks both parties are verified;
- asks
IdentityRegistry.verifyApprove(alice, bob, E_bob, pi_CP)– the registry readsE_alicefrom its own storage (so Alice can't substitute a fake credential) and runs the CP check (~29K gas); - stores
_receiptFragments[alice][bob] = keccak256(E_bob); - records the standard ERC-20 allowance.
- Off-chain, Alice's Holochain hApp delivers the matching
identity_dataand \( E_{bob} \) ciphertext to Bob's hApp. Bob's wallet decrypts to recover \( M' \) and asserts \( H(identity\_data) \cdot G \stackrel{?}{=} M' \) – the two-channel binding from the Identity document.
Step 3: Bob Pulls the Transfer
Bob (or a contract Bob authorized) calls BUCK.transferFrom(alice, bob, 10000e18):
_spendAllowance(alice, bob, 10000e18)debits the allowance Alice set above._identityCheckedTransfer(alice, bob, 10000e18)verifies both parties are still verified and that_receiptFragments[alice][bob] !0= (Alice did supply a CP-bound receipt at approve time).- Tokens transfer.
BuckTransferReceipt(alice, bob, 10000e18, fromHash, toHash)is emitted, withfromHashandtoHashbeing keccak256 commitments over the corresponding ciphertexts. An auditor with subpoena power over either Alice or Bob can recover the underlying identities; an outside observer learns only that addressesaliceandbobexchanged 10,000 BUCKs.
Step 4: Alice Denominates 125 BUCK as Notes
Separately, Alice wants to move some of her BUCKs off the public-ledger transfer path into private
notes. Her wallet picks flavour A2 and values [100 BUCK, 25 BUCK] (totalFace = 125 BUCK), draws
fresh rho[i] and idHash[i] randomness, and – since the deployment pins N \in {16, 128, 1024}
– pads the live pair with 14 dummy openings whose v_i = 0 so the batch lands on the smallest
pinned circuit (N* = 16). The wallet snapshots (oldRoot, nextLeafIndex) from chain, derives
the depth-20 sibling paths for each of the 16 leaves in submission order, and runs
snarkjs groth16 fullProve against build/snark/mint_batch_N16/mint.wasm and mint_final.zkey.
The proof triple (pA, pB, pC) is abi-encoded as (uint256[2], uint256[2][2], uint256[2]) into
proofBytes.
On-chain, Alice makes two calls:
BUCK.approve(notes, 125e18, E_notes, pi_CP_notes)– standard identity-aware approve. TheNotescontract was bound at deployment under a Public Identity (bindContract(notes, pk_notes, E_notes, true)fromscript/Deploy.s.sol), so Alice's wallet constructsE_notesby re-encrypting against the Notes pool's publishedm. The CP proof itself is still required – the always-CP rule (see Why approve Is Always CP-Bound) applies regardless of spender flavour – and the on-chain receipt fragment Alice's operator stores is Alice's ownE_notesre-encryption, openable on subpoena to show "Alice authorised the Notes pool to draw 125 BUCK."Notes.mint(proofBytes, oldRoot, newRoot, nextLeafIndex, 125e18, [cm[0], ..., cm[15]])– theMintVerifierAdapterdispatches bycms.length=16tomintVerifiers[16]and forwards to the N=16 Groth16 verifier with public signals[oldRoot, newRoot, nextLeafIndex, 125e18, keccak256(cms)]. On success, 125 BUCK moves from Alice toNotes,roots[]advances to the SNARK-attestednewRoot,nextLeafIndexadvances by 16, andnoteFaceSum +125e18=. Alice's wallet keeps the live openings (flavor, v, rho, idHash, predicatefor the two real notes) privately – those are theNoteOpeningrecords she will need to spend. The 14 dummy openings are discarded.
Step 5: Alice (or her recipient) Spends a Note
Later, the holder of one of Alice's notes – it could be Alice herself, or the bearer to whom she
off-chain handed the opening – redeems cm[0] for 100 BUCK to address recipient:
- The wallet reconstructs the Merkle path
(pathElements[20], pathIndices[20])forcm[0]by replaying theMintedevent log and pulling the calldatacms[]out of each batch mint transaction (or from a locally-cached tree). snarkjs groth16 fullProveagainstbuild/snark/spend/spend.wasmandspend_final.zkeyproduces a proof binding[noteRoot, nullifier, face, recipient, chainId]=[currentRoot, Poseidon(rho, idHash, 4242), 100e18, recipient, 1]to the witness opening.Notes.spend(proofBytes, currentRoot, nullifier, 100e18, recipient)– the contract checks the root is still in the recent-roots window, rejects a previously-seen nullifier, runs the adapter (~360K gas total), marks the nullifier burned, decrementsnoteFaceSum, and callsBUCK.transferCarrying(recipient, 100e18). The recipient receives the full 100 BUCK and an amount-weighted share of the pool's average demurrage age.
Later, HomeCo reappraises the structure at $280K (updated faceValue). Alice's credit limit
drops. If her outstanding balance exceeds the new limit, the default insurance mechanism
activates (or the Jubilee fund eventually releases the lien), and she can no longer mint BUCKs.
Gas Cost Analysis
Approximate gas costs (Ethereum mainnet, Solidity 0.8.x optimizer). The Notes rows are measured
from the MintVerifier.t.sol and SpendVerifier.t.sol Foundry runs against the real Groth16
verifiers; the rest are architectural estimates for the eventual PID build.
| Operation | Gas (approx) | Cost at 30 gwei | Notes |
|---|---|---|---|
| BUCK_CREDIT.activate() | ~50K | ~$4 | Single SSTORE + event |
| BUCK_CREDIT.updateCredit() | ~80K | ~$6 | Multiple SSTOREs (insurer pays) |
| IdentityRegistry.register() | ~235K | ~$19 | PS pairing + NIZK (one-time) |
| IdentityRegistry.verifyApprove | ~29K | ~$2 | CP proof, per counterparty pair |
| BUCK.approve() (identity) | ~97K | ~$8 | 29K CP + 46K allowance + 22K SSTORE |
| BUCK.transfer / transferFrom | ~80K | ~$6 | ERC-20 baseline + receipt event |
| BUCK.transferCarrying() | ~60K | ~$5 | Weighted-merge index, no fee burn |
| BUCK.mint() (static BUCK_K) | ~150K | ~$12 | Enumerate credits + mint |
| BUCK.mint() (with PID update) | ~350K | ~$28 | + Oracle reads + PID computation |
| Notes.mint() (mint_batch, N=1) | ~461K | ~$37 | Groth16 verify (N=1, ~216K) + insert |
| Notes.mint() (mint_batch, N=2) | ~473K | ~$38 | ~237K gas/leaf – amortising starts |
| Notes.mint() (mint_batch, N=4) | ~500K | ~$40 | ~125K gas/leaf |
| Notes.mint() (mint_batch, N=8) | ~562K | ~$45 | ~70K gas/leaf |
| Notes.mint() (mint_batch, N=16) | ~624K | ~$50 | ~39K gas/leaf – shipped sweet-spot |
| Notes.mint() (mint_batch, N=32) | ~1.12M | ~$90 | ~35K gas/leaf (verifier ~422K) |
| Notes.spend() (Groth16, B-shape) | ~360K | ~$29 | Groth16 verify + transferCarrying |
| Notes.spendACP() (Groth16 + CP-DLEQ, A-shape) | ~410K | ~$33 | Groth16 verify + verifySpendCP |
| (~36K via EIP-196) + transferCarrying | |||
| BUCK_CREDIT.currentValue() | 0 (view) | Free | Pure computation, no state change |
| BUCK_K.currentBuckK() | 0 (view) | Free | Read cached value |
The PID update cost (~200K gas additional) is amortized across all mints within a dT window. With
a 1-hour dT, only the first mint after each hour pays for the oracle reads and PID computation.
For lower gas costs, the same contracts can be deployed on Ethereum L2s (Arbitrum, Optimism, Base) where gas is 10-100x cheaper, or on alternative EVM chains. The contract architecture is chain-agnostic.
Security Considerations
Oracle Manipulation
- TWAP resistance: The BUCK/USDC price uses a Uniswap V3 TWAP (30+ minutes), requiring sustained capital commitment to manipulate.
- Chainlink feeds: Commodity prices from Chainlink's decentralized oracle network, with built-in deviation thresholds and heartbeat checks.
- Staleness checks:
_getBasketCost()should verifyupdatedAtis within an acceptable window (e.g., 1 hour) and revert if stale.
Credit Enumeration DoS
A malicious user could create many tiny BUCK_CREDITs to make totalCurrentValue() gas-prohibitive.
Mitigations:
- Minimum face value for BUCK_CREDIT creation.
- Cap on credits per account (e.g., 20).
- Off-chain aggregation with Merkle proof (advanced).
Insurer Trust
The insurer can reduce faceValue at any time, potentially pushing accounts into default. This is
by design – it reflects real-world reappraisal risk. Mitigations:
- Insurance pool covers default events.
- Multiple competing insurers provide market discipline.
- Governance can blacklist abusive insurers.
- Time-locks on insurer updates (e.g., 7-day delay for reductions > 20%).
Reentrancy
Standard OpenZeppelin ReentrancyGuard on mint() and activate(). All state changes occur
before external calls.
Future Approaches
This ERC-721 implementation prioritizes clarity and correctness. Subsequent documents will explore:
ERC-1155: Semi-Fungible Batch Credits
If standardized credit classes emerge (e.g., "Calgary residential, HomeCo, LINEAR 200bp"), multiple properties in the same class could share a token ID with individual amounts. Benefits:
- Batch operations (
safeBatchTransferFrom) for portfolio management. - Reduced gas for accounts with many same-class credits.
- Natural fit for insurance tranches within a single credit.
Challenges: per-token depreciation start dates require additional state mapping, partially negating the fungibility benefit.
ERC-3643 (T-REX): Regulated Security Token
For production deployment under regulatory oversight:
- ONCHAINID: Native identity registry for KYC/AML compliance.
- Compliance modules: Programmable transfer restrictions (e.g., credits can only transfer to verified Alberta residents).
- Agent roles: Insurer as agent with granular permissions (update, freeze, force-transfer).
- Recovery: Lost-key recovery through identity verification.
This is the likely production standard if Alberta implements BUCK under provincial regulation.
ERC-4626: Vault-Based Insurance Pool
While a poor fit for BUCK_CREDIT itself, ERC-4626 is well-suited for the InsurancePool:
- Premium deposits as
deposit()/ mutual insurance shares asshares. convertToAssets()/convertToShares()for transparent share pricing.- Standard DeFi composability (yield aggregators, lending protocols).
L2 and Cross-Chain Deployment
The contracts are EVM-compatible and can deploy on Arbitrum, Optimism, Base, or Polygon with minimal changes. Cross-chain BUCK transfers via canonical bridges or LayerZero/Axelar would enable multi-chain liquidity while maintaining a single BUCK_K oracle on the settlement layer.
Implementation Plan
This document specifies the eventual shape of the system. The first deployment ships in narrow phases so the cryptographic identity layer – the single largest piece of new ground in the design – can land first, against minimal stubs of the surrounding contracts. Each phase ends in a self-contained, deployable state with passing tests. The status table below reflects the repo at the header date; the phase subsections document the intent of each phase as originally scoped and are annotated with what the current code actually ships.
The phase set has grown since the original v0.5 plan in two directions. Backward, Phase 7-bis
landed the in-circuit Merkle-insertion batch-mint pivot documented in
alberta-buck-notes-rollup-mint.org – now shipped as
mint_batch.circom with per-N pinned verifiers in {1, 2, 4, 8, 16, 32}. Phase 8 (A-flavor spend
with key-pair binding) also shipped, in two layers: spend_a.circom V2 (in-circuit Poseidon-8
idHash binding gate) plus off-chain Chaum-Pedersen DLEQ verified in
IdentityRegistry.verifySpendCP via EIP-196 BN254 precompiles, all glued by the new
Notes.spendACP entry point. Forward, the path to live BUCK_K (Phase 10/11) and dynamic
BUCK_CREDIT-backed minting (Phase 12) is sequenced behind one remaining Notes deliverable:
serialized B1 sub-notes (Phase 9) per
alberta-buck-notes-serialized.org. Both companion
memos have been reviewed for cryptographic correctness; see Cryptographic Correctness Review of
Companion Memos below.
Phase Completion Status (current)
| Phase | Scope | Status |
|---|---|---|
| Phase 0 | Stub BuckCredit + BuckKControllerStatic |
done – BuckKControllerStatic.sol live |
| Phase 1 | BN254.sol helper library |
done – ecAdd/ecMul/ecPairing wrappers + FS helpers |
| Phase 2 | IdentityRegistry.sol |
done – PS + NIZK + CP all on-chain |
| Phase 2-A | Simulated issuer (alberta_buck.wallet.issuer) + tests |
done – Issuer class with PS issuance, ElGamal delivery, revocation; 15 tests pass |
| Phase 3 | Identity-aware Buck.sol |
done – CP-bound approve, bilateral transfer, receipt event |
| Phase 4 | Public-Identity contracts | done – bindContract(target, pk, E, isPublicIdentity_); Notes wired as Public-Identity |
| Phase 5 | Notes scaffolding + stub verifiers | done – Notes.sol + StubMintVerifier / StubSpendVerifier |
| Phase 6 | Groth16 mint circuit (mint.circom, N=2) + adapter |
done – MintGroth16Verifier, MintVerifierAdapter |
| Phase 7 | B-flavor spend circuit + nullifier reveal | done – spend.circom, SpendGroth16Verifier, Tornado-tree, transferCarrying |
| Phase 7-bis | Batch-mint pivot: in-circuit Merkle insertion | done – mint_batch.circom, per-N verifiers in {1, 2, 4, 8, 16, 32} |
| Phase 8 | A-flavor spend (spend_a.circom V2 + off-chain CP-DLEQ) |
done – spend_a.circom V2 (in-circuit Poseidon-8 idHash binding gate); Notes.spendACP calls SpendAGroth16Verifier + IdentityRegistry.verifySpendCP (~36K gas via EIP-196) |
| Phase 9 | Serialized B1 sub-notes (spend_serialized.circom + bitmap) |
pending – POC validated to N_fam=4096; circuit + bitmap storage outstanding |
| Phase 10 | Live BuckKController (PID + AMM-pool oracles) |
pending – contract skeleton in-tree; AMM-pool oracle wiring + PID tuning outstanding |
| Phase 11 | BUCK liquidity bootstrap + BUCK/USDC AMM pool | pending – closes the loop on Phase 10 by giving the controller a real BUCK price |
| Phase 12 | BUCK_CREDIT insurance integration in Buck.mint |
pending – per-tx credit-limit deduction + simultaneous insurance purchase polish |
| Phase 13 | Production MPC trusted-setup ceremony | pending – replaces dev entropy in scripts/snark/setup.sh |
Current forge suite: 202 unit tests pass (across 13 test suites); one BuckKControllerForkTest
requires a live mainnet RPC and is skipped offline.
Cryptographic Correctness Review of Companion Memos
Two design memos drive the immediate path forward. Phase 7-bis (batch-mint) and Phase 8 V2
(A-spend) are both shipped; only Phase 9 (serialized B1 sub-notes) remains as an outstanding
Notes deliverable. Both memos were reviewed for cryptographic correctness on 2026-04-24; both
verdicts are sound, with one explicit operational trust assumption (K_fam destruction in
serialized B1) that is symmetric to a pre-existing one (issuer-retained rho in single-note
B1) and is documented in the Phase 9 wallet-UX requirement.
alberta-buck-notes-rollup-mint.org – Phase 7-bis batch-mint design
Verdict: cryptographically sound; integrated into shipped code. The in-circuit Poseidon Merkle
insertion replaces the per-leaf Notes._insert ladder with one SNARK-attested newRoot per batch.
- Public-input layout
(oldRoot, newRoot, nextLeafIndex, totalFace, cm[0..N-1])is binding-complete: the contract'soldRoot =roots[currentRootIndex]= guard plus the SNARK's derivation ofnewRootfromoldRootand the folded leaves chains the on-chain root state to the SNARK-attested transition without a trusted off-chain step. - Dual Merkle walk per leaf –
priorWalkconsumesZERO_VALUEat the insertion slot to attestoldRoot;postWalkconsumescm[i]to advancerollingRoot– is the correct in-circuit dual of the shipped on-chain insert. Both walks share the same siblings, supplied by the prover's localfilledSubtreesmirror. nextLeafIndexas a public input forces wallet/chain agreement on tree size and prevents the prover from misplacing leaves at a stale index.- Hostile-Alice analysis A1 through A12 holds. A3 (duplicate
cmacross mints) correctly migrates from explicit revert to economic self-enforcement: the deterministicPoseidon-3(rho, idHash, 4242)nullifier means duplicate openings are spendable exactly once; the issuer paystotalFacefor each duplicate and forfeits all but one. A6 (lying aboutnewRoot) is closed by the SNARK constraint set; A7 (front-running) reverts on theoldRootguard with no value lost. - Affine cost model
mint_tx_gas(N) ~125K + 31K * N= empirically fits the measured N=16 (624K) and N=32 (1.12M) Foundry runs; per-note cost asymptotes at ~31K. - Pinned-N policy {16, 32} on stock L1 (both fit the EIP-170 24 KB ceiling at 14.5 KB and 20.8 KB respectively), promote to {16, 32, 128} via calldata-IC delivery, recursion above N=512 is the natural sequencing. Currently shipped: per-N pinned verifiers at {1, 2, 4, 8, 16, 32}; the smaller sizes are useful for unit tests and small-batch wallet flows even after the L1 ceiling is reached.
alberta-buck-notes-serialized.org – Serialized B1 sub-notes design
Verdict: cryptographically sound, with K_fam destruction as the single operational trust
assumption (symmetric to existing single-note B1 issuer-race over rho). Mint-circuit reuse is
exact; only the spend circuit and the double-spend storage diverge.
- Construction:
subRoot = MerkleRoot(s_0..s_{N-1}),predicate_fam = Poseidon-2(predicate_base, subRoot),cm_parent = Poseidon-5(flavor, v, rho, idHash, predicate_fam). Becausecm_parentis an ordinary Poseidon-5 output,mint_batch.circomhashes it identically to a single-note B1 leaf – no new mint circuit, no new trusted setup, no new pinned-N variants. - Witness-and-verify discipline: the spend circuit does NOT invert
s_i -> i(which would require breaking the PRF and would let an attacker enumerate every sibling). The recipient witnesses(i, s_i, path_i_in_subRoot)privately; the circuit verifiesMerkleOpen(subRoot, i, s_i, path) === true.iis emitted as a public signal and used by the contract to flipbitmap[cm_parent]biti. K_famdestruction is the documented operational trust assumption. An issuer retainingK_famcan produce(i, s_i, path)on demand for any sub-note and impersonate recipients. This is symmetric to the existing single-note B1 issuer-race – an issuer who retainsrhocan race the recipient at spend time – and is enforceable only by wallet UX / HSM attestation, not by the cryptography itself. Promoted to a Phase 9 deliverable.- B1-only restriction: each spend reveals
cm_parent, linking co-spent siblings. For bearer notes the linkage is free (denom + family membership are not secrets in a bearer model); for A1/A2 the linkage would defeat the privacy property. Correctly scoped. - Per-parent bitmap nullifier amortizes to ~2.96K gas per sub-note (
SLOAD + warm SSTORE) vs 20K cold for the single-note path – ~7x storage reduction at the asymptote. The bit-flip squat attack is closed by the fact that onlyNotes.spendSerializedwrites bits, and that path requires a Groth16 proof binding(cm_parent, i)to the recipient. - Brute-force resistance is 2^128 (Poseidon preimage / second-preimage), identical to the single-note path. Serialization adds zero attack surface at the "forge a note I don't own" level.
The scripts/snark/serialized_notes_poc.py driver validates the construction end-to-end
(issuer-mint, recipient-verify, spend-predicate, forgery tests) at N_fam in {256, 1024, 4096}.
Porting the predicate to circuits/spend_serialized.circom is the remaining implementation work.
The two memos are mutually orthogonal: rollup-mint amortizes publishing N commitments into
noteRoot; serialized amortizes producing N bearer notes from one commitment. Together they
drop marginal per-note mint cost by 2-3 orders of magnitude vs. the shipped per-leaf design.
Testing Strategy: Three Layers
Tests are stratified by what they actually need from the EVM, so the cryptographic and accounting work can iterate without ever touching a network:
- Local-EVM unit tests (default). Foundry's in-memory EVM ships the BN254 precompiles
(
ecAdd0x06,ecMul0x07,ecPairing0x08) and all the EIP-2929 / cancun behavior we need. Every contract in this document – BUCK_CREDIT, BUCK, IdentityRegistry, the BN254 helper, the static BUCK_K – is fully exercisable without RPC, on a clean state, in milliseconds per test. - Pinned mainnet/Sepolia forks (only the eventual PID BUCK_K). When the production
BuckKControllerships and needs to read Chainlink price feeds and the Uniswap V3 BUCK/USDC TWAP, run the relevant tests with--fork-url ${RPC} --fork-block-number ${N}. Pinning the block number is the determinism knob: the EVM state at block N is immutable, and Foundry caches the RPC responses under~/.foundry/cache/rpc/– after the first run the suite is offline and bit-reproducible.vm.mockCallcovers pathological oracle paths (stale feeds, manipulated TWAPs) without needing to find a block at which they actually occurred. - Differential parity with the Python reference. The cryptographic surface is described by an
executable Python reference (see Off-Chain Wallet below). CI regenerates the canonical JSON
test vectors from that reference and
git diff --exit-code's them against committed copies; the Solidity tests load the same vectors viavm.readFileand assert equality. Any drift between the spec, the wallet, or the Solidity tells us immediately which of the three is wrong.
The deliberate exclusion: no vm.ffi shelling out to Python at test time. That trades
determinism, speed, and CI portability for a coupling we don't need – the wallet emits vectors
once, the Solidity reads them many times.
Off-Chain Wallet (alberta_buck.wallet, Python)
The off-chain components – KYC ceremony, credential derivation, Chaum-Pedersen proving, and (later)
SNARK witness generation for Notes – are implemented as a Python package, alberta_buck.wallet,
with a CLI front-end. Python is the natural choice: the existing references in
Identity Example and Proofs
are already Python, py_ecc ships a complete BN254 (alt_bn128) implementation matching Ethereum's
precompiles, and the package is invoked the same way every other dev tool in this repo is
(nix develop --command python3 -m alberta_buck.wallet ...).
The wallet has four jobs:
- Reference implementation of the cryptography in Identity, the place where the spec is ground-truthed.
- Test-vector emission: a CLI verb (
wallet emit-vectors) writes a canonical JSON file undertest/vectors/containing the inputs and expected outputs of every on-chain proof check. Foundry tests load this file and assert agreement with the Solidity verifier. - End-to-end developer surface: a CLI verb (
wallet sign,wallet register,wallet approve) produces the calldata an end user's wallet would submit, so integration tests (and humans) can drive the contracts the same way a real wallet would. - Simulated counterparties:
alberta_buck.wallet.issuermodels the off-chain government / institutional issuer (PS keypair, identity-record canonicalization, signed credential delivery, optional ElGamal-encrypted transmission to the applicant, in-memory revocation log). This is the entity the wallet'sregisterverb effectively receives a credential from before rerandomizing and posting toIdentityRegistry. See Phase 2-A for the implementation status.
The wallet does not hold private keys for production users; it is a deterministic reference and a test driver. A production wallet would reuse the same primitives behind a different storage and UI.
Phase 0: Stub the Surrounding Contracts
Goal: Buck can be developed and tested against a minimal, deterministic surface for
BuckCredit and BuckK. All real moving parts (depreciation curves, PID, oracles) are
deferred.
-
BuckKController.sol-> replace withBuckKControllerStatic.sol:- State:
uint256 public buckK(18 decimals, default1e18). - Functions:
currentBuckK() view returns (uint256),compute()(no-op compat shim),setBuckK(uint256),transferGovernance(address)(both governance-only). - Drop all PID state, Chainlink basket, and Uniswap TWAP code. The full PID implementation
documented above remains the design target – it slots back in once the identity layer is
stable. The design-target
BuckKController.solalso ships (unit-tested with mocked feeds); the live-oracle fork test is gated on RPC availability.
- State:
BuckCredit.sol-> keep the existing implementation, but treat only one function as a load-bearing interface for Phase 0+:totalCurrentValue(address holder) external view returns (uint256). No depreciation rework, no insurer-flow churn.
Deliverables: passing Foundry tests that mint, set new BUCK_K values, and watch credit limits re-derive correctly.
Phase 1: BN254 Helper Library
Goal: a single Solidity library that wraps the three BN254 precompiles and exposes the typed operations the identity contracts need.
-
src/crypto/BN254.solwith:G1Point,G2Pointstructs and constants forg1,g2.add(G1Point, G1Point) -> G1PointviaecAddprecompile (0x06).mul(G1Point, uint256) -> G1PointviaecMulprecompile (0x07).pairingProd3(...)andpairingProd4(...)helpers viaecPairingprecompile (0x08).- Pure-Solidity Fiat-Shamir helpers:
fsRegistration(...)andfsChaumPedersen(...), each wrapping akeccak256over the canonical encoding from the Identity document. - High-level checks:
checkPSSignature,checkRegistrationNIZK,checkChaumPedersen.
Deliverables: differential tests against the Python reference in alberta-buck-identity-example.org
(same curve, same encoding, same Fiat-Shamir transcript). nix develop --command python3 drives
the reference; Foundry vm.ffi or pre-baked vector files feed the Solidity tests.
Phase 2: IdentityRegistry Contract
Goal: ship the on-chain trust anchor exactly as specified in the IdentityRegistry section.
src/IdentityRegistry.solimplementingregister,bindContract,verifyApprove,trustIssuer.- All proofs verified using
BN254helpers from Phase 1. -
Tests:
- End-to-end registration with the Python reference vectors (positive case + the four
counter-examples from
alberta-buck-identity-example.org). registerrejects untrusted issuers.registerrejects double-registration of the same address.verifyApproveaccepts a valid Chaum-Pedersen proof and rejects every documented tampered case (wrong message, wrong randomness, wrong public key, replayed challenge).- Gas measurements within ~15% of the published estimates (235K register / 29K verifyApprove).
- End-to-end registration with the Python reference vectors (positive case + the four
counter-examples from
Deliverables: deployable registry, passing test suite, documented gas measurements.
Phase 2-A: Simulated Issuer (Government Registry) + Test-Credential Pipeline
Goal: model the off-chain side of credential issuance – the entity an applicant submits identity
documents to, which signs them with PS and (optionally) returns the credential ElGamal-encrypted
to the applicant's public key. Without this layer the existing tests have no way to drive
IdentityRegistry other than from inline ps_keygen + ps_sign calls in test fixtures, which
collapses the issuer's role into the wallet's role and makes multi-issuer / rotation / revocation
scenarios awkward.
The on-chain trust anchor is unchanged: IdentityRegistry.trustIssuer(addr, pk) keys the
issuer's PS public key by Ethereum address and revokeIssuer(addr) clears it. This phase adds
the matching off-chain entity – same PS keypair, same address, plus the issuance ceremony itself.
-
alberta_buck/wallet/issuer.py:Issuerdataclass:issuer_id(string label, e.g. "atb-financial-ca"),issuer_addr(Ethereum address used as the on-chain trust anchor),keypair(PS), simulated revocation set, in-memory issuance log.Issuer.setup(issuer_id, issuer_addr, rng)class-method for deterministic test setup.issue(identity_fields, applicant_addr, applicant_pk=None, rng)->IssuedCredential: overwrites theissuer_idfield in the submitted record (so applicants cannot smuggle a different issuer label into the canonical), canonicalizes, computes m, signs sigma, and – whenapplicant_pkis given – ElGamal-encryptsM = m*Gunder it for confidential delivery. The wallet decrypts on receipt to confirm M before rerandomizing sigma.revoke(addr)/reset(addr)/issuance_log()/verify_credential(cred)helpers.rerandomize_for_registration(cred, rng)– convenience wrapper for the wallet-side step that produces sigma' before callingIdentityRegistry.register.
alberta_buck/test/test_issuer.py: 15 tests covering keygen determinism, single-credential round-trip,issuer_idoverwrite invariant, ElGamal delivery decryption, revocation, multi-issuer non-cross-verification, and the full end-to-end pipeline (issue -> rerandomize -> ElGamal encrypt ->registration_prove->registration_verify).- Future: replace the inline
ps_keygencalls inalberta_buck/wallet/vectors.pyand the Foundry-side identity test vectors withIssuer.setup(...).issue(...)so the canonical JSON emitted totest/vectors/reflects an actual issuance ceremony rather than a synthetic one. This is mechanical and can be done incrementally – vectors stay byte-identical when seeded the same way.
Cryptographic note: ElGamal re-encryption proper (Alice -> Bob during approve) is wallet-side
and remains in alberta_buck.wallet.chaum_pedersen. The issuer's optional ElGamal step is a
one-shot encryption to the applicant; no Chaum-Pedersen proof is required because the issuer is
trusted to encrypt to the address the applicant nominated, and the applicant verifies M decrypts
to m*G for the m they intended to receive.
Deliverables: issuer.py module exporting Issuer and IssuedCredential; test_issuer.py
green; the wallet's __init__.py re-exports the new symbols so downstream tests can write
from alberta_buck.wallet import Issuer.
Phase 3: Identity-Aware Buck ERC-20
Goal: replace the current Buck.sol with the identity-aware version specified in the BUCK section.
- Add
IIdentityRegistryinterface and constructor wiring. mintgates onisVerified.approve(spender, amount, E_bob, pi_CP)with the parameterlessapprove(address, uint256)reverting.transfer/transferFromvia_identityCheckedTransferwith bilateral checks and theBuckTransferReceiptevent._receiptFragmentsstorage and_identityHashhelper (keccak of bound(pk, E)).
Tests:
- Two registered users can complete the full
approve->transferFromcycle and observe aBuckTransferReceiptwhose hashes match the off-chain ciphertexts. - Unregistered
mintreverts. transferto an unregistered recipient reverts.transferwithout a prior CP-boundapprovereverts when neither party is bound under a Public Identity.- Transfers to/from a Public-Identity contract may fall back to the bound
_identityHashwhen no per-pair receipt fragment exists; EOA<->EOA encrypted transfers MUST have a prior CP receipt.
Phase 4: Public-Identity Contract Support
Goal: AMM pools, vaults, and treasuries can interact with BUCK without needing to generate Chaum-Pedersen proofs.
- Confirm
bindContract(target, pk, E, isPublicIdentity_=true)is invocable only against deployed contracts (target.code.length > 0) and is first-binder-wins; atomic deploy+bind is available viaBuckAwareDeployer.deployAndBind/deployAndBindCreate2. - Add a small
MockAMMPooltest contract whose deployerbindContract=s it as a Public-Identity contract with the operator's published =(pk, E), then participates inapprove=/=transferFromflows on both sides (taker and maker). - Verify the receipt event uses the bound
_identityHashas the public side's fragment when no per-pair CP fragment exists, and the CP-bound ciphertext on the counterparty side.
Phase 5: Notes Scaffolding
Goal: lay the structural groundwork for BUCK Notes – a commitment pool, a pluggable mint verifier interface, and the off-chain wallet helpers – behind a stub verifier so the end-to-end flow can be exercised without yet shipping the SNARK.
Shipped:
src/Notes.sol– the contract documented in the Notes section above. The initial Phase-5 deployment used a flatcommitments[]append list behind a stub verifier; the incremental Poseidon Merkle tree and 30-slot recent-roots ring buffer landed alongside Phase 7.src/IMintVerifier.sol/src/ISpendVerifier.sol– the narrow verifier interfaces. DecouplingNotesfrom the SNARK toolchain was the key Phase-5 invariant so the Phase-6 / Phase-7 verifiers land as constructor-argument swaps.src/StubMintVerifier.sol/src/StubSpendVerifier.sol– governance-toggleable boolean verifiers used by all Notes state-machine tests.alberta_buck.wallet.notes(Python) –NoteOpeningdataclass, flavour constants, keccak-based placeholder commitment / nullifier helpers with domain-separating tags,id_payload_a1/a2/b1helpers matching the expected circuit public-input layout. 13 pytest tests.test/Notes.t.sol– 16 tests including happy path, duplicate detection, zero-commitment rejection, empty batch, verifier disabled, external rejecting verifier, missing approval, missing balance, state-atomicity on transfer failure.
The Python wallet's note_commitment is a keccak-based placeholder; the on-chain contract stores
opaque uint256 commitments without re-hashing, so swapping keccak for Poseidon is an off-chain
wallet change only.
Phase 6: Groth16 Mint Circuit + Verifier
Goal: turn the stub into a cryptographically load-bearing verifier. The mint contract must refuse
to append a batch unless a real Groth16 proof ties totalFace to the sum of the (flavour, value,
rho, idHash, predicate) openings, and each cm[i] to Poseidon(5) of its opening.
Shipped:
flake.nixaddscircomto the dev shell and bootstraps npm dev dependencies on first entry.package.jsonpinscircomlib2.0.5,circomlibjs^0.1.7,snarkjs0.7.5,ethers^6.13.4.circuits/mint.circom–N = 2mint circuit (see Notes section for the signal table), withNum2Bits(128)range bounds ontotalFaceand eachv[i]. ~3,500 R1CS constraints.scripts/snark/setup.sh– circom compile, 2^15 powers-of-tau (shared between mint and spend), groth16 phase-2 setup, export verification key + Solidity verifier (MintGroth16Verifier,SpendGroth16Verifier). Dev-only entropy.scripts/snark/prove_mint.js– witness builder (two A2 notes,v[100,25]e18=), seeded LCG randomness,snarkjs.groth16.fullProve,pBFp2-coordinate swap, abi-encodedproofBytes, and sanity-checksnarkjs.groth16.verify.src/MintGroth16Verifier.sol– snarkjs-generated verifier.src/MintVerifierAdapter.sol–MINT_BATCH_SIZE=2adapter implementingIMintVerifier.test/MintVerifier.t.sol– 7 tests: happy path (real proof accepted), three tamper attacks (totalFace,cm[0], batch size) all reverting withNotes: bad mint proof, malformed proof-bytes revert at decode, adapter constructor rejects zero.
The circuit is pinned at N=2. A variable-N deployment re-templates both the circuit and the
adapter's public-signals arity.
Phase 7: Spend Circuit + Nullifier Reveal
Goal: cryptographically redeem a minted commitment against an on-chain Merkle root under a fresh
nullifier, paying out an equal face value of BUCK via transferCarrying so the SNARK's
face === v invariant is preserved at the ERC-20 boundary.
Shipped:
circuits/spend.circom– single-note B-shape redemption (~12,000 R1CS constraints). Public inputs[noteRoot, nullifier, face, recipient, chainId]; five constraint groups (range, commitment, Merkle inclusion at depth 20, nullifier determinismPoseidon(3)(rho, idHash, 4242), value conservation).circuits/spend_a.circomV2 – A-shape redemption circuit (~13,300 R1CS constraints; 6152 non-linear + 7140 linear). Public inputs[noteRoot, nullifier, face, recipient, chainId, E_n.R.x, E_n.R.y, E_n.C.x, E_n.C.y](9 total); adds the A-tag nullifierPoseidon(3)(rho, idHash, 4243), the flavor-in-{1,2} gate, and the in-circuit Poseidon-8 binding constraintPoseidon-8(E_n.R.x, E_n.R.y, E_n.C.x, E_n.C.y, issuerData[0..3]) ==idHash= (~265 R1CS) that pins the publicly-revealedE_nto the leaf at mint time.src/SpendGroth16Verifier.solandsrc/SpendVerifierAdapter.sol– snarkjs-generated verifier and the narrowISpendVerifieradapter for B-shape spends.src/SpendAGroth16Verifier.solandsrc/SpendAVerifierAdapter.sol– snarkjs-generated verifier and the narrowISpendAVerifieradapter for A-shape (V2) spends; the adapter packs the 9 public signals in order and calls the underlyingverifyProof.src/Notes.solextended with the Tornado-style incremental Poseidon Merkle tree (depth 20,TREE_DEPTHprecomputed empty-subtree roots, 30-slotroots[]ring buffer), and three spend entry points:spend(proof, root, nullifier, face, recipient)for B-shape,spendACP(proof, root, nullifier, face, recipient, E_n, cpProof)for A-shape (which calls bothspendAVerifier.verifySpendAandidentityRegistry.verifySpendCPatomically), and a governance settersetIdentityRegistryguarded by the sameonlyGovernancemodifier as the verifier setters.src/PoseidonT3Bytecode.sol– a helper that deploys the 2-input Poseidon precompile from raw bytecode generated by circomlibjs;script/Deploy.s.soldeploys it and passes the address toNotes's constructor.src/Buck.solextended with continuous demurrage (cumIndex,BASE_RATE_PER_SEC, per-account_indexAtLastTouch) andtransferCarrying(to, amount)– a single entry point that performs a weighted-index merge on the recipient without burning a fee, so a note redemption pays the recipientface, notface - fee. (The per-accountFeeTreatmentenum that earlier drafts proposed has been replaced by this simpler single-function approach; the Notes pool does not need a flag becausetransferCarryingis the only path it calls.)test/SpendVerifier.t.solandtest/BuckDemurrage.t.solcover the B-spend path: happy path, double-spend, unknown root, tampered recipient/face/nullifier, chainId mismatch, zero-recipient, and the weighted-merge invariant that the recipient's age equals the pool's average after spend.test/SpendAVerifier.t.solcovers the A-spend path (V2; 26 tests): happy path, double spend, A-tag-disjoint-from-B-tag, every SNARK-public-input tamper (including all fourE_ncoordinates), every CP-DLEQ tamper (e, s, T1, T2), unregistered/wrong-spender rejection viaverifySpendCP, and governance lockdown for bothsetSpendAVerifierandsetIdentityRegistry.
Pending (now sequenced as their own phases below):
- Phase 7-bis: in-circuit Merkle-insertion batch-mint pivot. Now done – see Phase 7-bis: Batch-Mint Pivot below.
- Phase 8: A-flavor spend (
spend_a.circomV2 with in-circuit Poseidon-8 binding + off-chain CP-DLEQ viaNotes.spendACP). Now done – see Phase 8: A-flavor Spend below. - Phase 9: serialized B1 sub-notes (
spend_serialized.circom+ per-parent bitmap). - Phase 10: live
BuckKControllerdeployment with AMM-pool oracles (XAU/USDC, BUCK/USDC, etc.). - Phase 11: BUCK liquidity bootstrap + BUCK/USDC AMM pool deployment.
- Phase 12: BUCK_CREDIT insurance integration in
Buck.mint(dynamic credit-limit deduction + simultaneous insurance purchase). - Phase 13: production MPC trusted-setup ceremony.
Resolved Items from the Notes Correctness Review
The Phase 6 \(\to\) Phase 7 transition surfaced four correctness obligations originally listed as open questions in alberta-buck-proofs.org (Part IV). All four are now resolved in the shipped contracts:
Num2Bitsrange checks onv[i]andtotalFaceincircuits/mint.circom– resolved.vRange[i] = Num2Bits(128)andtotalRange = Num2Bits(128)are wired inmint.circom;scripts/snark/setup.shandMintGroth16Verifierhave been refreshed. The spend circuit re-derives the matching range bound on its ownv(vRange = Num2Bits(128)) and on the publicface(faceRange = Num2Bits(128)), closing the field-overflow corridor end-to-end.- Explicit batch identifier in
Notes.mint– still an open design question, not a correctness bug. The shippedMinted(issuer, totalFace, startIndex, count, newRoot)event carries a root per batch, which an indexer can Poseidon-commit to if an auditable batchId is wanted. Revisit if regulatory analytics need an on-chainbatchIdsmap. - Phase-7 spend circuit and on-chain support – resolved. Shipped as described above: the
Tornado-style incremental Poseidon tree lives in
Notes.sol, nullifiers are computed in-circuit asPoseidon(3)(rho, idHash, 4242), and value conservation is enforced via the 128-bit range bound plus the commitment reconstruction constraint. - Per-account fee-treatment mode – superseded. The earlier draft added an
enum FeeTreatment { Deducting, Carrying }, a per-account_feeModemapping, aBuckDerived()bytecode marker, abalanceOf(account) == 0precondition for mode changes, and aFeeCarriedevent. The shipped solution is simpler: a singletransferCarrying(to, amount)method onBuck.solthat performs the weighted-merge index update atomically, callable by any contract (today onlyNotescalls it; future escrow/payroll contracts may too). No per-account mode, no bytecode marker, and no balance precondition are needed because the weighted merge is continuous – the recipient simply absorbs a proportional share of the sender's unsettled age.
Phase 7-bis: Batch-Mint Pivot (in-circuit Merkle insertion)
Goal: collapse the per-leaf on-chain Poseidon ladder of Phase 7 into one SNARK-attested newRoot
per batch, lifting the practical mint ceiling from N=4 (block-saturated under per-leaf inserts) to
N=256-512 on L1 with stock Groth16 verifiers and an L2 ceiling of N=1024+.
Shipped:
circuits/mint_batch.circom– parameterised batch-mint circuit;circuits/mint_batch_n{1,2,4,8,16,32}.circomare the per-N specialisations consumed byscripts/snark/setup.sh.src/MintBatchN{1,2,4,8,16,32}Groth16Verifier.sol– snarkjs-generated per-N verifiers, measured 14.5 KB at N=16 and 20.8 KB at N=32 (both under the EIP-170 24 KB ceiling).src/MintVerifierAdapter.sol– now dispatches bycms.lengthtomintVerifiers[cms.length], buildspub[0..4+N) = [oldRoot, newRoot, nextLeafIndex, totalFace, cm[0..N)]inline, forwards to the per-N Groth16 verifier.src/Notes.sol– public inputs include(oldRoot, newRoot, nextLeafIndex, totalFace);filledSubtrees,zeros[], andcommitmentExistsare retired (duplicate-cmmigrated to economic self-enforcement via the deterministic nullifier). The 30-slotroots[]ring buffer is now updated by writing the SNARK-attestednewRootrather than the per-leaf on-chain insert.scripts/snark/prove_mint_batch.js– new prover script; the off-chain wallet maintains a local mirror offilledSubtreesand emits per-leaf sibling paths in submission order.scripts/snark/setup.sh– runs phase-2 setup per pinned N, exporting one verifier Solidity file per pinned N.test/MintVerifier.t.sol– 43 tests: full N=1..16 ladder (happy path, four tamper pathstotalFace/newRoot/cm[]/ wrong-N, replay-after-mint, two-batch successive insertion) plus the N=32 happy path, cross-N successive (N=16 -> N=32), cross-N replay (N=4 proof rejected after live state advances), per-N dispatch coverage, and a partial-batch case (5 live + 11 v=0 dummy leaves at N=16) confirming dummies advancenextLeafIndexby N without inflatingtotalFace.
Cost: full forge measurements across the pinned ladder – N=1: 461K, N=2: 473K, N=4: 500K, N=8: 562K, N=16: 624K, N=32: 1.12M. Per-note cost amortizes from 461K at N=1 (the constant ~216K Groth16 verifier dominates) to 39K at N=16 to 35K at N=32, approaching the ~31K asymptote implied by the per-leaf calldata + R1CS slope. Spend cost is unchanged at ~360K (independent of mint batch size).
Stop conditions for promoting to N >= 64:
- EIP-170 24 KB verifier-bytecode ceiling. N=64 lands at ~33 KB, breaching the limit. The three escape hatches (calldata-IC hash-pinned, sharded SSTORE2, plain SLOAD) are documented in alberta-buck-notes-rollup-mint.org; calldata-IC is the natural fit for the Holochain wallet.
- Block-share economics. At N=512 a single mint occupies ~half a 30M-gas block; at N=1024 it cannot land on L1 at all. L2 deployment (Arbitrum/Base/Optimism, ~100x looser block budgets) lifts this; recursion (Nova/Halo2/Plonky3) is the eventual fix above N=512.
Phase 8: A-flavor Spend (spend_a.circom V2 + off-chain CP-DLEQ) – Shipped
Goal: ship the missing half of the spend surface – private redemption of A1/A2 notes that carry
an identity binding – with the corrected key-pair-binding semantics surfaced by the 2026-04
Deepseek R4 review. Both V1 and V2 are now in tree, exercised end-to-end by 26 forge tests in
test/SpendAVerifier.t.sol.
Corrected design invariant (per alberta-buck-proofs.org Theorem 10.1):
- The A-spend identity binding requires a single
sk_depwitness that simultaneously satisfiespk_dep_current ==sk_dep * G= (the spender's registered public key) and produces matching decryptions of both the note ciphertextE_nand the registered ciphertextE_reg. The Chaum-Pedersen DLEQ enforcesC_n - sk_dep * R_n === C_reg - sk_dep * R_reg– if the recipient lostsk_recand re-registered under a new ElGamal key, no singlesk_depsatisfies both constraints. - Consequence: A-note loss-recovery via identity re-issuance is impossible by design.
sk_recmust be backed up like a hardware-wallet seed; loss is terminal. The companion documents (alberta-buck-notes.org, alberta-buck-notes-flow.org) were revised in the same cycle to remove the now-incorrect "transparent re-issuance" framing.
Phase 8 ships in two layers, both in tree:
V1: spend_a.circom predicate-only A-spend
The Phase 8 V1 circuit ships the addressed-spend scaffolding – A-tag nullifier, flavor
constraint, recipient binding – but defers the cryptographic identity gate to V2. The reason is
purely engineering: the CP-equality constraint
C_n - sk_dep * R_n === C_reg - sk_dep * R_reg requires native BN254 G1 arithmetic in-circuit,
which is non-native field arithmetic over the BN254 scalar field, costing on the order of 10K-25K
constraints per group operation. A full in-circuit CP would multiply spend_a's constraint count
by ~10x. Until Phase 8 V2 ships the off-chain verifier (below), A-flavor notes remain
predicate-bound only – whoever holds (rho, idHash, predicate) can spend.
V2: In-circuit Poseidon-8 binding + off-chain CP-DLEQ + Solidity precompile verifier
Phase 8 V2 ships the cryptographic identity gate as two cooperating components glued by the new
Notes.spendACP entry point. The in-circuit half is a Poseidon-8 binding gate added to
spend_a.circom V2 that pins the publicly-revealed E_n to the leaf's idHash at mint time
(~265 R1CS). The off-chain half is a 4-element Chaum-Pedersen DLEQ proof verified by Solidity
using the EIP-196 BN254 precompiles inside IdentityRegistry.verifySpendCP (~36K gas). The
DLEQ-verification site moves out of the circuit (where it would have cost ~+250K R1CS in
non-native BN254 G1 arithmetic) and into Notes.spendACP, which calls both the SpendA Groth16
verifier and verifySpendCP atomically. This preserves the V1 invariant (Theorem 10.1):
identity re-issuance still cannot recover an A-note, because the second algebraic check fails
under any new key, and the in-circuit binding stops a spender from substituting a different
E_n just to make the off-chain check pass with their own key.
The DLEQ statement (single-sk_dep variant of Theorem 8 #3):
Prove knowledge of
sk_depsuch thatpk_dep ==sk_dep * G= (registered key)(C_reg - C_n) ==sk_dep * (R_reg - R_n)=
Sigma protocol (alberta_buck.wallet.spend_cp):
Prover picks
t in Fr, computesT1 = t*G,T2 = t*(R_reg - R_n). Challenge:e = H(R_n, R_reg, C_n, C_reg, pk_dep, T1, T2, recipient, chainid)Response:s = t + e*sk_depProof:(e, s, T1, T2)– 4 field elements, ~36K gas to verify on-chain.
Architectural note: identity verification location. The A-spend identity check lives in the
Notes.spendACP API call, not in the SNARK circuit. The spender provides their identity
ciphertext E_n alongside the spend, and Notes.spendACP calls
identityRegistry.verifySpendCP(msg.sender, recipient, E_n, cpProof) which reads
_E_addr[spender] and _pk[spender] from registry storage. All A1/A2 identity, security, and
privacy properties enumerated in alberta-buck-notes.org remain
in place: only the DLEQ verification location moved (off-chain CP via precompiles, instead of
non-native G1 in the circuit). The link between the SNARK and the CP verifier is the note's
identity ciphertext E_n, exposed as 4 additional public inputs to spend_a.circom V2 and
re-bound to idHash via the in-circuit Poseidon-8 binding gate (~265 R1CS). Together the
two layers force the spender to (a) prove a leaf opening pinning the published E_n to
idHash at mint time, and (b) prove they own the sk_dep that decrypts both E_n and the
registered E_reg to the same identity point M.
Why off-chain CP rather than in-circuit:
| Option | Cost | Status |
|---|---|---|
| In-circuit CP (non-native BN254 G1) | +250K R1CS, +30s prove, +6 KB vkey | Rejected (V1, V2) |
| BabyJubJub re-key (re-register on Twisted | Re-architect IdentityRegistry, | Rejected (scope) |
| Edwards curve) | wallet, all Identity flows | |
| Off-chain CP-DLEQ + EIP-196 precompiles | 4 field elts, ~36K gas verify | Selected (V2) |
V1 Deliverables (shipped) – predicate-only A-spend:
circuits/spend_a.circomV1 – A-shape redemption circuit. Constraint groups: range bounds onface=/=v, Poseidon-5 commitment opening, Tornado-style Merkle inclusion (depth 20), A-tag nullifierPoseidon(3)(rho, idHash, 4243)(4243 domain-separates from B-flavor's 4242), flavor in {A1, A2}, value-conservation ghost bind.src/SpendAGroth16Verifier.sol– snarkjs-generated verifier for the A-spend circuit.src/Notes.sol– gainedspendA(...)entry point (V1; superseded byspendACPin V2).scripts/snark/prove_spend_a.js+setup.shintegration.
V2 Deliverables (shipped) – cryptographic identity gate via two cooperating components:
alberta_buck/wallet/spend_cp.py– Python prover and verifier for the CP-DLEQ; mirrors alberta-buck-proofs.org Theorem 8 #3 algebra. Tests inalberta_buck/test/test_spend_cp.pycover honest spend, identity re-issuance rejection, tampered transcript, and cross-chain/recipient replay.src/IdentityRegistry.sol– addsSpendCPProofstruct(e, s, T1, T2)andverifySpendCP(spender, recipient, E_n, cpProof) -> boolexternal view that reads_E_addr[spender]and_pk[spender]from storage, runs the two algebraic checks via 2 ecMul + 2 ecAdd EIP-196 precompiles, and recomputes the Fiat-Shamir challenge over the same byte-for- byte transcript order as the Python prover. ~36K gas total.-
circuits/spend_a.circomV2 – exposes(R_n.x, R_n.y, C_n.x, C_n.y)as 4 new public inputs (9 total) and adds the Poseidon-8 binding constraintidHash ==Poseidon-8(R_n.x, R_n.y, C_n.x, C_n.y, issuerData[0..3])= so the on-chain CP-DLEQ verifier and the SNARK agree on which ciphertext is being spent.issuerData[4]is a private witness (A1: m_issuer + sigma; A2: E_iss coords mod F_R). This costs ~265 R1CS for one Poseidon-8, well within the existing pot15 ptau budget. Total ~13,300 R1CS (6152 non-linear- 7140 linear).
src/ISpendAVerifier.sol– new interface distinct fromISpendVerifierdue to the 9 public inputs (vs B-spend's 5). MethodverifySpendA(proof, noteRoot, nullifier, face, recipient, chainId, eNoteRx, eNoteRy, eNoteCx, eNoteCy).src/SpendAVerifierAdapter.sol– wrapsISpendAGroth16Verifier; decodes proof bytes; packs the 9 public signals in order before calling the underlyingverifyProof.src/Notes.sol– gainsspendACP(proof, root, nullifier, face, recipient, E_n, cpProof): callsspendAVerifier.verifySpendA(...)with E_n.R/C coords andidentityRegistry.verifySpendCP(msg.sender, recipient, E_n, cpProof). Both must succeed. Also gains storage slotidentityRegistryand governance settersetIdentityRegistryguarded byonlyGovernance, with companionIdentityRegistryUpdatedevent.spendAVerifier's type is nowISpendAVerifier(wasISpendVerifier).test/SpendAVerifier.t.sol– 26 forge tests covering happy path, double spend, A-tag nullifier non-collision with B-tag, tamper paths on every SNARK public input (including all fourE_ncoordinates), CP-DLEQ tampers (e, s, T1, T2), unregistered-spender rejection, wrong-spender rejection (passing CPProof from a different spender), and governance lockdown for bothsetSpendAVerifierandsetIdentityRegistry. Fixture generated byscripts/snark/prove_spend_a.jsagainst vectors fromalberta_buck.wallet.vectors.
Stop condition: any test that demonstrates an A-note spendable after sk_rec loss + identity
re-issuance is a hard halt. The cryptographic invariant is the whole point of Phase 8.
Phase 9: Serialized B1 Sub-Notes (spend_serialized.circom + per-parent bitmap)
Goal: amortize bearer-note issuance by anchoring N pseudorandom serials s_i under one
cm_parent leaf and spending each against a shared per-parent nullifier bitmap. The mint circuit
is unchanged – cm_parent is an ordinary Poseidon-5 output – so this phase is
spend-side-only on circuits and storage. Per-bearer-note marginal mint cost drops to
~31K/N_fam at N_batch=16, vs ~31K at the Phase 7-bis baseline.
Per alberta-buck-notes-serialized.org migration plan (Phases A-D):
Phase 9-A: Spend-serialized circuit:
circuits/spend_serialized.circom– extend single-note spend with(subRoot, i, s_i, path_i_in_subRoot)sub-block; constraint groups are the single-note spend's plus a depth-K Poseidon-2 sub-tree walk. Constraint count ~25K at K=10 (N_fam=1024), within stock 24 KB verifier.scripts/snark/setup.shphase-2 setup (re-uses the existing pot17-pot19 ptau).src/SpendSerializedGroth16Verifier.sol+ adapter route inSpendVerifierAdapter.
Phase 9-B: Contract support:
Notes.soladdsspendSerialized(proof, root, cm_parent, i, face, recipient)alongside the existingspend()and the Phase 8spendACP().- New storage
mapping(uint256 => uint256[]) bitmap(parent> packed bitmap), allocated on first write per parent; 256 bits per =uint256word. nullifiersmapping unchanged for single-note and A-flavor flows.
Phase 9-C: Wallet support:
- Mint path: add "serialize with N" option that builds
K_fam,subRoot,predicate_fam, destroysK_famafter deriving the per-recipient payloads. - Payload schema v2: extend the bearer-note blob format to include
(i, s_i, path_i_in_subRoot, subRoot)when the note is serialized. - Spend path: detect payload variant from schema version; route to the appropriate verifier.
- Wallet UX requirement:
K_famdestruction is the operational trust assumption. The wallet must present this prominently to the issuer and SHOULD attest destruction via HSM if available.
Phase 9-D: Measure + promote:
- Measure spend-serialized gas at N_fam in {16, 256, 1024}. Target: spend-tx ~390-410K (single-note baseline + log_2(N_fam) sub-tree levels at ~4K each).
- Confirm verifier bytecode size; confirm no EIP-170 breach.
- Document family-linkage disclosure prominently in wallet UX.
Stop condition: any test that demonstrates a sibling sub-note spendable without the recipient's
(i, s_i, path) triple, or a bitmap[cm_parent] bit settable by anything other than
Notes.spendSerialized with a valid Groth16 proof.
Phase 10: Live BuckKController (PID + AMM-pool oracles)
Goal: replace BuckKControllerStatic in the Buck constructor with the live BuckKController
reading commodity-basket prices and the BUCK/USDC TWAP from on-chain sources. The controller is
in-tree with mock-feed unit tests; the remaining work is wiring real oracle addresses, picking
gains, and validating against historical price paths on a forked-mainnet harness.
Oracle source: AMM pools, not Chainlink (interim):
The architecture document targets Chainlink commodity feeds; the interim Phase 10 deployment substitutes Uniswap V3 TWAP reads on USDC-quoted pools for each basket component. Rationale: Chainlink commodity feeds (XAU/USD, AGRI baskets, etc.) are limited in coverage and add an external trust dependency before BUCK has a price discovery surface of its own. AMM-pool oracles – with sufficient TWAP windows and pool depth – are self-sovereign and deployable on any chain without an off-chain oracle integration step.
Initial basket components (illustrative; final composition is governance-set):
| Component | Oracle source | Weight | Notes |
|---|---|---|---|
| XAU (gold) | Uniswap V3 PAXG/USDC TWAP | 30% | Wrapped-gold proxy |
| WTI (oil) | Uniswap V3 USOIL-token/USDC | 20% | Synthetic if no liquid wrapper |
| Wheat | Uniswap V3 wheat-token/USDC | 10% | Synthetic |
| Real estate | Tokenised REIT / USDC | 20% | RE-tokenised proxy |
| USD | 1.0 (numeraire) |
20% | Anchor to the quote currency |
Process variable: Uniswap V3 BUCK/USDC TWAP (Phase 11 dependency – pool must exist).
Deliverables:
src/BuckKController.sol– already in-tree with PID structure; complete the_getBasketCost()and_getBuckPrice()AMM-pool TWAP plumbing. UseOracleLibrary.consult(pool, twapInterval)for tick-cumulative deltas; per-pool decimal normalisation helper.script/Deploy.s.sol– swapBuckKControllerStatic->BuckKController, wire the basket-pool addresses + weights, set initial gains (Kp/Ki/Kd), setdTandtwapInterval.test/BuckKController.t.sol– mocked-pool unit tests (already passing); add fork-test (gated on RPC) that runs the controller against a few thousand blocks of historical price paths and asserts BUCK_K trajectories within a reasonable corridor.- Governance migration runbook: how to flip
Buck's controller pointer atomically.
Stop condition: a fork-test that drives BUCK_K outside the [buckKMin, buckKMax] band on
realistic historical input would indicate gain mistuning or anti-windup logic bug – halt before
mainnet promotion.
Phase 11: BUCK Liquidity Bootstrap + BUCK/USDC AMM Pool
Goal: deploy the BUCK/USDC Uniswap V3 pool that Phase 10 reads as its process variable, seed initial liquidity, and validate the closed loop BUCK price -> BUCK_K -> credit limits -> BUCK supply -> BUCK price. Without this pool the live controller has no signal.
Deliverables:
- Treasury minting path: a one-time governance authorisation to mint a seed quantity of BUCK against a treasury-owned BUCK_CREDIT (the seed is collateralised by the same insurance-NFT mechanism every other mint uses; no special path).
- Pool deployment: deploy
UniswapV3Pool BUCK/USDCat an initial fee tier (0.30% suggested for sub-Jubilee bootstrap). Add the BUCK/USDC pool address toBuckKControllerin the same governance batch as the Phase 10 controller swap. - Liquidity bootstrap: provide initial single-sided liquidity (USDC + the treasury-minted BUCK)
at a price band tied to the initial BUCK_K target. Documented in
script/BootstrapLiquidity.s.sol. - Ongoing test harness:
test/AMMPool.t.sol– fork-test that walks several mint -> AMM-swap -> spend -> AMM-swap cycles and asserts that the BUCK_K controller responds with the expected sign and magnitude. - Loop validation: run a multi-day testnet rehearsal with synthetic transaction flow before the controller takes mainnet inputs.
Stop condition: BUCK_K oscillating with growing amplitude under realistic flow indicates gains
too aggressive (Phase 10 tuning) or an oracle-update cadence too slow (dT too high) – both are
halt conditions.
Phase 12: BUCK_CREDIT Insurance Integration in Buck.mint
Goal: close the loop the architecture document describes – the BUCK_CREDIT NFT is the
insurance-backed collateral that bounds BUCK issuance; Buck.mint() deduces an instantaneous
credit limit from the holder's deposited NFTs and simultaneously pays the default-insurance
premium into the InsurancePool. Most of the mechanics already exist (current Buck.mint() reads
buckCredit.totalCurrentValue(sender) and pays _computePremium() to the pool); this phase is
integration polish, insurer onboarding, and Jubilee fund hookup.
Deliverables:
- InsurancePool contract (
src/InsurancePool.sol) – ERC-4626 vault that accumulates default-insurance premiums and pays out on documented default events. Pool shares are mutual-insurance equity; depositors absorb claim losses pro-rata. Governance-controlled claim-payout authority for Phase 12; on-chain claim adjudication is a follow-up phase. - Default detection: a periodic (or manually triggerable) view that walks accounts whose
balanceOf(sender) > storedLimit * BUCK_K– those are in default and need eitherburn()(voluntary), forced liquidation of activated BUCK_CREDIT, or an InsurancePool payout. Off-chain monitoring tooling reads the events; on-chain enforcement is governance in Phase 12 and automated in a follow-up. - BUCK_CREDIT insurer onboarding script (
script/OnboardInsurer.s.sol) – the mechanical steps to register an insurer, mint a representative BUCK_CREDIT, and validate the fullBUCK_CREDIT.mint -> client.activate -> Buck.mint -> InsurancePool.depositflow. - Jubilee fund hookup – the
BUCKdemurrage accrual already feeds a single sink whose_indexAtLastTouchis weighted-merged on disbursement; Phase 12 routes this sink to a governance-controlledJubileeFundcontract that periodically distributes accumulated fees to defaulted accounts (or burns them, governance choice). - End-to-end mint walkthrough test (
test/MintWalkthrough.t.sol) that drives the full Step 0-4 sequence from Integration: Full mint() and transfer() Walkthrough above against a forked-mainnet harness with real AMM-pool oracles (Phase 10 + 11) and a representative BUCK_CREDIT.
Stop condition: any path that allows minting BUCK above the credit limit (modulo the documented
strict-monotonic storedLimit ratchet and the BUCK_K reduction default window) is a hard halt.
The credit-limit guarantee is the system's solvency invariant.
Phase 13: Production MPC Trusted-Setup Ceremony
Goal: replace the dev-only entropy in scripts/snark/setup.sh with a production multi-party
ceremony for every shipped circuit: mint_batch_n{1,2,4,8,16,32} (Phase 7-bis), spend
(Phase 7), spend_a (Phase 8), spend_serialized (Phase 9). Each circuit's zkey is
circuit-specific; the underlying ptau is universal up to the chosen power and can be inherited
from a community ceremony (e.g., Perpetual Powers of Tau).
Deliverables:
- Ceremony plan: contributor recruitment, attestation ledger, public-coordination thread,
per-circuit
zkeycontribution sequence, archival storage for transcripts. - Import community ptau: pull pot15-pot25 from Perpetual Powers of Tau (or equivalent), verify provenance against the published attestations, mirror to project-controlled storage.
- Per-circuit phase-2 ceremony: at least 5 independent contributors per circuit, with the
final
zkeyverifiable by any third party against the contribution log. - Verifier re-deployment: regenerate
MintBatchN*Groth16Verifier.soland the spend verifiers from the productionzkey; deploy via governance in a single batched transaction (atomic across all circuits to avoid mixed-trust state). - Public attestation: publish ceremony transcripts and verification instructions alongside the deployed contract addresses.
Stop condition: any contributor unable to demonstrate independent entropy generation (e.g.,
re-using a colleague's machine, no hardware-RNG attestation) reduces the trust assumption below
the "1-of-N honest" threshold. Halt and re-do that contribution before final zkey publication.
Sequencing and Stop Conditions
The phases are strict prerequisites of one another: Phase n+1 does not begin until Phase n
has a passing CI run and a Foundry gas snapshot. Implementation STOP conditions:
- Phase 1: a Solidity
checkChaumPedersendisagreement with the Python reference vectors. Disagreement implies a bug in the spec or in one of the implementations – resolve before any contract that depends on it ships. - Phase 2-A: an
Issuer.issue(...)path that produces a credential whose sigma the wallet'srerandomize_for_registrationcannot turn into a sigma' accepted byregistration_verifyunder the issuer's PS public key. The issuance -> registration handoff is the contract this phase establishes; any drift here propagates into every later identity-bound test. - Phase 3:
BuckTransferReceipthash collisions across distinct ciphertexts (would indicate an ABI-encoding bug – canonicalisation matters). - Phase 5: any Solidity-side type that does not match the chosen SNARK verifier output (revisit the toolchain choice; do not paper over with adapter contracts).
- Phase 6: a tamper-path test that fails to revert. The adapter + Groth16 verifier must reject
every documented attack on
totalFace,cm[i], batch size, and malformed proof bytes; any false-accept is a hard stop before further Notes work. - Phase 7: any inconsistency between the mint-circuit commitment layout and the spend circuit's
opening layout (would fork the two proof systems' views of a note, silently). Both circuits
are pinned at
Poseidon(5)(flavor, v, rho, idHash, predicate)withNum2Bits(128)range bounds on everyv[i],totalFace, andface; the value-conservation constraintsum(v[i]) === totalFacenow holds in the integers (62-bit margin against field overflow atN <2^64=). Any future change to either circuit's commitment layout must be made simultaneously in both. - Phase 7-bis: an
oldRoot/newRootchain break. The contract guardoldRoot =roots[currentRootIndex]= and the SNARK derivation ofnewRootare the two halves of the chain; either side admitting a fabricatednewRootsilently forks the tree. Any per-N adapter dispatching to the wrong verifier (cms.length=NmismatchingmintVerifiers[N]) is the same class of failure. - Phase 8: any A-spend test that completes after
sk_recloss + identity re-issuance. The single-sk_depwitness invariant is the cryptographic substance of the phase; if a compiled circuit accepts unequalpk_rec_mintandpk_dep_currentthe constraint set is broken. - Phase 9: a
bitmap[cm_parent]bit settable by anything other thanNotes.spendSerializedwith a valid Groth16 proof, OR a sibling sub-note spendable without the recipient's(i, s_i, path)triple. Either is a forge-a-note-I-don't-own admission and a hard halt. - Phase 10: BUCK_K trajectory outside
[buckKMin, buckKMax]on a fork-test driven by realistic historical price paths. Indicates gain mistuning or anti-windup logic bug; halt before mainnet promotion. - Phase 11: BUCK_K oscillating with growing amplitude under realistic flow. Indicates gains
too aggressive (Phase 10) or oracle-update cadence too slow (
dTtoo high). Either is a halt condition. - Phase 12: any path that mints BUCK above the credit limit (modulo the documented
strict-monotonic
storedLimitratchet and the BUCK_K reduction default window). The credit-limit guarantee is the system's solvency invariant. - Phase 13: any contributor unable to demonstrate independent entropy generation reduces the
trust assumption below "1-of-N honest"; halt and re-do that contribution before final
zkeypublication.