
A concrete Ethereum implementation of the Alberta Buck Architecture, in six contracts:
- BUCK_CREDIT (ERC-721) – one NFT per insured asset, with deterministic depreciation and piecemeal client activation.
- IdentityRegistry – per-address Pointcheval-Sanders credentials and ElGamal-encrypted identity points, verified on-chain via the BN254 precompiles. Trust anchor for every BUCK transfer.
- BUCK (ERC-20) – identity-bound, single-slot per-account state with signed raw balances,
continuous demurrage on positive holdings, and NFT-backed credit headroom that lets the raw
balance go negative up to
creditLimit(a) = totalCurrentValue(a) * currentBuckK / 1e18.mint(N)activates NFT-backed coverage and pays the insurance pool principal out of the holder's credit (no BUCK is delivered to the holder's raw balance); the holder spends the new headroom by transferring out, driving the signed raw negative.balanceOfreportsheld + unused crediton non-Carrying accounts, so the ERC-20 view answers "what can I spend right now". - BuckBasket – USD-free basket registry and direct-mint orchestrator. Custodies a single
full-range Uniswap-V3 LP position per TOKEN/BUCK pool, mints BUCK against deposited basket
tokens at the current pool spot price, and on redemption withdraws proportionally from
overweight pools, hands the depositor the TOKEN side(s) and recycles the BUCK side "sell high /
buy low" into the most underweight pool as treasury LP. Publishes
basketValueInBuck() = sum( basketAmount_i * pool_price_in_BUCK_i )as the direct-embodiment process variable forBUCK_K. - BUCK_K – the value-stabilization factor for credit-limit computation. Three implementations
ship today:
BuckKControllerStatic(governance-set constant),BuckKControllerDirect(USD-free PID; setpoint = 1.0 BUCK by definition, process variable =BuckBasket.basketValueInBuck()), andBuckKController(PID against an external four-pool USDT/USDC basket of Uniswap-V3 TWAPs, with a BUCK/USDT TWAP as the BUCK price). The PID mechanics – priming, setpoint-shift (dS) compensation, long-gapdTMaxclamp,fundingFactor()counter-cyclical insurance view – live in a sharedBuckKControllerBasefollowing the ezpwd::pid pattern;Buck.mintandBuck.burndrivecompute(), andBuckBasketholds the privileged hook toreprime()after constituent additions. - Notes – privacy-preserving denomination on top of BUCK. Mint and spend via Groth16 SNARKs over a Tornado-style incremental Poseidon Merkle tree.
This is the first implementation, prioritizing clarity over breadth of standards. Subsequent documents will examine ERC-1155, ERC-3643, and ERC-4626 alternatives for individual layers.
Contract Architecture
The six contracts compose into a single transfer pipeline gated on identity. IdentityRegistry
is the trust anchor: mint, approve and transfer all consult it before mutating state. Every
account and contract that touches BUCK carries an identity binding – EOAs via register,
contracts via bindContract – so bilateral identity-checked transfers always have a verifiable
counterparty on both sides.

