Skip to main content
  1. Range/
  2. Finance/

The Alberta Buck - Ethereum Implementation (DRAFT v0.93)

·11855 words·56 mins
Perry Kundert
Author
Perry Kundert
Communications, cryptography, automation & monetary system design and implementation.
Alberta-Buck - This article is part of a series.
Part 9: This Article

https://perry.kundert.ca/images/dominion-logo.png

A concrete Ethereum implementation of the Alberta Buck Architecture, in six contracts:

  1. BUCK_CREDIT (ERC-721) – one NFT per insured asset, with deterministic depreciation and piecemeal client activation.
  2. IdentityRegistry – per-address Pointcheval-Sanders credentials and ElGamal-encrypted identity points, verified on-chain via the BN254 precompiles. Trust anchor for every BUCK transfer.
  3. 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. balanceOf reports held + unused credit on non-Carrying accounts, so the ERC-20 view answers "what can I spend right now".
  4. 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 for BUCK_K.
  5. 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()), and BuckKController (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-gap dTMax clamp, fundingFactor() counter-cyclical insurance view – live in a shared BuckKControllerBase following the ezpwd::pid pattern; Buck.mint and Buck.burn drive compute(), and BuckBasket holds the privileged hook to reprime() after constituent additions.
  6. 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.

../../../images/buck-eth-layers.png

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:

  1. An insurer mints a BUCK_CREDIT NFT for a client's asset, setting face value, depreciation schedule, and premiumRate (annual basis points of activated coverage).
  2. The client may pre-activate some or all of the credit via BuckCredit.activate to expose the activated portion as a credit limit (without yet drawing against it). Pre-activation is optional – Buck.mint will auto-activate (top-up only) any additional coverage it needs.
  3. Before touching BUCK, the client registers with IdentityRegistry, posting a rerandomized PS credential and ElGamal-encrypted identity point. The registry verifies both via BN254 pairings (~235K gas, paid once per address).
  4. BUCK.mint(amount[, tokenIds]) draws coverage cheapest-first across the client's NFTs (via the batched BuckCredit.batchCreditInfo read), auto-activates any uncovered take per NFT through BuckCredit.activateFromBuck, debits the mutual-insurance pool principal (sized at 10x the annual premium on the activated coverage) from the client's signed raw balance, and credits that principal to insurancePool. No BUCK is delivered to the client's raw balance: signedRawBalanceOf(client) = -poolPrincipal after a fresh mint, and balanceOf(client) = creditLimit(client) - poolPrincipal is the headroom the client can now spend.
  5. To send BUCK, the sender approves with a Chaum-Pedersen receipt that re-encrypts the sender's identity under the spender's public key. transfer / transferFrom then bilateral- identity-check both sides and emit BuckTransferReceipt carrying only ciphertext hashes.
  6. To denominate as private notes, the client calls Notes.mint with a Groth16 proof binding the deposited totalFace to a batch of Poseidon commitments. Holders later call Notes.spend or Notes.spendACP with a fresh nullifier and a SNARK proving inclusion under a recent root.
  7. BuckBasket is the direct-mint orchestrator and the value anchor. Anyone holding a basket constituent (PAXG, cbBTC, XAUT, WBTC, etc., as wired by governance via addBasketToken) can call BuckBasket.depositToken to LP it into the corresponding TOKEN/BUCK Uniswap-V3 pool; the basket mints exactly tokenAmount * spotPrice worth of fresh BUCK against the deposit (via the privileged Buck.mintFromBasket hook), 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. On BuckBasket.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.
  8. BUCK_K ships in three forms today. The governance-set static variant is the simpler choice for early deployments where no on-chain price reference exists. BuckKControllerDirect is the USD-free embodiment: process variable = BuckBasket.basketValueInBuck(), setpoint = 1.0 BUCK by definition, error = 1.0 - basketValue. The legacy BuckKController reads 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.mint and Buck.burn call BuckK.compute() regardless of variant, so user activity drives and amortizes the PID; long quiet stretches are bounded by dTMax, and BuckBasket is the privileged caller of reprime() after addBasketToken so 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 at faceValue), 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 at depRate bp/year on the depreciable portion (faceValue - floor), clamped at depreciationFloor.
  • DECLINING_BALANCE: discrete whole-year compounding – the factor (BP - depRate)/BP is 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:

  1. PS signature at registration: e(sigma'_1, X + m * Y) = e(sigma'_2, g_2), with m reconstructed linearly via the binding NIZK in step 2. ~147K gas.
  2. 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.
  3. Chaum-Pedersen NIZK at approve: proves the recipient's identity ciphertext re-encrypts the same M registered in E_alice. Fiat-Shamir binds msg.sender, spender, and chainid. ~29K gas per counterparty pair.
  4. Chaum-Pedersen DLEQ at A-flavor spend (verifySpendCP): proves the spender holds a single sk_dep that opens both the note's E_n and the registered E_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-shot

A few non-obvious points:

  • verifyApprove reads E_alice from registry storage so the caller cannot substitute a fake credential.
  • bindContract is 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 uses BuckAwareDeployer.deployAndBind for atomic deploy+bind, or the operator races to bind a pre-existing contract before anyone else. Once bound, immutable.
  • markApproved (called by Buck inside approve) freezes the spender's isCarrying flag 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:

  1. isVerified(msg.sender) guard.
  2. buckK.compute() – advances the PID if dT has elapsed; otherwise returns the cached value. Mint and burn together amortize PID work across user activity.
  3. Capture fundingFactor and – when it is non-zero – the minter's pre-activation balanceOf. The reserve is measured before this mint activates the credit it pledges, so the freshly-activated headroom cannot satisfy its own reserve.
  4. _allocateMint(amount, tokenIds) reads BuckCredit.batchCreditInfo(tokenIds) once, walks the slice array, calls BuckCredit.activateFromBuck(tid, holder, take_i) per NFT (grows activatedValue and mintsBacked in lockstep), returns (totalCoverage, poolPrincipal).
  5. Counter-cyclical funding-factor reserve, enforced now that poolPrincipal is 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 account balanceOf = held + unusedCredit; fundingFactor is 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.

  6. _accrueJubilee(), crystallise insurancePool and msg.sender, debit poolPrincipal from the sender's signed raw (_subBalance is signed-aware – the raw goes negative if the sender had no positive holdings), credit poolPrincipal to insurancePool. _totalSupply is maintained automatically by _setBalanceSigned so that _totalSupply == sum_a max(0, signedRaw(a)) holds.
  7. 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) and isVerified(to).
  • For the to side (_receiptFragments[from][to]): if zero, requires =isPublicIdentity(from) — reverts with BUCK: sender must identity-approve recipient otherwise. Falls back to _identityHash(to) when from is public.
  • For the from side (_receiptFragments[to][from]): if zero, requires =isPublicIdentity(to) — reverts with BUCK: recipient must identity-approve sender otherwise. Falls back to _identityHash(from) when to is 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 + unusedCredit where held = max(0, signedRaw - feeOwing) and unusedCredit = 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 if BUCK_K falls.
  • 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's buckSeconds so the locked fees ride with the BUCKs. Carrying accounts hold no BuckCredit NFTs and never go negative (the spendable check inside _carryingTransfer rejects 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:

  1. BuckBasket-minted BUCK – minted by BuckBasket via the privileged Buck.mintFromBasket(to, amount) hook when a depositor pledges a TOKEN. Tracked in BuckBasket.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.
  2. Externally-supplied BUCK – created by the standard Buck.mint(amount, tokenIds) path described in BUCK, backed by BuckCredit insured-asset NFTs and gated by BUCK_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:

  1. Renormalizes every existing constituent's targetWeightBp so the total across the N+1 constituents sums to 10000 bp, preserving each existing constituent's relative weight ratio.
  2. Re-prices every existing constituent's basketAmount at the current pool spot price so its contribution to basketValueInBuck equals its declared weight share of 1.0 BUCK.
  3. 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.
  4. Computes the full-range tick bounds for the pool's fee-tier spacing and stores the constituent's buckIsToken0 ordering.
  5. Calls controller.reprime() so the controller absorbs the dilution-discontinuity in basketValueInBuck without 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. BuckBasket reads the pool's spot price, mints tokenAmount * spotPrice worth of BUCK to itself via Buck.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:

  1. Validate + size the receipt; compute redeemBuck as either the full buckPrincipal or a basis-point fraction.
  2. 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).
  3. Allocate redeemValue across pools. Each pool's "ideal" allocation is alloc_i = v_i - t_i * (NAV - redeemValue) where t_i is 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 when redeemValue is below SMALL_REDEEM_BP (1 %) of that pool's value, saving the multi-pool gas in the dominant case.
  4. 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.
  5. 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 below MAX_ORPHAN_DUST_WEI (1 µBUCK) is tolerated; larger gaps revert with "pool depth too thin".
  6. Pay depositor the surviving TOKEN side per pool, emit one RedeemedFromPool event per touched pool (TOKEN-to-user, TOKEN-swapped, L burned).
  7. Reinvest profit above the MIN_REINVEST_BUCK floor (0.001 BUCK): the BUCK side minus the principal burn is routed through _buckToLp into 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). Then Buck.burnFromBasket burns the principal, totalOutstandingBuck decrements, 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 / _swapTokenForBuckExactIn swaps use the permissive MIN/MAX_SQRT_RATIO ± 1 price limit; should be TWAP-bounded to resist sandwich.
  • #7_mostUnderweightPool / _mostOverweightPool read spot prices; should use TWAP.
  • #8_reinvestBuck LPs 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.
  • #10Buck.mintFromBasket bypasses BuckK's fundingFactor gate; document or fold basket-minted BUCK into the PID's accounting (see Dynamic Issuance below).
  • #11_reinvestBuck emits no event; downstream observers can't distinguish treasury reinvestment from external arb trades.
  • #13 – Late-basket-life redemptions can revert pool depth too thin when 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) via BuckCredit NFTs, gated by maxLimit = 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 ⇒ basketValueInBuck rises above 1.0).