A Wallet (EOA) is the only initiator. It registers once with IdentityRegistry and then
mints, burns, transfers, deposits or spends via the contract entry points in the second
row. Every contract that moves BUCK – Buck, BuckBasket, Notes – consults
IdentityRegistry.isVerified on both counterparties before settling. BuckCredit
(ERC-721) is mutated only by insurers (createCredit, updateCredit) and by Buck itself
(activateFromBuck / onCreditMutation hook during mint and burn). BUCK_K is private
infrastructure: Buck calls compute() on every mint/burn, and BuckBasket is the
privileged caller of reprime() and the source of basketValueInBuck() that the
Direct variant integrates against. External dependencies (not shown): Uniswap V3 (LPs
custodied by BuckBasket; TWAPs read by the External variant of BUCK_K); the BN254
EVM precompiles (pairings for IdentityRegistry, modular arithmetic for the
Mint/SpendVerifier contracts); circom / snarkjs for off-chain Notes proof generation.
The flow is:
- An insurer mints a
BUCK_CREDITNFT for a client's asset, setting face value, depreciation schedule, andpremiumRate(annual basis points of activated coverage). - The client may pre-activate some or all of the credit via
BuckCredit.activateto expose the activated portion as a credit limit (without yet drawing against it). Pre-activation is optional –Buck.mintwill auto-activate (top-up only) any additional coverage it needs. - Before touching BUCK, the client
registerswithIdentityRegistry, posting a rerandomized PS credential and ElGamal-encrypted identity point. The registry verifies both via BN254 pairings (~235K gas, paid once per address). BUCK.mint(amount[, tokenIds])draws coverage cheapest-first across the client's NFTs (via the batchedBuckCredit.batchCreditInforead), auto-activates any uncovered take per NFT throughBuckCredit.activateFromBuck, debits themutual-insurance pool principal(sized at 10x the annual premium on the activated coverage) from the client's signed raw balance, and credits that principal toinsurancePool. No BUCK is delivered to the client's raw balance:signedRawBalanceOf(client) = -poolPrincipalafter a fresh mint, andbalanceOf(client) = creditLimit(client) - poolPrincipalis the headroom the client can now spend.- To send BUCK, the sender
approveswith a Chaum-Pedersen receipt that re-encrypts the sender's identity under the spender's public key.transfer/transferFromthen bilateral- identity-check both sides and emitBuckTransferReceiptcarrying only ciphertext hashes. - To denominate as private notes, the client calls
Notes.mintwith a Groth16 proof binding the depositedtotalFaceto a batch of Poseidon commitments. Holders later callNotes.spendorNotes.spendACPwith a fresh nullifier and a SNARK proving inclusion under a recent root. BuckBasketis the direct-mint orchestrator and the value anchor. Anyone holding a basket constituent (PAXG, cbBTC, XAUT, WBTC, etc., as wired by governance viaaddBasketToken) can callBuckBasket.depositTokento LP it into the corresponding TOKEN/BUCK Uniswap-V3 pool; the basket mints exactlytokenAmount * spotPriceworth of fresh BUCK against the deposit (via the privilegedBuck.mintFromBaskethook), pairs the two sides as full-range V3 liquidity, and hands the depositor an ERC-721 receipt (BuckBasketReceipt). Depositing already-minted BUCK is symmetric: the basket swaps it into the most underweight constituent's TOKEN and LPs there. OnBuckBasket.redeem, the value claim is allocated across overweight pools proportional to each pool's positive value-weight error; the basket withdraws L from each, transfers the entire TOKEN side to the depositor, burns the principal BUCK from the BUCK side, and routes the remaining "profit" BUCK into the most underweight pool's TOKEN as treasury LP – the "sell high on the way out, recycle to buy low" leg fires in the same transaction.BUCK_Kships in three forms today. The governance-set static variant is the simpler choice for early deployments where no on-chain price reference exists.BuckKControllerDirectis the USD-free embodiment: process variable =BuckBasket.basketValueInBuck(), setpoint = 1.0 BUCK by definition, error =1.0 - basketValue. The legacyBuckKControllerreads its process variable (BUCK price) from a BUCK/USDT TWAP and its setpoint (basket cost) from a four-pool USDT/USDC basket – XAUT/USDT, PAXG/USDC, cbBTC/USDC, WBTC/USDT, 25 % each – chosen for resilience against any single basis-currency or asset-token freeze.Buck.mintandBuck.burncallBuckK.compute()regardless of variant, so user activity drives and amortizes the PID; long quiet stretches are bounded bydTMax, andBuckBasketis the privileged caller ofreprime()afteraddBasketTokenso dilution discontinuities don't fire a one-cycle P/I spike.
ERC Standard Choice for BUCK_CREDIT
ERC-721 was the obvious starting point and the choice that ships. Each credit is genuinely unique
(specific asset, depreciation curve, insured value, premium schedule); ERC721Enumerable gives
BUCK.mint the tokenOfOwnerByIndex it needs to walk a holder's NFTs. ERC-3643 (T-REX) is a
plausible future move if regulatory KYC becomes a hard requirement; ERC-1155 helps only if
standardized credit classes emerge with shared depreciation parameters; ERC-4626 fits the
InsurancePool but not individual credits. None of those are revisited below – the rest of the
document specifies the shipped ERC-721 path.
BUCK_CREDIT: ERC-721 Insured Asset NFT
Each BUCK_CREDIT is an ERC721Enumerable token whose per-token CreditParams holds the insurer's
current offer and the client's activation state. Three roles touch it: the insurer mints and
updates, the client activates, BUCK reads the depreciated activated value to size mint
allocations. All monetary fields are in 6-decimal BUCK-equivalent units (USDC-compatible).
Data Model
Fields group by mutability:
- Immutable at mint:
insurer(the address allowed to update this token),assetClass,createdAt. - Insurer-mutable via
updateCredit:faceValue,depreciationFloor,depType,depRate,depStartAt,premiumRate,lastUpdated. - Client-mutable via
activate:activatedValue(capped atfaceValue),lastActivatedAt.
DepreciationType is one of NONE (land, gold), LINEAR (constant annual reduction), or
DECLINING_BALANCE (continuous-compounded percentage). Three events –
CreditCreated, CreditUpdated, CreditActivated – record every state change.
Depreciation Models
currentValue(tokenId) is a pure view that branches on depType:
NONE: returns the activated face value, unchanged.LINEAR: loss accrues atdepRatebp/year on the depreciable portion (faceValue - floor), clamped atdepreciationFloor.DECLINING_BALANCE: discrete whole-year compounding – the factor(BP - depRate)/BPis applied once per complete year via a loop capped at 100 years, then a linear interpolation bridges the trailing partial year. No transcendental approximations.
In every case the activated portion depreciates proportionally:
currentValue = depreciatedFace * activatedValue / faceValue.
| 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,725 |
| Farm equip | DECLINING_BALANCE | 1000bp | 10% | $13,815 |
| Gold in vault | NONE | – | – | $100,000 |
A residential property would typically have two BUCK_CREDITs: one for the land (NONE) and one for the structure (LINEAR or DECLINING_BALANCE with a salvage floor).
Activation = Mint, Deactivation = Burn
There is no public BuckCredit.activate() function. Activation – expanding activatedValue
and thus growing the holder's creditLimit – happens only atomically as part of
Buck.mint(N, [tids]), and deactivation happens only atomically as part of Buck.burn(N,
[tids]).
This is a deliberate consequence of the mutual-insurance pool model. Drawing take units of
coverage on an NFT generates annual_premium = take * premiumRate / BP per year. Instead of
charging that premium on a continuous schedule, the contract requires an upfront
poolPrincipal = annual_premium * POOL_ROI_INV deposit (where POOL_ROI_INV = 10 encodes the
insurer's assumed 10 % annual ROI on pool capital). At parity, the pool's investment yield on
poolPrincipal exactly funds the annual premium on take in perpetuity – so a policy is a
one-time purchase, not a recurring expense.
Therefore the surface BuckCredit exposes is restricted to (a) insurer-side creation /
re-appraisal (createCredit, updateCredit), (b) standard ERC-721 ownership operations
(transferFrom, balanceOf, ownerOf, …), and (c) two Buck-only entry points
(activateFromBuck, deactivateFromBuck) which Buck._allocateMint / _allocateBurn call as
part of their atomic sequences. Test fixtures that need to set up a holder with non-zero
creditLimit without exercising the mint() purchase path use the BuckCreditHarness
subclass's forceActivate – explicitly not present on production BuckCredit.
Buck.mint(amount[, tokenIds]) walks the holder's NFTs cheapest-first (or in caller-supplied
order), computes take_i = ceil(remaining_i * BP / (BP - rate_i * POOL_ROI_INV)) per NFT,
calls activateFromBuck(tid, holder, take_i) to grow activatedValue and mintsBacked in
lockstep, debits poolPrincipal_i = take_i - amount_i from the holder's signed raw, and
credits the same amount to insurancePool. Buck.burn(amount[, tokenIds]) is the symmetric
inverse: walks most-expensive-first, computes unwind_i, calls deactivateFromBuck to
shrink activatedValue and mintsBacked in lockstep, debits poolRefund_i from
insurancePool, and credits it to the holder's signed raw.
The post-burn solvency invariant: after release, the holder's used credit must still fit under the
shrunken creditLimit. If you owe X and try to release enough coverage to drop creditLimit
below X, the burn reverts – repay first.
totalCurrentValue(account) walks tokenOfOwnerByIndex and sums currentValue(tokenId) – the
single call Buck.creditLimit makes to size the holder's headroom (then multiplied by
currentBuckK and divided by 1e18). creditInfo(tokenId) returns
(faceValue, activatedValue, premiumRate) one NFT at a time;
batchCreditInfo(uint256[] tokenIds) returns a packed CreditSlice[] = (owner, faceValue, activatedValue, premiumRate)
for the entire list in one external call, used by Buck
_allocateMint / _allocateBurn to amortize per-NFT cross-contract dispatch overhead.
Buck Wiring
BuckCredit and Buck form a bidirectional dependency: Buck reads BuckCredit for credit
sizing, and BuckCredit notifies Buck when NFT state changes so Buck's per-block
creditLimitCache can invalidate. The cycle is broken with a one-shot
BuckCredit.setBuck(address) called post-deployment, and a tiny IBuckHook surface
(onCreditMutation(address from, address to)) that fires from BuckCredit._update (mint /
transfer / burn) plus updateCredit / activateFromBuck / deactivateFromBuck.
Until setBuck is called, the hook is silent (address(0) guard), so BuckCredit can be
deployed and exercised before Buck exists in test fixtures.
The two-contract split, rather than collapsing into a single Diamond, is forced by the
ERC-20 / ERC-721 standards: balanceOf(address) and transferFrom(address,address,uint256) share
function selectors with incompatible semantics across the two standards, and the Transfer event
has different indexed-argument counts. A Diamond can dispatch one selector per signature;
whichever loses stops being standards-compliant and silently breaks wallets, indexers,
marketplaces, and routers. See Future Directions for the modularization paths that are
viable (intra-ERC-20 facets in Buck, independent UUPS for BuckCredit).
Insurer Updates
updateCredit(tokenId, newFaceValue, newDepreciationFloor, newDepType, newDepRate,
newDepStartAt, newPremiumRate) is gated on msg.sender == credits[tokenId].insurer. If the new
face value is below the current activated amount the contract caps activatedValue downward in
the same call. assetClass and insurer never change. Reductions can push a client's
outstanding BUCK above their credit limit; default insurance (held in insurancePool) covers the
gap.
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 per address: pk[a] (G_1 identity public key), E_addr[a] (ElGamal ciphertext
of the identity point M), isVerified (true after PS+NIZK pass on-chain or after bindContract),
isCarrying (controls Buck's transfer dispatch), and isPublicIdentity (true only for contracts
bound with the public-disclosure flag). Trusted-issuer PS public keys live in
trustedIssuers[issuerAddr], populated by governance. EOAs always self-register; contracts always
bindContract.
Cryptographic Surface
Three on-chain verifications, all reducible to BN254 precompile calls:
- PS signature at registration:
e(sigma'_1, X + m * Y) = e(sigma'_2, g_2), withmreconstructed linearly via the binding NIZK in step 2. ~147K gas. - Schnorr-family NIZK binding the rerandomized PS signature to the ElGamal ciphertext. Three
response scalars, Fiat-Shamir bound to
(sigma', E_new, pk_new, registrant_address). ~30K gas on top of step 1. Total registration ~235K, paid once per address. - Chaum-Pedersen NIZK at
approve: proves the recipient's identity ciphertext re-encrypts the same M registered inE_alice. Fiat-Shamir bindsmsg.sender,spender, andchainid. ~29K gas per counterparty pair. - Chaum-Pedersen DLEQ at A-flavor spend (
verifySpendCP): proves the spender holds a singlesk_depthat opens both the note'sE_nand the registeredE_reg. ~36K gas via EIP-196.
Solidity Surface
src/IdentityRegistry.sol uses the BN254 helper for all curve math:
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 sigma_1; G1Point sigma_2; }
struct PSPubKey { G2Point X; G2Point Y; }
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; }
struct SpendCPProof { uint256 e, s; G1Point T1, T2; }
function register(address issuer, G1Point pk, ElGamalCT E,
PSSig sigma, RegistrationProof proof);
function bindContract(address target, G1Point pk, ElGamalCT E,
bool isPublicIdentity_, bool isCarrying);
function trustIssuer(address issuer, PSPubKey ipk); // governance
function verifyApprove(address sender, address spender,
ElGamalCT E_bob, CPProof pi) view returns (bool);
function verifySpendCP(address spender, address recipient,
ElGamalCT E_n, SpendCPProof cpProof) view returns (bool);
function markApproved(address spender); // Buck-only
function setBuck(address buck); // governance, one-shotA few non-obvious points:
verifyApprovereadsE_alicefrom registry storage so the caller cannot substitute a fake credential.bindContractis permissioned by deployment pattern, not governance: any caller may bind any yet-unbound deployed contract. The intended discipline is operator OPSEC – either the operator is the deployer and usesBuckAwareDeployer.deployAndBindfor atomic deploy+bind, or the operator races to bind a pre-existing contract before anyone else. Once bound, immutable.markApproved(called by Buck insideapprove) freezes the spender'sisCarryingflag the first time anyone successfully approves them, so the carrying flavor a sender consents to cannot be retroactively flipped.- Revocation does not un-register existing accounts; they were verified once and are never re-verified.
Public-Identity Contracts
DeFi infrastructure – AMM pools, vaults, oracles, the Notes pool – can't generate Chaum-Pedersen
proofs at approve time because the contract has no private key. Such contracts bind via
bindContract(target, pk, E, isPublicIdentity_=true, isCarrying=true). Counterparties of a
Public-Identity contract still call the identity-bound approve (the always-CP rule applies
regardless of spender flavor), but the receipt-fragment check on the contract side is satisfied by
the deterministic _identityHash rather than a per-counterparty CP receipt. Public-Identity
contracts forfeit content privacy: their identity M is openable on subpoena via the operator's
off-chain attestation. Encrypted-Identity contract binding (isPublicIdentity_=false) is
supported by the surface but not yet used in production.
Issuer Trust
trustedIssuers is the only governance-controlled trust anchor. Adding an 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 and never re-verified.
Epoch-based credential renewal, key revocation, and issuer rotation are described in
Identity: Epoch-Based Credential Renewal.
BUCK: ERC-20 Token
BUCK is an identity-bound ERC-20 with NFT-backed credit headroom, mutual-insurance-pool minting,
and continuous demurrage. All transfer-path state for an account fits in one storage slot, so a
transfer is one SSTORE per side. mint(N) opens enough NFT-backed insured-asset coverage to
deliver N units of spendable headroom to the holder (no BUCK is delivered to raw balance) and
simultaneously transfers a pool principal from the holder's signed raw to insurancePool,
sized so that at the insurer's assumed annual ROI the principal's investment yield exactly covers
the annual premium on the activated coverage. The holder spends the headroom by transferring
out, which drives their signed raw negative; burn(N) unwinds coverage most-expensive-first and
refunds principal back to the holder, lifting their signed raw toward zero. Every transfer
routes through the IdentityRegistry and emits BuckTransferReceipt(from, to, amount, fromHash,
toHash) – ciphertext hashes, no plaintext identity on chain.
Storage Layout
The transfer-path state for an account is one slot:
struct AccountState {
BuckQty balance; // int80 underlying; signed. Range = [-6.04e23, +6.04e23] raw
// = [-6.04e17, +6.04e17] BUCK at 6 decimals. Negative ==
// NFT-backed used credit; positive == held BUCK.
BuckSeconds buckSeconds; // uint120 underlying; demurrage IOU integral on positive holdings
uint40 timestamp; // last crystallisation (safe past year 36800)
uint16 flags; // reserved for future per-account flags
}
mapping(address => AccountState) internal _state;
BuckQty is a user-defined value type wrapping int80; constructors
toBuckQty(uint256) (bounds-checks against the positive cap) and toBuckQtySigned(int256)
(bounds-checks both endpoints) live in BuckTypes.sol so a future width change propagates to
Buck and BuckCredit in lockstep. BuckCredit always reads via asUint() (its face,
floor, and activated values are non-negative by construction); Buck reads via asInt() in
signed contexts and asUint() only after verifying non-negativity.
Outside the hot path: per-block credit-limit cache (creditLimitCache[a], creditLimitBlock[a],
two new mappings appended after basket), allowances, per-NFT outstanding allocations
(mintsBacked[tokenId]), approve-time receipt fragments. BUCK implements IERC20 and
IERC20Metadata directly – no OpenZeppelin ERC20 inheritance, so the slot layout is explicit
and the transfer paths are linear (no _update override re-entry). Decimals are 6, matching
USDC.
Signed Balances and balanceOf
signedRawBalanceOf(a) returns the raw int80 directly; rawBalanceOf(a) clamps negatives to
zero for legacy ERC-20-style readers; signedBalanceOf(a) subtracts demurrage from positive raw
(returns raw directly for negative raw, since used credit accrues no demurrage). balanceOf(a) –
the ERC-20 view – answers "what alice can spend right now":
held(a) = max(0, signedRaw(a) - feeOwing(a)) // Non-Carrying
used(a) = max(0, -signedRaw(a))
unusedCredit(a) = max(0, creditLimit(a) - used(a))
balanceOf(a) = held(a) + unusedCredit(a) // Non-Carrying
balanceOf(a) = max(0, signedRaw(a)) // Carrying
Carrying accounts (AMM pools, Notes pool, Jubilee) hold no BuckCredit NFTs, so
creditLimit == 0 and the signed-balance machinery degenerates to the prior unsigned model.
feeOwing and balanceOfFees clamp to zero on negative raw – demurrage is a per-second
property of held BUCK, not used credit.
mint(N) and burn(N)
mint(uint256 amount) and mint(uint256 amount, uint256[] calldata tokenIds) are the two
overloads. The no-arg form runs an internal cheapest-first selector (insertion sort over the
caller's NFT list – typically a handful) to minimise premium cost; the second form takes a
caller-supplied order an off-chain optimizer pre-computes against effective rate, depreciation, or
coverage midpoint.
burn(uint256 amount) mirrors the overload pair, but the default selector walks the holder's NFTs
most-expensive-first – the dearest insurance is released first, returning the largest pool
principal per BUCK burned and freeing expensive coverage capacity for re-use. The asymmetry is
not arbitrageable: per-NFT inversion is symmetric (same denom on both sides), so a mint-burn
round-trip on the same NFT restores both its mintsBacked and activatedValue exactly.
The mutual-insurance pool model: each NFT carries an annual premiumRate in basis points.
Drawing take units of coverage from one NFT generates an annual premium of
take * premiumRate / BP. The pool deposit sized to cover that premium at a 10% assumed ROI is
take * premiumRate * POOL_ROI_INV / BP where POOL_ROI_INV = 10. The insurer keeps any return
above 10% as profit. Per-NFT inversion – given a target net headroom amount and rate r:
denom = BP - r * POOL_ROI_INV (must be > 0; max effective rate ~1000bp)
take = ceil(amount * BP / denom)
principal = take - amount
The allocator (_allocateMint) walks BuckCredit.batchCreditInfo(tokenIds) once, then iterates
in memory: per-NFT netCap = avail * denom / BP where avail = faceValue - mintsBacked, and
either fully consumes the NFT (headroom contribution = netCap, principal = take - netCap) or
partially consumes it via the closed-form inversion to land exactly on remaining. For each
NFT touched, _allocateMint calls BuckCredit.activateFromBuck(tid, holder, take_i) to grow
activatedValue and mintsBacked in lockstep – the activation and the principal payment are
the same atomic operation.
Per-NFT bookkeeping: mintsBacked[tokenId] tracks the cumulative outstanding allocation against
each NFT, capped at faceValue. Under production semantics (no public
BuckCredit.activate()), the invariant mintsBacked[tid] == activatedValue[tid] holds at every
observation point – they grow together on mint and shrink together on burn. A mint-burn
round-trip on the same NFT order is rate-neutral and not arbitrageable – both sides hit the
same denom.
The shipped mint sequence:
isVerified(msg.sender)guard.buckK.compute()– advances the PID ifdThas elapsed; otherwise returns the cached value. Mint and burn together amortize PID work across user activity.- Capture
fundingFactorand – when it is non-zero – the minter's pre-activationbalanceOf. The reserve is measured before this mint activates the credit it pledges, so the freshly-activated headroom cannot satisfy its own reserve. _allocateMint(amount, tokenIds)readsBuckCredit.batchCreditInfo(tokenIds)once, walks the slice array, callsBuckCredit.activateFromBuck(tid, holder, take_i)per NFT (growsactivatedValueandmintsBackedin lockstep), returns(totalCoverage, poolPrincipal).-
Counter-cyclical funding-factor reserve, enforced now that
poolPrincipalis known:require(preActivationBalanceOf >= poolPrincipal * fundingFactor / 1e18, "BUCK: insufficient mint funding")The reserve scales with the insurance principal this mint pays into the pool – NOT the gross BUCK minted, nor the credit limit applied for. Its job is to put a gentle base-level demand under BUCK exactly when many credit-holders come online to mint (each must hold a little BUCK sized to its insurance cost), not to impede issuance – which a gate on the whole credit limit would. A zero-premium credit yields
poolPrincipal =0= ("zero-cost insurance"), so the requirement is zero and the mint is exempt (the basket / LP bootstrap mints freely). On a non-Carrying accountbalanceOf = held + unusedCredit;fundingFactoris 0 under the static controller (gate disabled), ~1.0 at parity, and rises above 1.0 when BUCK is undervalued, so it bites hardest on bootstrapping insured credit from a thin reserve while the market already signals too much BUCK. _accrueJubilee(), crystalliseinsurancePoolandmsg.sender, debitpoolPrincipalfrom the sender's signed raw (_subBalanceis signed-aware – the raw goes negative if the sender had no positive holdings), creditpoolPrincipaltoinsurancePool._totalSupplyis maintained automatically by_setBalanceSignedso that_totalSupply == sum_a max(0, signedRaw(a))holds.- Emit
Transfer(0, insurancePool, poolPrincipal),Minted(sender, totalCoverage, poolPrincipal, creditValue, buckKValue, newLimit).
End-state arithmetic for a fresh mint against a zero-balance, non-credit-using holder:
signedRawBalanceOf(holder) = -poolPrincipal (used = principal paid to pool)
creditLimit(holder) = sum take_i (= activatedValue * BUCK_K / UNIT)
balanceOf(holder) = creditLimit - used = sum take_i - sum principal_i
= sum amount_i
= amount (modulo rounding)
totalSupply += poolPrincipal (only the pool's positive contribution grows)
burn(amount[, tokenIds]) mirrors the math but with the most-expensive-first default selector:
unwind dearest NFT first, call BuckCredit.deactivateFromBuck(tid, holder, unwind_i) which
shrinks activatedValue and mintsBacked in lockstep, debit poolRefund from insurancePool,
credit poolRefund to the holder's signed raw (climbs toward zero from used credit, or further
positive from a positive balance). quoteMint and quoteBurn are read-only views that return
(totalCoverage, poolPrincipal) or (totalUnwind, poolRefund) for the same allocator math, so
an off-chain optimizer can size required limits and refunds before submitting.
The burn-side solvency check: after refund and deactivation, the holder's remaining used credit must
still fit under the shrunken creditLimit. If you owe X and try to release enough coverage to
drop creditLimit below X, the burn reverts with BUCK: post-burn credit used exceeds limit.
Repay first.
The balanceOf(sender) > amount= spendable check on transfer-out is where credit headroom
becomes a real BUCK flow: a transfer larger than held but at-or-below held + unusedCredit
drives the sender's signed raw negative, and the recipient receives freshly-issued BUCK.
_setBalanceSigned grows _totalSupply by exactly the amount of the positive contribution
created on the recipient side (offsetting nothing on the sender side, whose positive contribution
just shrank from held toward zero or further into used credit).
Identity-Bound approve()
approve(spender, amount, E_bob, pi_CP) is mandatory – the parameterless ERC-20
approve(address, uint256) reverts with BUCK: use identity-bound approve. Both sender and
spender must be isVerified. The CP proof is required regardless of the spender's identity
flavor (Public-Identity or Encrypted): the always-CP rule produces a uniform per-pair audit
record openable on subpoena, and engages the spender's BN254 identity key for forward security
under future identity rotation. Full rationale in
Why approve Is Always CP-Bound.
On success, _receiptFragments[sender][spender] = keccak256(E_bob), the standard ERC-20
allowance is written, and markApproved(spender) freezes the spender's isCarrying flag in the
registry (so the carrying flavor consented to cannot retroactively flip). Per-pair cost is ~97K
gas (29K CP verify + 46K allowance + 22K receipt SSTORE), paid once. Subsequent transfers
between the same pair add only the bilateral check and the receipt event (~5K above an
ERC-20-baseline transfer).
transfer() and transferFrom()
Both route through _identityCheckedTransfer, which enforces mutual
decryptability: every private (non-public) party must hold a per-pair CP
receipt fragment for its counterparty, established before the transfer by
the 4-arg identity-bound approve. Only public-identity contracts may
use the deterministic _identityHash fallback.
- Requires
isVerified(from)andisVerified(to). - For the
toside (_receiptFragments[from][to]): if zero, requires =isPublicIdentity(from)— reverts withBUCK: sender must identity-approve recipientotherwise. Falls back to_identityHash(to)whenfromis public. - For the
fromside (_receiptFragments[to][from]): if zero, requires =isPublicIdentity(to)— reverts withBUCK: recipient must identity-approve senderotherwise. Falls back to_identityHash(from)whentois public. - Both guards pass only when both parties are public-identity contracts. EOA-to-EOA transfers require bilateral CP-approve; EOA-to-Public and Public-to-EOA require the private party to CP-approve the public one.
After identity checks, transfer dispatch is automatic on the sender's isCarrying flag: Carrying
senders (AMM pools, the Notes pool, the Jubilee fund) take the _carryingTransfer path that
crystallises both sides through now and apportions the sender's live buckSeconds proportionally
to the value transferred; Non-Carrying senders take _nonCarryingTransfer which just shifts raw
balance after a spendable check on balanceOf(from) = balance - feeOwing. BuckTransferReceipt(from, to, amount,
fromHash, toHash) is emitted; _identityHash(a) = keccak256(abi.encode(pk, E_addr)) is
deterministic per account.
Demurrage and Jubilee
BUCK accrues continuous demurrage to fund a perpetual Jubilee. Each account's buckSeconds
field is the cumulative integral of (positive raw balance × dt) crystallised at timestamp; the
live fee owed is
feeOwing(a) = (buckSeconds + max(0, balance) * (now - timestamp)) * BASE_RATE_PER_SEC / SCALE
= 0 when signed raw <= 0 (no demurrage on used credit)
with BASE_RATE_PER_YEAR = 0.02 (2%/year) and SCALE = 1e27. Demurrage is a per-second property
of held BUCK; accounts using credit (signed raw < 0) accrue zero fee until they climb back into
positive territory. _crystallize uses max(0, signedRaw) as the rectangle height so a
negative-going transfer doesn't poison the buckSeconds integral. This makes sense; any account
extends into its credit only when a transfer has produced a positive BUCK balance somewhere else,
where it accrues demurrage.
Visible balance:
- Non-Carrying (default for EOAs):
balanceOf(a) = held + unusedCreditwhereheld = max(0, signedRaw - feeOwing)andunusedCredit = max(0, creditLimit - used). The locked fee stays inside the account's own slot; spendable on the held side shrinks over idle periods; unused credit shrinks ifBUCK_Kfalls. - Carrying (AMM pools, Notes, Jubilee, etc.):
balanceOf(a) = max(0, signedRaw). The account never loses visible balance to demurrage; instead, on every outflow the value-weighted age is folded into the recipient'sbuckSecondsso the locked fees ride with the BUCKs. Carrying accounts hold no BuckCredit NFTs and never go negative (the spendable check inside_carryingTransferrejects any transfer that would drive raw < 0).
_crystallize(a) folds the positive time rectangle into buckSeconds and bumps timestamp; it
never changes balance and tolerates negative signed raw (rectangle = 0). Carrying transfer
crystallises both sides through now, apportions liveBs * value / raw of the sender's live
integral to the recipient (where liveBs = buckSeconds + raw * (now - timestamp)), and settles
each side in one SSTORE.
The Jubilee is a normal account at address(this), treated as Carrying. On every mint or burn,
_accrueJubilee writes
_state[address(this)].balance += totalSupply * BASE_RATE_PER_SEC * elapsed / SCALE
directly into Jubilee's slot via a raw slot write – bypassing _setBalanceSigned – so
totalSupply is not mutated. Demurrage is internal redistribution from positive-balance
holders into the system-wide Jubilee, not fresh minting. The exact invariant:
sum_a max(0, signedRawBalanceOf(a)) == totalSupply + jubileeActual()
Jubilee is the system-wide fee escrow. Every BUCK has exactly one fee owner: non-Carrying accounts
lock it silently inside their raw balance (balanceOf = raw - feeOwing); Carrying accounts (Notes
pool, AMM pools, Jubilee itself) show balanceOf = raw, so their fee portion sits in Jubilee
instead. The _accrueJubilee call credits Jubilee at the full totalSupply rate, covering
Carrying holdings too. When Carrying BUCKs flow to a non-Carrying account via _carryingTransfer, the recipient absorbs
liveBs * value / raw as extra buckSeconds, where liveBs is the sender's full live integral
(crystallised history plus the current elapsed rectangle). Jubilee pre-accrued the full fee at
the totalSupply rate; it now debits in exact proportion to the BUCKs transferred, with both
sides settled through the current block. JubileeAccrued(delta, newBalance) surfaces every
accrual.
The Jubilee is the sink that funds default coverage, perpetual-ground-rent disbursement, or a periodic balance reset – all governance-driven on-chain disbursement paths land on top of this single accumulating slot.
BuckBasket: Direct-Mint Orchestrator + USD-Free Value Anchor
BuckBasket is the contract that closes the loop between BUCK and the real-world commodity tokens
it tracks. It registers a set of TOKEN/BUCK Uniswap-V3 pools (one per basket constituent),
custodies a single full-range Buck-owned LP position in each, lets anyone direct-mint BUCK by
depositing a constituent (or recycle already-minted BUCK into the most underweight constituent),
and on redemption pulls BUCK out of overweight pools while routing the surplus into the most
underweight pool as treasury LP. It is also the publisher of the USD-free process variable read
by BuckKControllerDirect.
Two Kinds of BUCK
BUCK in circulation today has two provenances, both flowing through the same ERC-20:
- BuckBasket-minted BUCK – minted by
BuckBasketvia the privilegedBuck.mintFromBasket(to, amount)hook when a depositor pledges a TOKEN. Tracked inBuckBasket.totalOutstandingBuck; burned on redemption. The activation gate is not the BuckCredit credit limit – it is the depositor's willingness to put TOKEN (or BUCK) into the basket at the current pool valuation. - Externally-supplied BUCK – created by the standard
Buck.mint(amount, tokenIds)path described in BUCK, backed byBuckCreditinsured-asset NFTs and gated byBUCK_K. These BUCK reach the TOKEN/BUCK pools indirectly via arbitrage swaps and are the lever the PID actually pulls on.
The two supply paths converge in the basket's BUCK-side balance. Price-up TOKEN moves leave
extra BUCK in the pool (TOKEN bought with BUCK by external traders); price-down moves draw BUCK
out. Combined with reinvested treasury BUCK from prior redemptions, the basket's NAV typically
exceeds totalOutstandingBuck – the spread is what compounds for depositors and underwrites the
"profit" side of the redemption split.
Constituents and Weights
Governance registers each basket constituent via addBasketToken(token, decimals,
initialPriceInBuck, weightBp, feeTier). weightBp is the declared target weight in basis
points; passing 0 defaults to an equal share (10000 / N). The call:
- Renormalizes every existing constituent's
targetWeightBpso the total across the N+1 constituents sums to 10000 bp, preserving each existing constituent's relative weight ratio. - Re-prices every existing constituent's
basketAmountat the current pool spot price so its contribution tobasketValueInBuckequals its declared weight share of 1.0 BUCK. - Finds or creates the BUCK/TOKEN V3 pool at the requested fee tier, initializes it at
initialPriceInBuck(silently no-ops if already initialized), and bumps the observation cardinality so TWAP reads are available immediately after the first swap. - Computes the full-range tick bounds for the pool's fee-tier spacing and stores the
constituent's
buckIsToken0ordering. - Calls
controller.reprime()so the controller absorbs the dilution-discontinuity inbasketValueInBuckwithout firing a one-cycle P/I spike.
A future rebalanceWeights(tokens[], weightBps[]) will let governance re-weight (or, with
weightBp = 0, remove) one or more constituents in a single call – recomputing all
basketAmount values from the current spot prices so each constituent's contribution again
matches its declared share of 1.0 BUCK.
Process Variable: basketValueInBuck()
basketValueInBuck = sum_i ( basketAmount_i * pool_price_in_BUCK_i / 1e18 )
This is the direct-embodiment "process variable" – the value of one basket bundle measured in
BUCK – and is what BuckKControllerDirect reads. TWAP-windowed reads (twapWindow seconds)
fall back to spot if the pool is too cold to satisfy the request, so the PID degrades gracefully
on a freshly-added constituent rather than freezing.
Direct-Mint: depositToken
depositToken(token, tokenAmount, maxDeviationBp) has two paths chosen on token:
- Basket TOKEN deposit: the depositor pledges a registered constituent.
BuckBasketreads the pool's spot price, mintstokenAmount * spotPriceworth of BUCK to itself viaBuck.mintFromBasket, and LPs the (TOKEN, BUCK) pair as full-range liquidity in that constituent's pool. An ERC-721 receipt (BuckBasketReceipt) records the (BUCK principal, TOKEN principal, original token, deposit time). - Already-minted BUCK deposit (
token =address(buck)=): the depositor's BUCK is pulled in, swapped on the most underweight pool for that pool's TOKEN, partner BUCK is freshly minted against the received TOKEN, and the full (TOKEN, BUCK) pair is LP'd into the underweight pool. This is the cross-pool rebalance arrow on the deposit side.
maxDeviationBp optionally guards against TWAP/spot divergence (set to 0 during bootstrap or
cold-pool deposits; non-zero rejects the deposit if |spot - TWAP| > maxDeviationBp / 10000).
Redemption: "Sell High, Recycle to Buy Low"
redeem(receiptId, redeemBp, maxDeviationBp) settles a value claim
valueClaim = redeemBuck * NAV / totalOutstandingBuck where NAV is the basket's total LP value
across all pools. The claim is split across constituents by _allocateRedemption, then settled in
seven phases:
- Validate + size the receipt; compute
redeemBuckas either the fullbuckPrincipalor a basis-point fraction. - Slippage guard on the depositor's original pool (the known gap: this guard should also fire
on every pool actually withdrawn from – tracked as
BUG #5). - Allocate
redeemValueacross pools. Each pool's "ideal" allocation isalloc_i = v_i - t_i * (NAV - redeemValue)wheret_iis the pool's natural value share at target. Heavily overweight pools contribute more than their proportional share; heavily underweight pools clamp to zero. Two passes handle the equilibrium case (proportional) and the rare "must dip into underweight" case. A small-redemption fast path takes the entire claim from the most overweight pool whenredeemValueis belowSMALL_REDEEM_BP(1 %) of that pool's value, saving the multi-pool gas in the dominant case. - Burn LP from each allocated pool. TOKEN and BUCK are held inside
BuckBasket; no transfers yet, so any aggregate BUCK shortfall (e.g. from a single pool dominating the allocation but carrying mostly TOKEN) can still reach back into the basket's TOKEN holdings. - Cover shortfall by greedy TOKEN→BUCK swaps on the pools with the most TOKEN balance left,
using exact-input swaps padded by
SHORTFALL_BUFFER_BP(2 %). Any residual gap belowMAX_ORPHAN_DUST_WEI(1 µBUCK) is tolerated; larger gaps revert with"pool depth too thin". - Pay depositor the surviving TOKEN side per pool, emit one
RedeemedFromPoolevent per touched pool (TOKEN-to-user, TOKEN-swapped, L burned). - Reinvest profit above the
MIN_REINVEST_BUCKfloor (0.001 BUCK): the BUCK side minus the principal burn is routed through_buckToLpinto the most underweight pool's TOKEN and LP'd as a treasury position. Sub-floor profit stays in the basket's BUCK balance and is swept by the next redemption (still treasury-owned, just not yet LP'd). ThenBuck.burnFromBasketburns the principal,totalOutstandingBuckdecrements, and the deposit is finalized (cleared on full redemption; both BUCK and TOKEN principals scale down proportionally on partial).
The depositor/treasury split is approximately 50/50: full-range V3 LP is roughly 50/50 by value at any price, so the TOKEN side (depositor) and BUCK side (treasury) are roughly equal. The principal burn comes out of the BUCK side, slightly favouring the depositor.
Invariant
After all deposits exit, totalOutstandingBuck = 0 and remaining LP value is entirely
treasury-owned – compounded TOKEN appreciation, accumulated AMM fees, and external-arb BUCK that
didn't get claimed. Modulo bounded MAX_ORPHAN_DUST_WEI per-redemption dust, this is the
liveness backstop the audit trail rests on.
Known Gaps
The contract carries BUG #N: tags for the redesign work still in flight:
- #5 – Slippage guard runs on the depositor's original pool, not on the pools actually withdrawn from.
- #6 –
_buckToLp/_swapTokenForBuckExactInswaps use the permissiveMIN/MAX_SQRT_RATIO ± 1price limit; should be TWAP-bounded to resist sandwich. - #7 –
_mostUnderweightPool/_mostOverweightPoolread spot prices; should use TWAP. - #8 –
_reinvestBuckLPs into a single most-underweight pool; should mirror the redemption's proportional allocation. - #9 – TOKEN deposits LP into the deposited token's own pool with no underweight-routing – asymmetric with the BUCK-deposit path.
- #10 –
Buck.mintFromBasketbypassesBuckK'sfundingFactorgate; document or fold basket-minted BUCK into the PID's accounting (see Dynamic Issuance below). - #11 –
_reinvestBuckemits no event; downstream observers can't distinguish treasury reinvestment from external arb trades. - #13 – Late-basket-life redemptions can revert
pool depth too thinwhen the basket NAV is too low to cover the remaining outstanding (genuine liquidity exhaustion, not dust); a graceful tail-redemption mode is future work.
Dynamic Issuance: Why BuckBasket Needs BUCK_K
BuckBasket mints BUCK at exactly the current pool spot price. That is value-neutral at the
moment of deposit, but it is also the cause of a structural deflationary pressure on BUCK:
- Direct-mint adds new BUCK only when a depositor wants to LP a constituent. External BUCK demand (somebody wants to buy a TOKEN and the cheapest path is BUCK -> TOKEN swap on the BuckBasket pool) does not mint new BUCK – it just rearranges existing BUCK between the pool and the swap-caller.
- The only path that produces "free" BUCK against assets outside the basket is
Buck.mint(amount, tokenIds)viaBuckCreditNFTs, gated bymaxLimit = totalCreditValue * currentBuckK / 1e18. When external arbitrageurs buy TOKEN with BUCK from the basket pools, BUCK gets scarce (price up vs. TOKEN ⇒ each TOKEN/BUCK pool quotes a higher TOKEN price in BUCK terms ⇒basketValueInBuckrises above 1.0).
Sign convention (inherited from BUCK_K):
basketValue > 1.0(BUCK undervalued / inflation: TOKEN expensive in BUCK):error < 0⇒buckKdecreases ⇒BuckCreditbacked mints shrink ⇒ BUCK supply contracts ⇒ basket value falls back toward 1.0.basketValue < 1.0(BUCK overvalued / deflation: TOKEN cheap in BUCK):error > 0⇒buckKincreases ⇒BuckCreditbacked mints expand ⇒ BUCK supply grows ⇒ basket value rises back toward 1.0.
So the closed-loop equilibrium picture is: BuckBasket establishes the TOKEN-side anchor and
publishes the deviation signal; BuckKControllerDirect integrates that deviation into a
credit-limit multiplier; Buck meters new supply against the multiplier; external arb agents
close the loop by moving BUCK between the basket pools and the wider market. Direct-mint
deposits are the value injector (asset coming in); BuckCredit backed mints under PID control
are the supply regulator (BUCK going out).
BUCK_K: Value Stabilization Controller
BUCK_K supplies the credit-limit multiplier BUCK.mint reads to size against
totalCurrentValue. The IBuckK interface is three functions – currentBuckK() external view
returns (uint256), compute() external returns (uint256), and fundingFactor() external view
returns (uint256) (counter-cyclical insurance scalar) – so BUCK is invariant to which
implementation is wired in. BUCK.mint and BUCK.burn both call compute() so user activity
drives the controller and amortises any PID work; the function is otherwise permissionless,
keepers and individual users can poke it directly during quiet stretches. Three implementations
ship today: BuckKControllerStatic (governance-set constant), BuckKControllerDirect (USD-free
PID against BuckBasket), and BuckKController (PID against an external four-pool USDT/USDC
basket of Uniswap-V3 TWAPs). The PID mechanics – prime cycle, dS correction, dT /
dTMax, anti-windup, fundingFactor() – live in a shared BuckKControllerBase abstract
contract; each concrete variant only overrides _readReferences() returns (int256 buckValue,
int256 basketValue) to wire its specific price sources.
Static Variant
BuckKControllerStatic holds one uint256 buckK (18 decimals, 1e18 = neutral). Functions:
currentBuckK(), compute() (returns the stored value, non-view to match the IBuckK signature),
setBuckK(uint256) (governance-only), transferGovernance(address). This is the simpler choice
for early deployments where no BUCK price oracle exists yet, exercising the identity, demurrage,
and Notes layers while BUCK_K is held constant.
Direct Variant (USD-Free)
BuckKControllerDirect is the embodiment used in tandem with BuckBasket. It has no external
stablecoin intermediary; BUCK measures itself against a basket of TOKEN/BUCK Uniswap-V3 pools
that BuckBasket owns and prices.
| PID Term | Source | Meaning |
|---|---|---|
| Setpoint | UNIT (= 1e18, constant by definition) |
One BUCK should buy one basket bundle |
| Process Variable | BuckBasket.basketValueInBuck() |
sum_i (basketAmount_i * pool_price_in_BUCK_i) |
| Error | buckValue - basketValue = 1.0 - basketValue |
Positive: BUCK overvalued; negative: undervalued |
| Output (BUCK_K) | 1e18 + (P*Kp + I*Ki + D*Kd)/1e18 |
Credit-limit multiplier (1.0 = neutral) |
The sign convention is identical to the External-Basket variant below (all shipped gains are positive), but the error semantics are inverted relative to a "BUCK price in USDT" reading:
basketValue > 1.0(one BUCK now buys less than one basket – BUCK undervalued / inflation):error < 0⇒BUCK_Kdecreases ⇒ credit contracts ⇒ supply shrinks ⇒ BUCK recovers value vs. the basket.basketValue < 1.0(one BUCK now buys more than one basket – BUCK overvalued / deflation):error > 0⇒BUCK_Kincreases ⇒ credit expands ⇒ supply grows ⇒ BUCK gives back value to the basket.
basket is wired one-shot from governance via setBasket(address) (immutable thereafter);
deploying a new BuckBasket requires a new controller. reprime() external is callable only
by the registered BuckBasket; BuckBasket.addBasketToken invokes it after every constituent
addition so the dilution discontinuity in basketValueInBuck – a new constituent's
basketAmount * spotPrice contribution appears in a single block – doesn't manifest as a
one-cycle P/I spike. _reprime() recaptures P against the new process state and re-derives
I so the next no-error cycle reproduces the current buckK.
Cold-pool / pre-basket behaviour: _readReferences returns (UNIT, UNIT) until setBasket
fires, so the controller returns the priming buckK and runs no PID math. Once the basket is
wired and observations exist, the loop is identical to the External-Basket variant – only the
PV source differs.
External-Basket Variant
BuckKController is the original Uniswap-V3-basket variant. It reads BUCK and a multi-token
basket from separate Uniswap-V3 pools (typically priced in USDT/USDC) and treats their
difference as the error term; it predates BuckBasket and remains in tree for deployments where
the basket-of-pools approach is preferred over (or runs in parallel with) the direct embodiment.
It follows the
ezpwd::pid
controller layout, ported to 18-decimal Solidity fixed-point.
| PID Term | Source | Meaning |
|---|---|---|
| Process Variable | BUCK/USDT Uniswap V3 TWAP | What a BUCK actually trades for |
| Setpoint | Commodity-basket TWAP sum | What a BUCK should be worth |
| Error | Process - Setpoint | Positive: BUCK overvalued; negative: undervalued |
| Output (BUCK_K) | 1e18 + (P*Kp + I*Ki + D*Kd)/1e18 |
Credit-limit multiplier (1.0 = neutral) |
Sign convention (all shipped gains are positive):
- BUCK below basket (inflation):
error < 0->BUCK_Kdecreases. Credit contracts; holders running near their limit must burn BUCK to stay compliant; supply shrinks; BUCK price recovers toward basket from below. - BUCK above basket (deflation):
error > 0->BUCK_Kincreases. Credit expands; holders can mint additional BUCK against their NFTs and sell it into the pool at the premium; supply grows; BUCK price recovers toward basket from above.
Two canary tests in test/BuckKController.t.sol guard this direction
(test_sign_convention_inflation_contracts_credit and
test_sign_convention_deflation_expands_credit); any sign flip in the
controller surface fails them within one PID cycle. See
alberta-buck-ethereum-example.org for the bootstrap, equilibrium, and
arbitrage examples that exercise the loop end-to-end.
External Basket and BUCK Price Oracles
The External-Basket controller reads basket components from two parallel sources, both summed
into the basket cost: a Chainlink-feed array (BasketComponent { feed, weight, feedDecimals },
used in tests against mock aggregators) and a Uniswap-V3-pool array (BasketPool { pool,
baseToken, quoteToken, weight, baseDecimals, quoteDecimals, twapInterval }). addBasketComponent
and addBasketPool are governance-only. The shipped target basket is V3-pool-sourced:
| Pool | Base | Quote | Weight |
|---|---|---|---|
| XAUT / USDT | XAUT (6d) | USDT | 25 % |
| PAXG / USDC | PAXG (18d) | USDC | 25 % |
| cbBTC / USDC | cbBTC (8d) | USDC | 25 % |
| WBTC / USDT | WBTC (8d) | USDT | 25 % |
Two gold tokens and two BTC tokens, paired against two stablecoins, gives the basket resilience
against any single asset-token or basis-currency freeze. setBuckPriceOracle(pool, buckToken,
quoteToken, quoteDecimals, twapInterval) configures the BUCK price oracle (typically BUCK/USDT).
Pool reads route through UniswapV3OracleLib (src/lib/) – a 0.8-compatible port of the
OracleLibrary /=TickMath= /=FullMath= helpers we need. The vendored lib/v3-periphery
OracleLibrary is pinned to <0.8.0 and can't be imported directly under 0.8.20+. When a pool's
twapInterval > 0 the controller calls consult(pool, secondsAgo) for the time-weighted mean
tick; when zero it reads slot0().tick directly (test-only).
Priming and dS Compensation (shared with the Direct Variant)
The first compute() after deployment captures references and returns the cached buckK
without running PID math:
prime cycle: P = current_error; lastBasketCost = current_basket; primed = true;
return buckK
This avoids the spurious first-cycle derivative spike that arose from P_prev = 0 (default
storage) combined with a non-zero initial error. All subsequent cycles compute
dS = basketCost - lastBasketCost
D = (error - P_prev - dS) * UNIT / dt
where dS captures setpoint movement (gold spike, governance basket re-weight, oracle update).
Subtracting dS from (error - P_prev) strips out target movement so derivative reflects only
BUCK's drift relative to its (moving) target. Without dS, a basket-side jump would otherwise
fire Kd * basketJump / dt as if BUCK had moved, slamming BUCK_K toward a rail.
dT, dTMax, and Pokability (shared with the Direct Variant)
Three time constants govern when and how aggressively the controller responds:
dT(minimum cycle interval):compute()short-circuits ifnow - lastUpdate < dTand returns cachedbuckKas a one-SLOAD operation. This is the amortisation knob – multiple mints in a singledTwindow share one PID cycle's gas cost. 60 s is reasonable for a chain with ~12 s blocks (one PID cycle per ~5 blocks).dTMax(long-gap clamp): whennow - lastUpdate > dTMax, the cycle integrates as if onlydTMaxseconds had passed. ReallastUpdatestill advances tonow, so subsequent cycles measure forward correctly. Defaulttype(uint256).max(no clamp); governance opts in viasetDTMax. Typical value 1 h: a 24-h silence then integrates as one 1-h step rather than slamming the integral with 24 h of accumulated error.- Pokability:
compute()is permissionless and side-effect-only-when-due. In normal operationBuck.mint/Buck.burndrive it; in long quiet stretches a keeper can poke it directly to keep the integral fresh, or rely ondTMaxto bound the eventual catch-up step.
The TWAP window itself (currently 600 s on every pool) is the manipulation-resistance knob. A flash-loan-shape attack that drives BUCK to $0.50 in one block and unwinds the next leaves spot collapsed but TWAP unmoved -- the test suite verifies that during such an attack the TWAP read sits at ~$1.000002 while spot reads $0.500.
Future: Amortizing PID Work via a State Machine
Today's compute() reads all five pools and runs the full P/I/D math in a single call. At
~5 V3 pool reads + a few mults this is cheap (~300-500 K gas including a state write). If a
richer PID grew expensive enough that mint-time amortization became painful, the cycle work
could be split into a state machine over multiple calls – each transfer advancing one micro-step
of (read pool i, accumulate, finalize). The tradeoff is that pool reads stretch over real time
and so the basket sample becomes incoherent unless every input is itself TWAP'd over a window
covering the state-machine duration; today's full-cycle compute wins for that reason. Revisit
when oracle reads grow or the basket expands materially.
Tuning (shared with the Direct Variant)
| Gain | Effect of increasing | Risk |
|---|---|---|
| Kp | Faster response to deviations | Oscillation, over-correction |
| Ki | Eliminates steady-state error | Integral windup, slow recovery |
| Kd | Damps oscillation, anticipates trends | Noise amplification |
Initial gains should be conservative (high Kd relative to Kp, low Ki). dT acts as a coarse
damper, dTMax as a backstop. Priming and dS compensation make non-zero Kd safe at small
dT: pre-priming, every fresh deploy with Kd > 0 had a permanent derivative contribution
because P_prev was stuck at 0 – post-priming, P_prev captures the actual initial error and
the derivative dies off as soon as the system reaches a new equilibrium.
A future enhancement is on-chain gain scheduling: maintain a rolling variance of the error signal
and rotate between conservative and responsive gain sets at regime boundaries. This maps to the
ezpwd::pid::reset() hot-swap pattern in the C++ reference.
Notes: Privacy-Preserving Denomination
BUCK transfers carry a public (from, to, amount) quartet. Notes lift values, flavours, and
identity bindings off the public ledger by escrowing BUCK in a commitment pool: holders post
Poseidon-hash commitments via a Groth16 mint, then redeem any commitment later via a second
Groth16 spend with a fresh nullifier. The contract's public state is just a list of opaque 32-byte
commitments, a 30-slot recent-roots ring buffer, a nullifier set, and a running face-value total.
Full design in Notes and Proofs; this section documents the Ethereum surface only.
Mint Circuit (Batch, In-Circuit Merkle Insertion)
circuits/mint_batch.circom is a parameterised batch-mint circuit. Each pinned size N
(specialised at N ∈ {1, 2, 4, 8, 16, 32}, with the smaller sizes useful for unit tests and small
wallet flows) produces its own MintBatchN*Groth16Verifier.sol. The shipped Notes contract holds a single
mintVerifier adapter; per-N dispatch lives inside the adapter (a single IMintVerifier chooses
the right pinned verifier by cms.length). Public-signals arity is 5 regardless of N:
| Signal | Description |
|---|---|
oldRoot |
Live note Merkle root the prover read from chain |
newRoot |
Root after folding cm[0..N-1] starting at nextLeafIndex |
nextLeafIndex |
Live tree size the prover read from chain |
totalFace |
Sum of note values, range-bounded to 128 bits |
cmBatchHash |
keccak256(abi.encodePacked(cm[0..N-1])) – ties calldata to the SNARK |
Per leaf, the circuit enforces a Poseidon-5 commitment opening
cm_i === Poseidon(flavor_i, v_i, rho_i, idHash_i, predicate_i), Num2Bits(128) range bounds on
v[i] and totalFace (closing field-overflow attacks), one sum check totalFace === sum v[i],
and an N x TREE_DEPTH Poseidon-2 dual Merkle walk: priorWalk consumes ZERO_VALUE at the
insertion slot to attest oldRoot, postWalk consumes cm[i] to advance rollingRoot to
newRoot. Both walks share prover-supplied siblings. A keccak hash check binds cmBatchHash
to the per-leaf commitments. ~6K R1CS per leaf (~1.7K opening + ~4.3K insertion).
Verifier bytecode sizes: 14.5 KB at N=16, 20.8 KB at N=32 – both under the EIP-170 24 KB ceiling. Per-mint gas: 461K at N=1 (constant ~216K Groth16 verifier dominates), 624K at N=16 (~39K/leaf – the shipped sweet-spot), 1.12M at N=32. Asymptotic per-leaf cost is ~31K. Promotion to N >= 64 needs one of the three escape hatches in alberta-buck-notes-rollup-mint.org (calldata-IC hash-pinned, sharded SSTORE2, plain SLOAD), with calldata-IC the natural fit for the Holochain wallet.
The trusted setup in scripts/snark/setup.sh uses a fixed dev-only entropy string. Production
would run a multi-party ceremony per pinned N; the script is the scaffolding.
Spend Circuits (B-flavor, A-flavor V2)
circuits/spend.circom proves redemption of a single B-shape note under an on-chain Merkle root.
Public inputs [noteRoot, nullifier, face, recipient, chainId]; private witness
(flavor, v, rho, idHash, predicate, pathElements[20], pathIndices[20]). Constraint groups:
Num2Bits(128) on face and v, cm === Poseidon-5(flavor, v, rho, idHash, predicate),
Tornado-style depth-20 Merkle inclusion under noteRoot, B-tag nullifier
Poseidon-3(rho, idHash, 4242), and value conservation face * 1 === v * 1 with a quadratic
anchor. ~12,000 R1CS. SpendGroth16Verifier takes the 5-element public-signals tuple;
SpendVerifierAdapter presents the narrow ISpendVerifier.
circuits/spend_a.circom V2 is the A-flavor (identity-bound) sibling. Public inputs grow to
[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 nullifier Poseidon-3(rho, idHash, 4243), a flavor-in-{1,2} gate, and an
in-circuit Poseidon-8 binding gate Poseidon-8(E_n, issuerData) === idHash (~265 R1CS) that pins
the publicly-revealed E_n to the leaf at mint time. Total ~13,300 R1CS.
The cryptographic identity gate – proving the spender holds a single sk_dep that opens both
E_n and the registered E_reg – lives in IdentityRegistry.verifySpendCP rather than the
circuit. In-circuit BN254 G1 arithmetic over the BN254 scalar field would have cost +250K R1CS
per group operation; off-chain CP-DLEQ verified via EIP-196 precompiles costs ~36K gas instead.
Notes.spendACP calls the Groth16 verifier and verifySpendCP atomically; both must succeed.
The DLEQ is a 4-element Sigma proof:
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_dep. Proof:(e, s, T1, T2)– 4 field elements, ~36K gas to verify on-chain.
The invariant the two layers together enforce: A-note loss-recovery via identity re-issuance is
impossible by design. sk_rec must be backed up like a hardware-wallet seed; loss is terminal.
Companion documents Notes and
Notes Flow document the wallet-side implications.
Verifier Interfaces
Notes is decoupled from the SNARK toolchain via narrow interfaces:
interface IMintVerifier {
function verifyMint(bytes proof, uint256 oldRoot, uint256 newRoot,
uint256 nextLeafIndex, uint256 totalFace,
uint256[] calldata cms) external view returns (bool);
}
interface ISpendVerifier {
function verifySpend(bytes proof, uint256 root, uint256 nullifier,
uint256 face, address recipient, uint256 chainId)
external view returns (bool);
}
interface ISpendAVerifier {
function verifySpendA(bytes proof, uint256 root, uint256 nullifier,
uint256 face, address recipient, uint256 chainId,
uint256 enRx, uint256 enRy, uint256 enCx, uint256 enCy)
external view returns (bool);
}
Stub implementations (StubMintVerifier / StubSpendVerifier) ship for unit tests that exercise
state transitions unrelated to the SNARK. The production Adapter contracts decode the proof as
(uint256[2], uint256[2][2], uint256[2]), build the public-signals tuple, and forward to the
snarkjs-generated Groth16 verifier. mintVerifier is a singleton IMintVerifier adapter that internally dispatches by cms.length to
the correct pinned MintBatchN*Groth16Verifier; spend verifiers are likewise singletons. Swapping
any of them is a single governance call.
Notes Contract
src/Notes.sol is constructed with (buck, mintVerifier, spendVerifier, governance). The
A-flavor spend verifier and the identity registry are wired post-construction via governance
setters (setSpendAVerifier, setIdentityRegistry); until both are set, spendACP reverts with
Notes: A-spend disabled or Notes: identity registry not set. Its state is intentionally lean
– per-leaf insertion math now lives in the SNARK:
- Verifier handles: singleton
mintVerifier(the adapter that dispatches by batch size), singletonsspendVerifierfor B-spend andspendAVerifierfor A-spend, plus theidentityRegistryused byspendACPforverifySpendCP. All swappable by governance. - Audit / double-spend state:
mapping(uint256 => bool) nullifiers,uint256 noteFaceSum. - Tree state:
uint32 nextLeafIndexanduint256[ROOT_HISTORY_SIZE=30] rootswithuint8 currentRootIndex.filledSubtrees,zeros[], and per-cm uniqueness checks are gone – duplicate openings are economically self-punishing via the deterministic nullifier (one spend nullifies every copy of the same opening; the duplicator forfeits their mint).
mint(proof, oldRoot, newRoot, nextLeafIndex, totalFace, cms) asserts cms.length > 0, checks
oldRoot == roots[currentRootIndex] and nextLeafIndex == self.nextLeafIndex (stale-state
guards), field-bounds each cms[i] against FIELD_R, dispatches to mintVerifier (which forwards
to the per-N pinned Groth16 verifier internally), 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 Minted(issuer, totalFace, startIndex, count, newRoot).
Per-leaf Appended events are gone; the commitments live in calldata for off-chain replayers.
spend(proof, root, nullifier, face, recipient) (B-flavor) requires recipient != 0,
face > 0, isAcceptedRoot(root) (root in the recent-roots window), and !nullifiers[nullifier].
Forwards to spendVerifier, marks the nullifier burned, decrements noteFaceSum, and calls
buck.transfer(recipient, face). Because the Notes pool is bound under the Carrying flag,
buck.transfer dispatches to the carrying path automatically – the recipient absorbs the pool's
average demurrage age via buckSeconds, preserving the SNARK's face === v invariant at the
ERC-20 boundary. Spent(nullifier, face, recipient) closes the spend.
spendACP(proof, root, nullifier, face, recipient, E_n, cpProof) (A-flavor) is identical except
it also calls identityRegistry.verifySpendCP(msg.sender, recipient, E_n, cpProof) atomically;
both verifications must succeed.
End-to-End Walkthrough
Alice owns a $500K Calgary home (land $200K, structure $300K). Two insurers (LandCo, HomeCo) have
issued BUCK_CREDIT NFTs; HomeCo's structure credit depreciates LINEAR at 200bp/yr. After 3
years and partial activation, Alice's collateral and effective coverage:
| Token | Insurer | Face Value | Depreciation | Activated | Current Value | Premium Rate |
|---|---|---|---|---|---|---|
| #42 | LandCo | 200,000 | NONE | 200,000 | 200,000 | 50bp |
| #43 | HomeCo | 300,000 | LINEAR, 200bp, 20% fl | 150,000 | 141,000 | 200bp |
| Total | 341,000 |
Step 0: Identity Registration (One-Time)
Alice obtains a PS-signed credential off-chain from a trusted issuer (the Alberta government, ATB
Financial, etc.), rerandomizes it, generates a fresh ElGamal key pair, encrypts her identity point
M = m·G under her public key, and produces the Schnorr-family NIZK binding the rerandomized
sigma to the ciphertext. IdentityRegistry.register(issuer, pk, E, sigma', proof) verifies both
via BN254 precompiles in about 235K gas. After this call isVerified[alice] = true and Alice can
touch BUCK. She repeats steps 2-3 for every Ethereum account she controls; the issuer never sees
those accounts.
Step 1: Mint
Alice calls BUCK.mint(50_000e6) – the no-arg form, so the built-in selector sorts her NFTs by
premiumRate ascending: LandCo (50bp) first, then HomeCo (200bp). At BUCK_K = 1e18:
totalCurrentValue = 341,000e6,maxLimit = 341,000e6. Alice's existing balance is 280,000e6, so the early gate (280k + 50k <= 341k) passes.- The allocator walks NFT #42:
denom = 10000 - 50*10 = 9500,avail = 200,000e6 - mintsBacked[#42]. Suppose this is Alice's first mint;avail = 200,000e6,netCap = 190,000e6.netCap >= 50,000e6, so the partial-fill closed form fires:take = ceil(50_000e6 * 10000 / 9500) = 52,631,579,principal = take - 50_000e6 = 2,631,579.mintsBacked[#42] +52,631,579=. - Tight gate:
280,000e6 + 52,631,579 <= 341,000e6– passes. - Alice receives exactly 50,000e6.
insurancePoolreceives 2,631,579 (the 10x principal whose 10% ROI funds the 0.50% annual premium on the activated coverage). Minted(alice, 52,631,579, 2,631,579, 341,000e6, 1e18, 341,000e6), plus standardTransferevents.
Step 2: Approve Bob (Identity Handshake)
Alice's wallet reads Bob's pk_bob from IdentityRegistry, decrypts E_alice (it knows
sk_alice) to recover M, and re-encrypts under pk_bob with fresh randomness as
E_bob = (r_b·G, r_b·pk_bob + M). It produces a Chaum-Pedersen NIZK
pi_CP = (e, s_1, s_2) proving E_bob encrypts the same M registered in E_alice, with
Fiat-Shamir bound to msg.sender = alice, spender = bob, chainid.
BUCK.approve(bob, 10_000e6, E_bob, pi_CP) verifies both parties are isVerified, runs
IdentityRegistry.verifyApprove (29K gas), stores ~_receiptFragments[alice][bob] =
keccak256(E_bob), calls markApproved(bob) to freeze his isCarrying flag, and writes the
ERC-20 allowance.
Off-chain (e.g. via Holochain), Alice delivers the matching identity_data and E_bob
ciphertext to Bob's wallet. Bob decrypts to recover M' and asserts H(identity_data) * G ===
M' – the two-channel binding from the Identity document.
Step 3: Bob Pulls the Transfer
BUCK.transferFrom(alice, bob, 10_000e6) debits the allowance, runs _identityCheckedTransfer
(both parties verified, _receiptFragments[alice][bob] ! 0=), and dispatches to the
Non-Carrying path (Alice is not Carrying). Tokens move; BuckTransferReceipt(alice, bob,
10_000e6, fromHash, toHash) is emitted. An auditor with subpoena power over either Alice or
Bob can recover the underlying identities; an outside observer learns only that addresses Alice
and Bob exchanged 10,000e6.
Step 4: Alice Denominates as Notes
Alice picks flavour A2 and values [100e6, 25e6] (totalFace 125e6). Her wallet pads the live
pair with 14 dummies (v=0) so the batch lands on the smallest pinned circuit (N=16),
snapshots (oldRoot, nextLeafIndex) from chain, derives the depth-20 sibling paths in
submission order, and runs snarkjs groth16 fullProve against
build/snark/mint_batch_n16/mint.wasm and mint_final.zkey. The proof triple is abi-encoded
into proofBytes.
On-chain:
BUCK.approve(notes, 125e6, E_notes, pi_CP_notes)– the always-CP rule applies even though Notes is bound under a Public Identity. The receipt fragment is openable on subpoena.Notes.mint(proofBytes, oldRoot, newRoot, nextLeafIndex, 125e6, [cm[0]..cm[15]])– the adapter dispatches internally bycms.length=16to its pinnedMintBatch16Groth16 verifier. On success, 125e6 BUCK moves from Alice to Notes,roots[]advances to the SNARK-attestednewRoot,nextLeafIndexadvances by 16,noteFaceSum +125e6=. Alice keeps the live openings privately; the dummies are discarded.
Step 5: Spend a Note
Later, the holder of one of Alice's notes (Alice herself, or a bearer she off-chain handed the
opening to) redeems cm[0] for 100 BUCK to recipient:
- The wallet reconstructs
(pathElements[20], pathIndices[20])forcm[0]by replaying theMintedevent log and pullingcms[]from each batch's calldata. snarkjs groth16 fullProveagainstspend.wasm/spend_final.zkeyproduces a proof binding[currentRoot, Poseidon-3(rho, idHash, 4242), 100e6, recipient, 1]to the witness.Notes.spend(proofBytes, currentRoot, nullifier, 100e6, recipient)– the contract checks the root window, rejects a previously-seen nullifier, runs the adapter (~360K gas total), marks the nullifier burned, decrementsnoteFaceSum, and callsbuck.transfer(recipient, 100e6). Notes is bound Carrying, so the transfer dispatches to the carrying path automatically: the recipient receives the full 100e6 and absorbs an amount-weighted share of the pool's average demurrage age viabuckSeconds.
If HomeCo later reappraises the structure at $280K, Alice's effective collateral drops correspondingly. If her outstanding BUCK exceeds the new limit, the InsurancePool covers the gap on documented default events; the Jubilee accumulator is the long-horizon backstop.
Gas Cost Snapshot
Approximate gas costs from the current Foundry suite (Solidity 0.8.31, optimizer on). PID and oracle figures are estimates against unwritten oracle wiring.
| Operation | Gas (approx) | At 30 gwei | Notes |
|---|---|---|---|
| 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 |
| IdentityRegistry.verifySpendCP | ~36K | ~$3 | CP-DLEQ via EIP-196 (A-flavor spend) |
| BUCK.approve() (identity) | ~97K | ~$8 | 29K CP + 46K allowance + 22K SSTORE |
| BUCK.transfer / transferFrom | ~80K | ~$6 | Identity check + receipt + 1 SSTORE/side |
| BUCK.mint() (static BUCK_K, 1 NFT) | ~150K | ~$12 | NFT walk + allocator + Jubilee accrual |
| BUCK.mint() (static BUCK_K, 2-NFT spillover) | ~210K | ~$17 | + second NFT crystallise |
| BUCK.burn() (1 NFT) | ~120K | ~$10 | Mirror of mint, no limit ratchet |
| BUCK.mint() (with PID update, 4-pool basket) | ~450K | ~$36 | + 4 V3 TWAP reads + BUCK TWAP + PID math |
| BUCK.mint() (PID cached, within dT) | ~155K | ~$12 | one SLOAD on the cache path |
| Notes.mint (mint_batch, N=1) | ~461K | ~$37 | Groth16 verify (~216K) + insert |
| Notes.mint (mint_batch, N=16) | ~624K | ~$50 | ~39K/leaf – shipped sweet-spot |
| Notes.mint (mint_batch, N=32) | ~1.12M | ~$90 | ~35K/leaf (verifier ~422K) |
| Notes.spend (Groth16, B-shape) | ~360K | ~$29 | Groth16 verify + Carrying transfer |
| Notes.spendACP (Groth16 + CP-DLEQ, A-shape) | ~410K | ~$33 | + verifySpendCP (~36K) via EIP-196 |
| BUCK_CREDIT.currentValue / BUCK_K.currentBuckK | 0 | Free | View |
The PID update cost is amortised across all mints within a dT window (60 s default; only the
first mint after each window pays the oracle-read + PID-math cost; every other mint sees the
cached one-SLOAD path). Buck.burn calls compute() too, so burns share the amortisation. The
same contracts deploy on EVM L2s (Arbitrum, Optimism, Base) where gas is 10-100x cheaper.
Security Considerations
Oracle Manipulation
BUCK/USDT and basket pool prices use Uniswap V3 TWAPs (600 s shipped, governance-tunable per
pool), so manipulation requires sustained capital commitment over the full window proportional to
pool depth. The BuckKControllerV3TwapTest suite verifies that a single-block flash-shape attack
driving BUCK to $0.50 leaves spot collapsed but TWAP unchanged at ~$1.000002, with the PID
computing off the unchanged TWAP. When/if Chainlink basket feeds are adopted later, the
Chainlink branch of _getBasketCost must verify updatedAt is within an acceptable window
(~1 hour) and revert if stale.
Credit Enumeration DoS
A malicious user could mint many tiny BUCK_CREDITs to make totalCurrentValue and the
cheapest-first allocator gas-prohibitive. Mitigations: minimum face value at credit creation, a
cap on credits per account (e.g. 20), or off-chain aggregation with Merkle proof. None are wired
in yet – the relevant test suites cap NFT counts at small N.
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. The InsurancePool covers default events;
governance can blacklist abusive insurers; multi-insurer market discipline is the natural check.
Time-locks on insurer updates (e.g. 7-day delay for reductions > 20%) are a future enhancement.
Reentrancy
All state changes occur before external calls in BUCK.mint, BUCK.burn, and Notes.mint /
spend / spendACP. Identity verification is the first guard in every transfer; mintsBacked is
written before the user-facing _mint. BuckCredit.activate is a leaf operation. No
ReentrancyGuard is currently wired – the design relies on checks-effects-interactions
discipline; adding a guard before mainnet is a one-line change if review insists.
Pool Solvency
insurancePool holds the cumulative pool principal for every outstanding mint. Burn requires
the pool's spendable balance to cover the proportional refund; if the pool is drained out-of-band
the burn reverts. Today no path drains the pool: it is not a verified identity (so no
identity-checked transfer can move BUCK out of it) and holds no NFTs (so it cannot self-burn via
the allocator). Adding governance-controlled disbursement requires explicit accounting of how
the pool's invariant (poolBalance >= sum of all outstanding mint principals) is preserved.
Future Directions
This implementation prioritises clarity and correctness; alternatives the architecture document flags for later study:
- ERC-1155 batch credits if standardised credit classes emerge (e.g. "Calgary residential,
HomeCo, LINEAR 200bp"), enabling
safeBatchTransferFromfor portfolio management and reduced gas for accounts holding many same-class credits. Per-token depreciation start dates partially negate the fungibility benefit, so applicability depends on the secondary market. - ERC-3643 (T-REX) if regulatory KYC compliance becomes a deployment requirement. Native ONCHAINID, programmable transfer restrictions, agent-role granularity, and lost-key recovery via identity verification. This is the likely production standard if Alberta deploys BUCK under provincial regulation.
- ERC-4626 InsurancePool – a vault-shaped wrapping of the existing
insurancePooladdress, so pool deposits become standardised mutual-insurance shares withdeposit/shares/ yield composability. Mechanical layer on top of the current single-address pool. - L2 deployment. The contracts are EVM-compatible and deploy on Arbitrum, Optimism, Base, or Polygon with no changes. Cross-chain BUCK via canonical bridges or LayerZero/Axelar would enable multi-chain liquidity while keeping a single BUCK_K oracle on the settlement layer.
- Coverage-ratio premium. The current per-NFT
premiumRateis flat. A loss-layer-style effective-rate derivation –rate(V, A, D) = lambda * coverage * (1 - D/V)^2 / 2under uniform loss – would price closer-to-full-coverage activations more aggressively, which matches actuarial intuition for parametric insurance with deductibles. The allocator's sort key would move from rawpremiumRateto a midpoint-evaluated effective rate; everything else carries through. - Recursive SNARKs above N=512. Mint batches above N=64 hit the EIP-170 24 KB bytecode ceiling on stock Groth16 verifiers. Calldata-IC delivery (hash-pinned) buys headroom to N=128 or 256; Nova / Halo2 / Plonky3 recursion is the natural fix above N=512.
- Buck as a Diamond (EIP-2535).
Bucktoday is a single monolith covering identity, demurrage, NFT-credit accounting, ERC-20 wire-protocol, BuckBasket direct-mint hooks, and Jubilee bookkeeping. Splitting these into facets (identity facet, demurrage facet, mint-burn facet, ERC-20 facet) is the natural modularization path: facets shareBuck's storage layout (AccountState,_totalSupply,mintsBacked,creditLimitCache), function selectors don't collide within a single ERC standard, and per-facet upgradability lets bug fixes ship without redeploying the whole stack. Cross-contract calls intoBuckCreditandBuckKControllerstay external – the ERC-20 / ERC-721 selector collision detailed in BUCK_CREDIT forcesBuckCreditto remain a sibling. - Independently upgradeable
BuckCredit(UUPS or transparent proxy). KeepBuck'simmutable buckCreditaddress stable while patchingBuckCredit's logic in place. Combined with the Diamond split above, this lets the system evolve facet-by-facet and contract-by-contract without the Big Bang redeploy that an immutable monolith requires.
Status
Current Foundry suite: 24 test files cover all six contracts in tree (BuckCredit,
IdentityRegistry, Buck, BuckBasket, BuckKControllerStatic + BuckKControllerDirect +
BuckKController, Notes) plus dedicated suites for the Phase 1b NFT-credit machinery
(BuckCreditLimit.t.sol, BuckSignedBalance.t.sol), the SNARK toolchain (mint_batch.circom at
pinned N ∈ {1, 2, 4, 8, 16, 32}, spend.circom, spend_a.circom V2), and a Python reference
wallet (alberta_buck.wallet) that emits canonical test vectors the Solidity tests load via
vm.readFile. One BuckKControllerForkTest requires a live mainnet RPC and is skipped offline.
The BUCK_K and BuckBasket test suites cover:
BuckKControllerBaseTest– abstract-base harness exercising the shared PID mechanics (priming,dScompensation,dT,dTMax, anti-windup, integral convergence,fundingFactor) against a synthetic_readReferencesoverride.BuckKControllerStaticTest– governance-set constant,setBuckKguards,fundingFactoralways-UNIT.BuckKControllerDirectTest– the USD-free direct embodiment wired to a mock basket source;reprimeaccess control, dilution-discontinuity absorption, sign-convention canaries on thebasketValue > 1.0and< 1.0branches.BuckKControllerUnitTest– harness with mocked Chainlink feeds, mocked BUCK price. Includes priming,dScompensation,dTMaxclamp, anti-windup, and multi-step integral convergence.BuckKControllerV3Test– locally deployed Uniswap V3 factory, all four basket pools at reference prices, BUCK/USDT pool with a 1e17 full-range LP (~100 K BUCK / 100 K USDT, matching the design narrative). Spot-price reads, swap-driven price drift, and PID response.BuckKControllerV3TwapTest– same V3 setup withtwapInterval = 600 son every pool, plus a warmup loop that walks forward writing observations on each pool. Verifies the TWAP path smooths a flash-shape spike (spot $0.50, TWAP $1.000002) and tracks sustained drift over the 600-s window (TWAP converges from $1.00 → $0.97 → $0.95 over a full window at $0.95 spot).BuckBasketTest– direct-mint deposit/redeem flows,addBasketTokenrenormalization, proportional-allocation correctness across overweight/underweight pools, "sell high / buy low" redemption invariants, shortfall-cover greedy swap, and thereprimecallback intoBuckKControllerDirect.BuckEquilibriumScenarioTest– multi-month closed-loop scenario; ticksBuckBasket.depositToken/redeemandBuck.mint/burnacross simulated price drift to exercisefundingFactorand thebuckKresponse in concert.BuckKArbScenarioTest– driven-arbitrageur scenario (BuckKArbScenarioTest, 2-year horizon) that exercises the External-Basket PID against an Alice agent who trades around the PID equilibrium; emitsarb-scenario.jsonfor the Python visualizer.
The Python simulation harness in alberta_buck/sim/ externally drives an anvil instance with
the deployed contracts (see make sim* targets and the project memo on the externally-driven
sim) to replay multi-agent scenarios – direct_mint.py and scenario.py for the basket-side
flows, agents.py for arb / depositor / redeemer agents, rebalancer.py for the cross-pool
recycling sanity check, plot_basket_model.py / plot_rebalancing.py / plot_routing.py for
visualization.
Outstanding work tracked in companion memos:
- Equilibrium-seeking dynamic-issuance test – closed-loop integration of
BuckBasket,BuckKControllerDirect,BuckCredit, andBuckagainst an external market driver and an arb agent that responds tobuckKchanges by minting/burningBuckCreditbacked BUCK. Should emit a snapshot tape suitable for the existingplot_*visualizers and double as the regression for Dynamic Issuance: Why BuckBasket Needs BUCK_K. Highest-priority validation for the closed-loop control claim. - Resolve
BUG #5..#11/#13inBuckBasket– per-withdrawal slippage guards, TWAP-bounded swap limits, TWAP-driven overweight/underweight reads, proportional reinvestment, symmetric TOKEN-deposit routing, basket-mint accounting infundingFactor, treasury-reinvest event, graceful tail-redemption. - Mainnet-fork BUCK_K test – exercise
BuckKControlleragainst the four real on-chain pools at a pinned recent block, verifying basket cost reads sensibly against live data. Cheap validation step before mainnet. - Multi-block historical replay – a backtest harness that walks
BuckKControllerthrough 12 months of EVM history at a chosen block cadence, for tuningKp/Ki/Kdagainst a real price tape. Highest-value validation; biggest engineering lift. - Serialised B1 sub-notes (
spend_serialized.circom+ per-parent bitmap) – design validated end-to-end byscripts/snark/serialized_notes_poc.pyat N_fam in {256, 1024, 4096}; circuit port and contract bitmap support are the implementation gap. See alberta-buck-notes-serialized.org. - Production trusted-setup ceremony – replace dev entropy in
scripts/snark/setup.shwith a multi-party ceremony per pinned circuit before mainnet.
The cryptographic surface is described by an executable Python reference; CI regenerates the
canonical JSON test vectors and git diff --exit-code's them against committed copies, so any
drift between the spec, the wallet, and the Solidity tells us immediately which of the three is
wrong. No vm.ffi shells out to Python at test time – the wallet emits vectors once, the
Solidity reads them many times.