Sign convention (inherited from BUCK_K):

  • basketValue > 1.0 (BUCK undervalued / inflation: TOKEN expensive in BUCK): error < 0buckK decreasesBuckCredit backed mints shrink ⇒ BUCK supply contracts ⇒ basket value falls back toward 1.0.
  • basketValue < 1.0 (BUCK overvalued / deflation: TOKEN cheap in BUCK): error > 0buckK increasesBuckCredit backed 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 < 0BUCK_K decreases ⇒ 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 > 0BUCK_K increases ⇒ 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_K decreases. 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_K increases. 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 if now - lastUpdate < dT and returns cached buckK as a one-SLOAD operation. This is the amortisation knob – multiple mints in a single dT window 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): when now - lastUpdate > dTMax, the cycle integrates as if only dTMax seconds had passed. Real lastUpdate still advances to now, so subsequent cycles measure forward correctly. Default type(uint256).max (no clamp); governance opts in via setDTMax. 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 operation Buck.mint / Buck.burn drive it; in long quiet stretches a keeper can poke it directly to keep the integral fresh, or rely on dTMax to 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, computes T1 = 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), singletons spendVerifier for B-spend and spendAVerifier for A-spend, plus the identityRegistry used by spendACP for verifySpendCP. All swappable by governance.
  • Audit / double-spend state: mapping(uint256 => bool) nullifiers, uint256 noteFaceSum.
  • Tree state: uint32 nextLeafIndex and uint256[ROOT_HISTORY_SIZE=30] roots with uint8 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:

  1. totalCurrentValue = 341,000e6, maxLimit = 341,000e6. Alice's existing balance is 280,000e6, so the early gate (280k + 50k <= 341k) passes.
  2. 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=.
  3. Tight gate: 280,000e6 + 52,631,579 <= 341,000e6 – passes.
  4. Alice receives exactly 50,000e6. insurancePool receives 2,631,579 (the 10x principal whose 10% ROI funds the 0.50% annual premium on the activated coverage).
  5. Minted(alice, 52,631,579, 2,631,579, 341,000e6, 1e18, 341,000e6), plus standard Transfer events.

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:

  1. 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.
  2. Notes.mint(proofBytes, oldRoot, newRoot, nextLeafIndex, 125e6, [cm[0]..cm[15]]) – the adapter dispatches internally by cms.length=16 to its pinned MintBatch16 Groth16 verifier. On success, 125e6 BUCK moves from Alice to Notes, roots[] advances to the SNARK-attested newRoot, nextLeafIndex advances 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:

  1. The wallet reconstructs (pathElements[20], pathIndices[20]) for cm[0] by replaying the Minted event log and pulling cms[] from each batch's calldata.
  2. snarkjs groth16 fullProve against spend.wasm / spend_final.zkey produces a proof binding [currentRoot, Poseidon-3(rho, idHash, 4242), 100e6, recipient, 1] to the witness.
  3. 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, decrements noteFaceSum, and calls buck.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 via buckSeconds.

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 safeBatchTransferFrom for 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 insurancePool address, so pool deposits become standardised mutual-insurance shares with deposit / 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 premiumRate is flat. A loss-layer-style effective-rate derivation – rate(V, A, D) = lambda * coverage * (1 - D/V)^2 / 2 under 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 raw premiumRate to 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). Buck today 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 share Buck'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 into BuckCredit and BuckKController stay external – the ERC-20 / ERC-721 selector collision detailed in BUCK_CREDIT forces BuckCredit to remain a sibling.
  • Independently upgradeable BuckCredit (UUPS or transparent proxy). Keep Buck's immutable buckCredit address stable while patching BuckCredit'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, dS compensation, dT, dTMax, anti-windup, integral convergence, fundingFactor) against a synthetic _readReferences override.
  • BuckKControllerStaticTest – governance-set constant, setBuckK guards, fundingFactor always-UNIT.
  • BuckKControllerDirectTest – the USD-free direct embodiment wired to a mock basket source; reprime access control, dilution-discontinuity absorption, sign-convention canaries on the basketValue > 1.0 and < 1.0 branches.
  • BuckKControllerUnitTest – harness with mocked Chainlink feeds, mocked BUCK price. Includes priming, dS compensation, dTMax clamp, 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 with twapInterval = 600 s on 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, addBasketToken renormalization, proportional-allocation correctness across overweight/underweight pools, "sell high / buy low" redemption invariants, shortfall-cover greedy swap, and the reprime callback into BuckKControllerDirect.
  • BuckEquilibriumScenarioTest – multi-month closed-loop scenario; ticks BuckBasket.depositToken / redeem and Buck.mint / burn across simulated price drift to exercise fundingFactor and the buckK response 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; emits arb-scenario.json for 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, and Buck against an external market driver and an arb agent that responds to buckK changes by minting/burning BuckCredit backed BUCK. Should emit a snapshot tape suitable for the existing plot_* 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/#13 in BuckBasket – per-withdrawal slippage guards, TWAP-bounded swap limits, TWAP-driven overweight/underweight reads, proportional reinvestment, symmetric TOKEN-deposit routing, basket-mint accounting in fundingFactor, treasury-reinvest event, graceful tail-redemption.
  • Mainnet-fork BUCK_K test – exercise BuckKController against 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 BuckKController through 12 months of EVM history at a chosen block cadence, for tuning Kp/Ki/Kd against 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 by scripts/snark/serialized_notes_poc.py at 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.sh with 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.

Alberta-Buck - This article is part of a series.
Part 9: This Article