Transaction

EA40FD55692E52…618311AA4B92

Block 1,315,542 · index 1 · indexed

Summary

Hash
EA40FD55692E529E5E8CE74A6F9FF0B37443785D7FB810DF7182618311AA4B92
Block
1,315,542
Size
98207 bytes
Gas used
127,272,256 / 150,000,000
Fee
150000 ugnot
Status
success

Messages

Attached funds
20000000 ugnot

Arguments · 9

  1. #1budgets
  2. #2budgets.gno
  3. #3// Realm budgets is a delegated-budget treasury in GNOT: an owner funds a // pool, grants named delegates a standing, rate-limited, revocable // authority to spend from it, and each delegate then draws within that // authority with no further approval from anyone. // // WHY THIS EXISTS (see DISCOVERY.md): every disbursement path on either // live chain is one of three shapes — a fresh group decision per spend // (this portfolio's treasury_board, gno.land's r/gov/dao/treasury, the // mainnet bubbletreasury), a fixed schedule (vesting), or a pull against // an already-settled entitlement (fee_split, revshare, crowdfund, // subscriptions). None of them lets one party spend another's money at // its own discretion within a bound. That is the gap, and // bubbletreasury — a real mainnet treasury that hand-rolled a sliding // spend window inside the realm, with every spend still a token-holder // proposal — is the evidence someone wanted it. // // It is also, deliberately, the FIRST CONSUMER of spendbook on any // chain. A primitive with no consumer has two properties unproven no // matter how many unit tests pass: that its API is usable from a realm, // and that its state survives across transactions. Only a realm can // answer either, and this one is the answer. // // COMPOSITION: spending authority is delegated entirely to spendbook // (anchored windows, lifetime caps, expiry, one-sided amendments, // revocation), coin movement to coinio, per-delegate credit and the fee // pot to feeledger, ordered storage to p/nt/avl/v0, and free-text render // output to p/nt/markdown/sanitize/v0. This realm owns only the pool // accounting, the grant records, and the solvency rule. // // # What a budget is, and what it is not // // A budget is AUTHORITY, not a reservation. Three grants of 100 against // a pool of 100 are all three valid, and the first delegate to draw gets // the money. This is not a defect to be fixed by reserving funds: a // reservation would make an owner unable to grant a standing allowance // without pre-committing the capital, which is the whole convenience // being bought. What the realm guarantees instead is that it never // PROMISES what it does not HOLD: // // Draw refuses unless amount <= the owner's own unspent pool. // // So a delegate is told "no" at draw time rather than credited with // something unclaimable. The pool is the solvency bound and it is // per-owner, which is strictly stronger than a realm-wide check: one // owner's grants can never reach another owner's funds. // // # Credit, then pull // // Draw moves nothing. It consumes the allowance, debits the owner's // pool, and credits the delegate in the ledger; the delegate then signs // a separate Claim to receive the coins. This is not ritual. If Draw // paid out directly, a failing payout inside the same transaction would // leave a consumed allowance with no money delivered, or force the // allowance consumption to happen after an effect — and spendbook's own // consumer contract item 3 says state first, effect second, for exactly // that reason. Splitting it also means a delegate can draw repeatedly // and claim once, which is cheaper than claiming per draw. // // # LIFECYCLE // // Fund (owner, +send) : adds real coins to the caller's own pool. // Anyone may fund; funding makes you an // owner. Minimum MinFunding. // WithdrawPool (owner) : pulls unspent pool back out, any amount, // any time. See "the owner can always // leave" below. // WithdrawPoolAll (owner) : the whole pool. // Grant (owner) : opens one spendbook allowance for one // delegate and returns the grant id. The // draw fee is snapshotted here. // Amend (owner) : changes the grant's terms. Tighter-or- // equal applies now; looser waits for the // next window boundary. PeriodLength is // NOT a parameter — spendbook makes it // immutable and this realm makes that // structural. // Revoke (owner) : immediate, and the id is never reused. // Draw (DELEGATE) : consumes authority, debits the pool, // credits the delegate net of the fee. // Claim (DELEGATE) : pays the delegate's whole credit out. // Reap (ANYONE) : removes an EXPIRED grant, freeing its // slot. Permissionless because an expired // allowance can authorize nothing, so // removing it takes nothing from anyone. // WithdrawFees (ANYONE) : pays the fee pot to the compile-time // FeeCollector. Fixed destination, so // there is nothing to steer. // SweepSurplus (ANYONE) : out-of-band coins to the FeeCollector, // never touching tracked liabilities. // // # The owner can always leave, and that is the design // // WithdrawPool has no lock, no notice period, and no check against // outstanding grants. An owner can empty the pool under a live grant and // the delegate's next Draw will fail on solvency. This is stated plainly // rather than buried: the money is the owner's, Revoke is the clean way // to end a relationship, and a realm that could trap an owner's capital // behind a grant they regret would be a worse realm. A delegate who // needs a guarantee of funds does not want a budget — they want escrow, // which is a different artifact. // // What the owner CANNOT do is undo work already authorized: credit // already drawn into the ledger is the delegate's, and Revoke does not // claw it back. Revoking a grant ends future authority only. // // # THE FEE, precisely // // feeBps is snapshotted into the grant at Grant time from the // compile-time DrawFeeBps, so a delegate's terms are fixed when the // grant is created and visible in GrantInfo from that moment. The fee is // charged ONCE per draw, on the gross amount, with feeledger's floor // rounding (rounding favors the delegate). The owner's pool is debited // the GROSS amount and the delegate is credited the net — the fee is // paid by the owner out of the budget, not taken out of the delegate's // authority, so a grant of PerPeriod means PerPeriod of the owner's // money leaves the pool per window, which is the figure an owner // actually cares about bounding. The ledger is constructed with the // compile-time MaxFeeBps cap, so a fee above the cap is refused by the // primitive, not by a check in this file. There is no fee on funding, on // withdrawing an unspent pool, or on claiming. // // # THERE IS NO ADMIN // // The fee rate, the fee collector, every bound and every cap are // compile-time constants. No address can change terms, pause the realm, // touch a pool it did not fund, or move a grant it did not create. The // deployer has no privilege of any kind after deployment. // // # MONETARY INVARIANT (conservation) // // Let H be the ugnot held at this realm's address, P the sum of all // owner pools (tracked O(1) as totalPooled), L the ledger's liabilities // (unclaimed delegate credit plus the fee pot), and S >= 0 the // out-of-band surplus: // // H == P + L + S // // Every transition moves value between exactly two terms inside one // transaction. Fund raises H and P together (coinio.Receive is the // receipt-guaranteed shape). WithdrawPool debits P before the identical // amount leaves H. Draw moves gross out of P and into L, split into the // delegate's credit and the fee — H does not move at all, which is why // Draw has no payout to fail. Claim and WithdrawFees debit L before // their payout. Any panic aborts the whole transaction; this realm never // issues or removes coins. // // # ONLY GNOT IS ACCEPTED // // Fund rejects any envelope that is not exactly one positive ugnot coin // (coinio.Receive). Every other entrypoint rejects attached coins // outright rather than converting them into sweepable surplus. // // # AUTHORIZATION // // Every identity is derived from the crossing entrypoint's // cur.Previous().Address(). No function takes a caller identity as a // parameter, so funding another owner's pool, drawing another delegate's // budget, claiming another delegate's credit, or amending a grant you do // not own is impossible by construction. This is spendbook consumer // contract item 2, closed structurally. // // The clock is runtime.ChainHeight(), never an argument. That is // spendbook consumer contract item 1: a realm that let a caller choose // `now` would have handed the delegate the power to roll the window at // will, defeating every cap in the primitive. // // # STORAGE // // A pool entry is paid for by the owner's own funding transaction; a // grant record and its allowance by the owner's Grant. Revoke and Reap // both remove the grant record and the allowance in the same call, and // the chain refunds the freed storage deposit to the transaction that // frees it — which is the standing incentive to call Reap on someone // else's expired grant. package budgets import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook" "gno.land/p/nt/avl/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // RealmPath is this realm's own path, used to build Render links. const RealmPath = "/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets" // Denom is the only asset this realm holds. const Denom = "ugnot" // MaxFeeBps is the hard ceiling on any draw fee this realm could ever // charge, enforced by the feeledger the realm is constructed with — a // feeBps above it is refused by the primitive's own validation, not by a // check in this file. 500 bps = 5%. const MaxFeeBps = int64(500) // DrawFeeBps is the fee actually snapshotted into every grant: 100 bps = // 1%, charged once per draw on the gross amount, floor-rounded in the // delegate's favor. The most conservative live fee in this portfolio // (fee_split, crowdfund) is also 1%. const DrawFeeBps = int64(100) // FeeCollector receives withdrawn fees and swept surplus. Compile-time // constant: there is no setter and no transfer path. It is the deploying // namespace's address — stated plainly: the operator of this realm. const FeeCollector = address("g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39") // MinFunding keeps dust pools out: 1 GNOT per Fund call. const MinFunding = int64(1_000_000) // MinPerPeriod is the smallest per-window cap a grant may carry. It // exists so a grant cannot be created that no draw could ever satisfy, // and it is the ONLY lower bound in the draw path — there is // deliberately no minimum Draw amount, because a minimum draw above a // grant's PerPeriod would make the grant silently undrawable. const MinPerPeriod = int64(1_000) // MinPeriodBlocks is the shortest rolling window a grant may use. A // window of a handful of blocks is a rate, not a budget, and it makes // PerPeriod meaningless as a bound on worst-case loss. A periodLength of // 0 is still accepted and means ONE LIFETIME WINDOW that never refills // (spendbook's own semantics). const MinPeriodBlocks = int64(100) // MaxLiveGrants caps simultaneously live grants, via spendbook's own // construction bound. Grant is permissionless to anyone with a funded // pool, so the cap is exhaustible in principle, and that AVAILABILITY // BOUND is documented rather than hidden. // // It is documented here in its CORRECTED form. An earlier version of // this comment claimed the mitigation was "MaxGrantsPerOwner plus the // cost of becoming an owner at all (MinFunding per pool), and Reap is // permissionless so expired grants cannot accumulate". An adversarial // audit (finding Y1, DISCOVERY.md § 9) showed BOTH HALVES were false. A // wrong mitigation is worse than an acknowledged bound, because a reader // sizes the risk from it and stops looking: // // - MinFunding is NOT a capital bound. The funded-pool requirement is // checked at Grant time only, and WithdrawPool has no lock, so one // address funds a pool, opens MaxGrantsPerOwner grants, withdraws // the pool in full, and the grants survive holding their slots. // TestOwnerCanAlwaysLeave pins exactly that sequence, as intended // behavior — the attack is built out of a documented feature. // - Reap is inert against it. Reap requires spendbook's IsExpired, // which is never true for expiresAt == 0, so an attacker omits the // expiry and no third party can remove the grant. // // What the cost ACTUALLY is: gas for one Fund, MaxGrantsPerOwner Grants // and one WithdrawPoolAll per address — about MaxLiveGrants*17/16 // transactions to fill the cap — plus the storage deposit on every grant // record, which the chain holds for as long as the grant does and // refunds only when it is removed. That is real and unrecoverable while // the grief lasts. It is not the locked pool capital the old comment // promised, and it is priced in gas and storage rather than in capital. // // No capital bound was added, because the obvious one does not work. // Requiring an owner with live grants to retain MinFunding is defeated // for the price of the draw fee: nothing forbids granting to YOURSELF, // so the attacker draws its own pool to zero through its own grant and // Claims the credit back, recovering all but DrawFeeBps of it while the // retention rule still reads as satisfied. Closing THAT would mean // bounding Draw below MinFunding — the realm refusing to let a delegate // spend the budget, which is the feature. The remaining alternative, a // non-refundable per-grant fee, taxes every honest owner to price a // grief, and at a fee high enough to deter one it is a tenth of // MinFunding per grant. // // The blast radius is narrow and that is the reason this is disclosed // rather than redesigned: a full book blocks NEW grants only. Every // existing grant, pool, draw, credit and claim keeps working, no value // is at risk, and any owner can free their own slots with Revoke. // // The figure was 1024 until that audit, and 1024 was conservative rather // than measured: nothing in this realm iterates the book (Render is // bounded by MaxRenderRows, every write path is a single avl lookup) and // a grant record's storage is paid for by whoever creates it. // Quadrupling it quadruples the cost of filling the cap and costs an // honest owner nothing. spendbook's own MaxLiveLimit is 10000. const MaxLiveGrants = 4096 // MaxGrantsPerOwner bounds how much of the global cap one owner can // occupy. spendbook's own package documentation names this as the gap a // consuming realm must close — "maxLive is a single global figure, so // any owner permitted to Open can exhaust it and lock out the rest … // the consuming realm decides who may Open, and a Book shared across // mutually untrusting owners needs its own per-owner cap on top of this // one." This is that cap. (permission_registry finding R1 is the same // lesson from this portfolio's own history.) const MaxGrantsPerOwner = int64(16) // MaxLabelLen bounds the grant's free-text label, which is sanitized // before reaching markdown. const MaxLabelLen = 64 // MaxRenderRows bounds the grant list on the index page. const MaxRenderRows = 20 type grant struct { id int64 owner address spender address allowID uint64 // the spendbook allowance backing this grant feeBps int64 // snapshotted at Grant label string createdAt int64 drawn int64 // lifetime gross drawn under this grant draws int64 // number of successful draws } var ( self address // grants holds one record per live grant. Revoke and Reap remove the // record; ids are never reused. grants = avl.NewTree() // padID(id) -> *grant nextID int64 // pools is each owner's unspent funded balance: // address string -> int64 (> 0). This is the P term. pools = avl.NewTree() // openByOwner tracks each owner's live grant count: // address string -> int64 (> 0). openByOwner = avl.NewTree() // totalPooled is the P term of H == P + L + S, maintained O(1). totalPooled int64 // lifetime counters, rendered for operators. lifetimeFunded int64 lifetimeDrawn int64 // gross, before the fee lifetimeClaimed int64 // net, paid to delegates lifetimeReturned int64 // unspent pool withdrawn by owners // ledger holds each delegate's unclaimed credit and the fee pot. Its // Liabilities() is the L term and the reserve SweepSurplus protects. ledger = feeledger.MustNew(MaxFeeBps) // book holds one live allowance per live grant. Every bound on // authority — windows, caps, expiry, amendment direction — is the // primitive's, not this file's. book = spendbook.MustNew(MaxLiveGrants) ) func init() { self = unsafe.CurrentRealm().Address() } // rejectStraySend aborts when coins are attached to a non-payable call. // This realm holds funds, so a stray send would become sweepable // surplus, silently converting a user's coins into operator funds. // Aborting reverts the transfer instead. Guarded on IsUserCall, not // IsUser, because a MsgRun ephemeral can consume the OriginSend envelope // before forwarding control; for a realm-routed call the envelope lands // at the intermediary, so there is nothing here to reject and the guard // deliberately fails open (crowdfund and treasury_board document the // same scope). func rejectStraySend(cur realm) { if !cur.IsCurrent() { // Every call site forwards a crossing entrypoint's own live cur, // so this cannot fire today; it exists so a future refactor that // hands this guard a stale realm value fails closed, not open // (coinio names IsCurrent as the guard secondary realm // parameters require, and this helper follows that discipline). panic("budgets: realm capability is not current") } if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 { panic("this entrypoint does not accept coins") } } // --- the pool --- // Fund adds the attached coins to the caller's own pool. Direct EOA // calls with -send only (the receipt-guaranteed shape); exactly one // positive ugnot coin, at least MinFunding. Anyone may fund; funding is // what makes an address an owner. func Fund(cur realm) int64 { owner, amount := coinio.Receive(0, cur, Denom) if amount < MinFunding { panic("fund at least " + itoa(MinFunding) + Denom) } key := owner.String() newPool, ok := checkedAdd(poolOf(key), amount) if !ok { panic("pool would overflow") } newTotal, ok := checkedAdd(totalPooled, amount) if !ok { panic("total pooled would overflow") } newLifetime, ok := checkedAdd(lifetimeFunded, amount) if !ok { panic("lifetime funded would overflow") } setPool(key, newPool) totalPooled = newTotal lifetimeFunded = newLifetime chain.Emit("Funded", "owner", key, "amount", itoa(amount), "pool", itoa(newPool)) return newPool } // WithdrawPool returns part of the caller's unspent pool and reports the // pool REMAINING afterwards. There is no lock and no check against // outstanding grants: the money is the owner's, and a delegate's // authority is not a claim on it (see the package doc, "the owner can // always leave"). Revoke is the clean way to end a grant. func WithdrawPool(cur realm, amount int64) int64 { rejectStraySend(cur) owner := cur.Previous().Address() if amount <= 0 { panic("amount must be positive") } return payOutPool(cur, owner, amount) } // WithdrawPoolAll returns the caller's entire unspent pool and reports the // amount WITHDRAWN. // // The return values of the two withdraw entrypoints are deliberately // asymmetric, which is worth naming rather than letting a caller discover: // each returns the figure its caller does not already have. WithdrawPool // was told the amount and wants to know what is left; WithdrawPoolAll // knows what is left (nothing) and wants to know what it got. Returning // the remainder here would hand every caller a constant 0. func WithdrawPoolAll(cur realm) int64 { rejectStraySend(cur) owner := cur.Previous().Address() amount := poolOf(owner.String()) if amount == 0 { panic("no pool to withdraw") } payOutPool(cur, owner, amount) return amount } // payOutPool is the shared debit-then-send path: the pool and the P term // are reduced before the identical amount leaves the realm // (checks-effects-interactions). func payOutPool(cur realm, owner address, amount int64) int64 { key := owner.String() pool := poolOf(key) if amount > pool { panic("amount exceeds pool") } newReturned, ok := checkedAdd(lifetimeReturned, amount) if !ok { panic("lifetime returned would overflow") } setPool(key, pool-amount) totalPooled -= amount lifetimeReturned = newReturned coinio.Payout(0, cur, owner, Denom, amount) chain.Emit("PoolWithdrawn", "owner", key, "amount", itoa(amount), "pool", itoa(pool-amount)) return pool - amount } // --- grants --- // Grant opens a standing spending authority for one delegate and returns // the grant id. The caller becomes the grant's owner and must already // have a funded pool — not because the grant reserves anything (it does // not), but because a grant from an address that has never funded // anything is a promise with nothing behind it, and this realm declines // to record one. // // Terms, all in the chain's own units: // // perPeriod the cap per window, at least MinPerPeriod. Required. // periodLength blocks per window. 0 means ONE LIFETIME WINDOW that // never refills; otherwise at least MinPeriodBlocks. // total lifetime cap across every window. 0 means unlimited. // expiresAt absolute block height at which authority ends. 0 means // no expiry. // // Every bound above except the two realm minimums is validated and // enforced by spendbook, and an invalid combination surfaces as that // package's own sentinel error. func Grant(cur realm, spender address, label string, perPeriod, periodLength, total, expiresAt int64) int64 { rejectStraySend(cur) owner := cur.Previous().Address() // The lowercase clause is load-bearing, not decoration, and the reason // was measured on this toolchain rather than argued: address.IsValid() // defers to crypto.AddressFromBech32, whose decoder lowercases the // input BEFORE verifying the checksum, so it returns true for the // all-uppercase spelling of a valid address. Here the hazard is not a // double count (as in fee_split/v2 and treasury_board, where the // address was a vote-bearing key) but a BRICKED GRANT: Draw // authenticates against cur.Previous().Address(), which is always // canonical, so an uppercase-spelled delegate could never draw, while // the grant still occupied a MaxGrantsPerOwner slot and a storage // deposit. The check is complete as well as necessary — that decoder // rejects mixed case outright, so a valid address has exactly two // spellings and rejecting every non-lower one leaves precisely the // canonical one. if !spender.IsValid() || string(spender) != strings.ToLower(string(spender)) { panic("spender is not a valid address in canonical (lowercase) form") } if len(label) > MaxLabelLen { panic("label exceeds " + strconv.Itoa(MaxLabelLen) + " bytes") } if perPeriod < MinPerPeriod { panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom) } if periodLength != 0 && periodLength < MinPeriodBlocks { panic("periodLength must be 0 (lifetime) or at least " + itoa(MinPeriodBlocks) + " blocks") } key := owner.String() if poolOf(key) == 0 { panic("fund a pool before granting") } if ownerOpen(key) >= MaxGrantsPerOwner { panic("owner already has " + itoa(MaxGrantsPerOwner) + " live grants") } now := runtime.ChainHeight() allowID, err := book.Open(key, spender.String(), now, spendbook.Terms{ PerPeriod: perPeriod, PeriodLength: periodLength, Total: total, ExpiresAt: expiresAt, }) if err != nil { panic(err) } id := nextID + 1 nextID = id grants.Set(padID(id), &grant{ id: id, owner: owner, spender: spender, allowID: allowID, feeBps: DrawFeeBps, label: label, createdAt: now, }) setOwnerOpen(key, ownerOpen(key)+1) chain.Emit("Granted", "id", itoa(id), "owner", key, "spender", spender.String(), "perPeriod", itoa(perPeriod), "periodLength", itoa(periodLength), "total", itoa(total), "expiresAt", itoa(expiresAt), "feeBps", itoa(DrawFeeBps)) return id } // Amend changes a grant's terms. Owner only. The returned bool reports // whether the new terms are in force NOW (a tighter-or-equal amendment) // or queued for the next window boundary (a looser one) — spendbook's // one-sided rule, and the distinction worth knowing. // // periodLength is NOT a parameter. spendbook makes it immutable because // moving it would move every future boundary, which is an enlargement // vector wearing a different hat; this realm reads the grant's own // periodLength and passes it unchanged, so changing it is not merely // refused, it is unexpressible. func Amend(cur realm, id int64, perPeriod, total, expiresAt int64) bool { rejectStraySend(cur) owner := cur.Previous().Address() g := mustGet(id) if owner != g.owner { panic("owner only") } if perPeriod < MinPerPeriod { panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom) } a, err := book.Get(g.allowID) if err != nil { panic(err) } applied, err := book.Amend(g.allowID, owner.String(), runtime.ChainHeight(), spendbook.Terms{ PerPeriod: perPeriod, PeriodLength: a.Terms.PeriodLength, // immutable Total: total, ExpiresAt: expiresAt, }) if err != nil { panic(err) } chain.Emit("Amended", "id", itoa(g.id), "owner", owner.String(), "perPeriod", itoa(perPeriod), "total", itoa(total), "expiresAt", itoa(expiresAt), "applied", boolstr(applied)) return applied } // Revoke ends a grant immediately. Owner only. The id is never reused, // so a revoked grant cannot be resurrected and a later Draw against it // fails as unknown. // // Revoking does NOT claw back credit the delegate has already drawn — // that is theirs and stays claimable. Revoke ends future authority. func Revoke(cur realm, id int64) { rejectStraySend(cur) owner := cur.Previous().Address() g := mustGet(id) if owner != g.owner { panic("owner only") } // Owner-gated twice on purpose: the check above is for the error // message, the primitive's is the authoritative one. if err := book.Revoke(g.allowID, owner.String()); err != nil { panic(err) } drop(g) chain.Emit("Revoked", "id", itoa(g.id), "owner", owner.String(), "spender", g.spender.String(), "drawn", itoa(g.drawn)) } // Reap removes a grant whose authority has EXPIRED, freeing its slot and // its storage. Anyone may call it, and the reasoning is spendbook's own: // an expired allowance can never authorize another draw, so removing it // takes nothing from the owner and grants nothing to the caller. The // chain refunds the freed storage deposit to the reaping transaction, // which is the standing incentive. // // A grant with no expiry can never be reaped; only its owner can end it. func Reap(cur realm, id int64) { rejectStraySend(cur) g := mustGet(id) if err := book.Reap(g.allowID, runtime.ChainHeight()); err != nil { panic(err) } drop(g) chain.Emit("Reaped", "id", itoa(g.id), "owner", g.owner.String(), "by", cur.Previous().Address().String()) } // --- drawing --- // Draw spends amount from the grant's budget. DELEGATE ONLY: the spender // is the calling address, never an argument. Nothing is paid out here — // the delegate is credited net of the fee and claims separately. // // Three things must all hold and they fail in this order, which matters: // the caller must be the grant's delegate and the amount must be within // every spendbook bound (window cap, lifetime cap, expiry, start); and // the owner's pool must cover the GROSS amount. The solvency check runs // BEFORE the allowance is consumed, so a draw refused for insolvency // leaves the budget untouched rather than burning window capacity on a // draw that did not happen. func Draw(cur realm, id int64, amount int64) int64 { rejectStraySend(cur) spender := cur.Previous().Address() g := mustGet(id) if spender != g.spender { panic("delegate only") } if amount <= 0 { panic("amount must be positive") } ownerKey := g.owner.String() pool := poolOf(ownerKey) if amount > pool { // A budget is authority, not a reservation (package doc). The // honest failure is here, at draw time, rather than a credit the // delegate could not claim. panic("owner's pool cannot cover this draw") } // Authority. spendbook checks every bound and CONSUMES the allowance // in this one call, before returning; on any error nothing is // consumed. Its sentinel errors name the reason. if err := book.Spend(g.allowID, spender.String(), runtime.ChainHeight(), amount); err != nil { panic(err) } newDrawn, ok := checkedAdd(g.drawn, amount) if !ok { panic("grant drawn total would overflow") } newLifetime, ok := checkedAdd(lifetimeDrawn, amount) if !ok { panic("lifetime drawn would overflow") } // Move the gross out of P and into L. H does not move, which is why // this transition has no payout to fail. setPool(ownerKey, pool-amount) totalPooled -= amount credited, fee := ledger.MustDeposit(spender.String(), amount, g.feeBps) g.drawn = newDrawn g.draws++ lifetimeDrawn = newLifetime chain.Emit("Drawn", "id", itoa(g.id), "spender", spender.String(), "gross", itoa(amount), "credited", itoa(credited), "fee", itoa(fee), "pool", itoa(pool-amount)) return credited } // Claim pays the caller's entire accrued credit out. DELEGATE ONLY: the // account is the calling address. Credit from a grant that has since // been revoked, expired or reaped is still claimable — it was earned // under a live authority and this realm does not revisit that. func Claim(cur realm) int64 { rejectStraySend(cur) account := cur.Previous().Address() key := account.String() amount, err := ledger.WithdrawAll(key) if err != nil { panic(err) } if amount == 0 { panic("no credit to claim") } newClaimed, ok := checkedAdd(lifetimeClaimed, amount) if !ok { panic("lifetime claimed would overflow") } lifetimeClaimed = newClaimed coinio.Payout(0, cur, account, Denom, amount) chain.Emit("Claimed", "account", key, "amount", itoa(amount)) return amount } // --- fees and surplus --- // WithdrawFees pays the entire accrued fee pot to the compile-time // FeeCollector. Anyone may call it: the destination is fixed, so there // is nothing for a caller to steer. func WithdrawFees(cur realm) int64 { rejectStraySend(cur) fees := ledger.WithdrawFees() if fees == 0 { panic("no fees accrued") } coinio.Payout(0, cur, FeeCollector, Denom, fees) chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees)) return fees } // SweepSurplus sends out-of-band coins to the FeeCollector: for the pool // denom, everything above tracked liabilities; for any foreign denom, // the full balance. Owner pools, delegate credit and the fee pot are // untouchable by construction (coinio.Sweep refuses to dip below the // reserve). Anyone may call it. // // With nothing out of band this ABORTS ("coinio: no surplus to sweep for // <denom>") rather than returning 0. That is the primitive's choice and // the right one: a sweep is a transfer, and a transfer of nothing that // reports success is how a caller comes to believe a balance was moved. func SweepSurplus(cur realm, denom string) int64 { rejectStraySend(cur) reserve := int64(0) if denom == Denom { reserve = Liabilities() } swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve) chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(), "amount", itoa(swept)) return swept } // --- views --- // GrantInfo returns a grant's parties and the terms currently RECORDED // for it. perPeriod reflects a tightening amendment immediately; a // queued loosening shows in PendingTerms until its boundary arrives. // // CAVEAT (audit 2026-11-04, N1): past the first window boundary after a // loosening was queued, the PENDING terms already govern enforcement -- // spendbook's at() applies them whether or not any write observed the // roll -- while this function still returns the stored record until the // next draw or amendment. For the governing figures, read GrantUsage / // Drawable (always boundary-correct) or the grant page, which derives // its terms exactly as the gate does. func GrantInfo(id int64) (owner, spender address, perPeriod, periodLength, total, expiresAt, anchor, createdAt, feeBps int64) { g := mustGet(id) a := mustAllowance(g) return g.owner, g.spender, a.Terms.PerPeriod, a.Terms.PeriodLength, a.Terms.Total, a.Terms.ExpiresAt, a.Anchor, g.createdAt, g.feeBps } // GrantUsage returns a grant's consumption AT THE CURRENT HEIGHT: what the // window the chain is in right now has spent, the lifetime gross, that // window's index, and how many draws there have been. // // The two window figures are computed rather than read straight off the // record, and that is the whole point of windowAt below: a window that has // rolled with no draw since leaves Allowance.WindowIndex naming a window // the chain has already left, and Allowance.SpentWindow holding the // previous window's spend. spentTotal and draws are cumulative and never // go stale. func GrantUsage(id int64) (spentWindow, spentTotal, windowIndex, draws int64) { g := mustGet(id) a := mustAllowance(g) now := runtime.ChainHeight() return spentInWindow(a, now), a.SpentTotal, windowAt(a, now), g.draws } // GrantLabel returns a grant's raw label (unsanitized: callers rendering // it into markdown must sanitize, as this realm's Render does). func GrantLabel(id int64) string { return mustGet(id).label } // Remaining is the AUTHORITY figure: what the grant's terms permit right // now, ignoring whether the owner's pool can cover it. func Remaining(id int64) int64 { g := mustGet(id) return book.Remaining(g.allowID, runtime.ChainHeight()) } // Drawable is the HONEST figure, and the one a delegate should read: the // lesser of what the grant authorizes and what the owner's pool holds. // This distinction is the realm's own — spendbook bounds permission and // says so, never solvency. func Drawable(id int64) int64 { g := mustGet(id) authorized := book.Remaining(g.allowID, runtime.ChainHeight()) pool := poolOf(g.owner.String()) if pool < authorized { return pool } return authorized } // PendingTerms returns a queued loosening amendment and whether there is // one. An amendment is queued only when it would widen the live window, // and it takes effect at the next window boundary. func PendingTerms(id int64) (perPeriod, total, expiresAt int64, pending bool) { a := mustAllowance(mustGet(id)) if !a.HasPending { return 0, 0, 0, false } return a.Pending.PerPeriod, a.Pending.Total, a.Pending.ExpiresAt, true } // IsExpired reports whether a grant's authority has lapsed, which is // also the condition under which anyone may Reap it. func IsExpired(id int64) bool { return mustAllowance(mustGet(id)).IsExpired(runtime.ChainHeight()) } // PoolOf returns an owner's unspent pool. func PoolOf(addr address) int64 { return poolOf(addr.String()) } // CreditOf returns a delegate's drawn-but-unclaimed credit. func CreditOf(addr address) int64 { return ledger.BalanceOf(addr.String()) } // GrantsOf returns how many live grants an owner currently holds. func GrantsOf(addr address) int64 { return ownerOpen(addr.String()) } // NumGrants returns how many grants have ever been created. Ids are // never reused, so this only grows. func NumGrants() int64 { return nextID } // LiveGrants returns how many grants are currently live, from the // primitive's own count. func LiveGrants() int64 { return int64(book.Count()) } // TotalPooled returns the P term: unspent owner funds. func TotalPooled() int64 { return totalPooled } // UnclaimedCredit returns the delegates' share of the L term. func UnclaimedCredit() int64 { return ledger.UsersTotal() } // FeesAccrued returns the fee pot: charged, not yet withdrawn. func FeesAccrued() int64 { return ledger.FeesAccrued() } // Liabilities returns P + L — everything this realm owes. func Liabilities() int64 { return totalPooled + ledger.Liabilities() } // Held returns H: the ugnot actually at this realm's address. func Held() int64 { return coinio.HeldAt(self, Denom) } // Surplus returns H - (P + L) — out-of-band coins, sweepable. func Surplus() int64 { return Held() - Liabilities() } // Address returns this realm's own address. func Address() address { return self } // Height returns the current chain height: the clock every window and // expiry is measured against, and the one no caller can choose. func Height() int64 { return runtime.ChainHeight() } // --- render --- // Render shows the realm at "" and a grant page at "<id>". Labels are // free text and pass through the ecosystem sanitizer before hitting // markdown. func Render(path string) string { if path != "" { return renderGrant(path) } now := runtime.ChainHeight() held := Held() liab := Liabilities() conservation := "OK" if held < liab { conservation = "VIOLATED" } out := "# Budgets\n\n" out += "Delegated spending in GNOT. Fund a pool, grant a delegate a " + "standing rate-limited authority over it, and they draw within " + "that bound without asking anyone. Revocable immediately; the " + "windows are anchored, so nobody resets a cap by choosing when " + "to call.\n\n" out += "## Terms (fixed at deploy; no admin, no setters)\n\n" out += "- draw fee: " + bps(DrawFeeBps) + " of the gross draw, paid from the owner's budget (hard cap " + bps(MaxFeeBps) + ")\n" out += "- no fee on funding, on withdrawing an unspent pool, or on claiming\n" out += "- minimum funding: " + itoa(MinFunding) + Denom + " per call\n" out += "- minimum per-window cap: " + itoa(MinPerPeriod) + Denom + "\n" out += "- window length: 0 (one lifetime window) or at least " + itoa(MinPeriodBlocks) + " blocks\n" out += "- live grants: " + itoa(LiveGrants()) + " of " + strconv.Itoa(MaxLiveGrants) + " (per owner: " + itoa(MaxGrantsPerOwner) + ")\n" out += "- current height: " + itoa(now) + "\n\n" out += "## Accounting (H == P + L + S)\n\n" out += "- pooled (P): " + itoa(totalPooled) + Denom + "\n" out += "- unclaimed delegate credit: " + itoa(ledger.UsersTotal()) + Denom + "\n" out += "- fee pot: " + itoa(ledger.FeesAccrued()) + Denom + "\n" out += "- held (H): " + itoa(held) + Denom + "\n" out += "- surplus (S): " + itoa(held-liab) + Denom + "\n" out += "- conservation: " + conservation + "\n" out += "- lifetime funded / drawn / claimed / returned: " + itoa(lifetimeFunded) + " / " + itoa(lifetimeDrawn) + " / " + itoa(lifetimeClaimed) + " / " + itoa(lifetimeReturned) + Denom + "\n\n" out += "## Live grants\n\n" if grants.Size() == 0 { out += "None yet. Fund a pool, then Grant a delegate.\n" } else { shown := 0 expired := 0 grants.ReverseIterate("", "", func(_ string, v any) bool { g := v.(*grant) a, aerr := book.Get(g.allowID) state := "live" if aerr != nil { state = "missing" } else if a.IsExpired(now) { state = "expired (reapable)" expired++ } out += "- [#" + itoa(g.id) + "](" + RealmPath + ":" + itoa(g.id) + ") [" + state + "] " + sanitize.InlineText(labelOr(g)) + " — drawable " + itoa(Drawable(g.id)) + Denom + "\n" shown++ return shown >= MaxRenderRows }) if shown < grants.Size() { out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) + " most recent of " + strconv.Itoa(grants.Size()) + " live grants (" + itoa(nextID) + " ever created; revoked and reaped records are removed). " + "Use GrantInfo(id) for any specific grant.\n" } if expired > 0 { out += "\n## Keeper work\n\nAnyone may Reap(id) an expired grant " + "to free its slot and reclaim its storage deposit: " + strconv.Itoa(expired) + " of the grants above are reapable.\n" } } return out } func renderGrant(path string) string { id, err := strconv.ParseInt(path, 10, 64) if err != nil { return "> [!WARNING]\n> invalid grant id\n" } v := grants.Get(padID(id)) if v == nil { return "> [!WARNING]\n> unknown grant id (never created, or revoked/reaped)\n" } g := v.(*grant) a, gerr := book.Get(g.allowID) if gerr != nil { // Unreachable: drop() removes the record and the allowance // together, in one call. Rendered rather than panicked so a // corrupted pair is visible instead of breaking the page. return "> [!CAUTION]\n> grant #" + itoa(g.id) + " has no backing allowance; this is a bug. Reason: " + sanitize.InlineText(gerr.Error()) + "\n" } now := runtime.ChainHeight() out := "# Grant #" + itoa(g.id) if g.label != "" { out += ": " + sanitize.InlineText(g.label) } out += "\n\n" out += "- owner: `" + g.owner.String() + "`\n" out += "- delegate: `" + g.spender.String() + "`\n" terms, pendingGoverns := termsAt(a, now) out += "- per-window cap: " + itoa(terms.PerPeriod) + Denom + "\n" if terms.PeriodLength == 0 { out += "- window: one lifetime window; the cap never refills\n" } else { out += "- window: " + itoa(terms.PeriodLength) + " blocks, anchored at " + itoa(a.Anchor) + " (now in window " + itoa(windowAt(a, now)) + ")\n" } if terms.Total == 0 { out += "- lifetime cap: none\n" } else { out += "- lifetime cap: " + itoa(terms.Total) + Denom + " (" + itoa(a.SpentTotal) + " used)\n" } if terms.ExpiresAt == 0 { out += "- expiry: none (only the owner can end it)\n" } else { out += "- expiry: block " + itoa(terms.ExpiresAt) + "\n" } out += "- draw fee: " + bps(g.feeBps) + " (snapshotted at grant time)\n" out += "- spent this window: " + itoa(spentInWindow(a, now)) + Denom + "\n" out += "- drawn in total: " + itoa(g.drawn) + Denom + " over " + itoa(g.draws) + " draws\n" out += "- created at block: " + itoa(g.createdAt) + "\n" out += "- current height: " + itoa(now) + "\n\n" authorized := a.Remaining(now) pool := poolOf(g.owner.String()) out += "## What can be drawn right now\n\n" out += "- authorized by the terms: " + itoa(authorized) + Denom + "\n" out += "- owner's unspent pool: " + itoa(pool) + Denom + "\n" drawable := authorized if pool < drawable { drawable = pool } out += "- **drawable: " + itoa(drawable) + Denom + "** (the lesser of the two)\n\n" switch { case a.IsExpired(now): out += "This grant has **expired**. Anyone may Reap(" + itoa(g.id) + ") to free its slot and reclaim its storage deposit.\n" case drawable == 0 && authorized == 0: out += "Nothing authorized at this height — the window cap is spent, " + "the lifetime cap is reached, or the grant has not started.\n" case drawable == 0: out += "Authorized but **not funded**: the owner's pool cannot cover " + "a draw. A budget is authority, not a reservation.\n" default: out += "The delegate may Draw(" + itoa(g.id) + ", amount) up to the " + "drawable figure, then Claim() to receive it.\n" } if a.HasPending { if pendingGoverns { out += "\n> [!NOTE]\n> A **looser** amendment crossed its window " + "boundary and NOW GOVERNS; the terms above are the amended " + "ones. The stored record updates on the next draw or " + "amendment, which changes nothing about what is enforced.\n" } else { out += "\n> [!NOTE]\n> A **looser** amendment is queued and takes " + "effect at the next window boundary: per-window cap " + itoa(a.Pending.PerPeriod) + Denom + ", lifetime cap " + itoa(a.Pending.Total) + ", expiry " + itoa(a.Pending.ExpiresAt) + " (0 means none). Until then the terms above are what govern.\n" } } return out } // --- internals --- func mustGet(id int64) *grant { v := grants.Get(padID(id)) if v == nil { panic("unknown grant id") } return v.(*grant) } // mustAllowance fetches the spendbook allowance behind a grant. The pair // is created and removed together, so a missing allowance is a bug in // this realm rather than a user error, and it panics rather than // reporting a zero value that a reader would take for "no authority". func mustAllowance(g *grant) spendbook.Allowance { a, err := book.Get(g.allowID) if err != nil { panic(err) } return a } // windowAt returns the anchored window index containing now, and // spentInWindow what that window has spent. // // These duplicate a line of spendbook, and the duplication is deliberate, // bounded, and worth justifying because the primitive's own at() comment // warns against exactly this. What is NOT reimplemented is the part that // warning is about: which terms are in force after a boundary, and // therefore every authority figure this realm reports — Remaining and // Drawable both call through to the primitive, and nothing here second- // guesses them. // // What is copied is pure index arithmetic over three EXPORTED fields plus // the already-documented fact that the in-window spend RESETS rather than // carries. The alternative is worse than the duplication: Allowance's // WindowIndex and SpentWindow are the last-WRITTEN pair, so between a // boundary and the next draw they describe a window the chain has left, and // a Render publishing them would print "spent this window: 100000" // immediately above "authorized by the terms: 100000". // // The two are pinned to the primitive by TestWindowRefillsAtAnchoredBoundary // rather than by inspection: it draws after crossing a boundary and asserts // spentWindow equals that draw, which can only hold if windowAt agreed with // spendbook's own index at the moment of the write. // termsAt mirrors spendbook's own at() resolution for DISPLAY: a queued // loosening GOVERNS enforcement from the first boundary after it was // queued, whether or not any write has observed the roll. The page must // derive its terms exactly as the gate does, or it understates the // owner's exposure in the window where that understatement matters most // (audit 2026-11-04, N1 -- the realm fixed this staleness class for // SpentWindow/WindowIndex via windowAt/spentInWindow and left the Terms // display uncovered). Second return reports whether the pending terms // are the governing ones. func termsAt(a spendbook.Allowance, now int64) (spendbook.Terms, bool) { if a.HasPending && windowAt(a, now) != a.WindowIndex { return a.Pending, true } return a.Terms, false } func windowAt(a spendbook.Allowance, now int64) int64 { if a.Terms.PeriodLength == 0 || now < a.Anchor { return 0 } return (now - a.Anchor) / a.Terms.PeriodLength } func spentInWindow(a spendbook.Allowance, now int64) int64 { if windowAt(a, now) != a.WindowIndex { return 0 } return a.SpentWindow } // drop removes a grant record and decrements its owner's live count. The // allowance itself is removed by the spendbook call that precedes every // caller of this helper. func drop(g *grant) { grants.Remove(padID(g.id)) decOwnerOpen(g.owner.String()) } func labelOr(g *grant) string { if g.label == "" { return "(no label)" } return g.label } func poolOf(key string) int64 { v := pools.Get(key) if v == nil { return 0 } return v.(int64) } func setPool(key string, n int64) { if n <= 0 { pools.Remove(key) return } pools.Set(key, n) } func ownerOpen(key string) int64 { v := openByOwner.Get(key) if v == nil { return 0 } return v.(int64) } func setOwnerOpen(key string, n int64) { if n <= 0 { openByOwner.Remove(key) return } openByOwner.Set(key, n) } func decOwnerOpen(key string) { setOwnerOpen(key, ownerOpen(key)-1) } func itoa(n int64) string { return strconv.FormatInt(n, 10) } func boolstr(b bool) string { if b { return "true" } return "false" } // bps renders a basis-point figure as a human percentage, exactly. func bps(n int64) string { whole := n / 100 frac := n % 100 if frac == 0 { return itoa(whole) + "%" } if frac%10 == 0 { return itoa(whole) + "." + itoa(frac/10) + "%" } pad := "" if frac < 10 { pad = "0" } return itoa(whole) + "." + pad + itoa(frac) + "%" } // padID encodes an id so avl's lexical order matches numeric order. func padID(id int64) string { s := strconv.FormatInt(id, 10) const width = 20 if len(s) >= width { return s } return zeros[:width-len(s)] + s } const zeros = "00000000000000000000" // checkedAdd returns a+b and reports whether the addition did not // overflow int64. func checkedAdd(a, b int64) (int64, bool) { sum := a + b if (b > 0 && sum < a) || (b < 0 && sum > a) { return 0, false } return sum, true }
  4. #4budgets_test.gno
  5. #5package budgets import ( "strings" "testing" "chain" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( owner1 = testutils.TestAddress("bgowner1") owner2 = testutils.TestAddress("bgowner2") delegate1 = testutils.TestAddress("bgdeleg1") delegate2 = testutils.TestAddress("bgdeleg2") mallory = testutils.TestAddress("bgmallory") ) // HARNESS NOTES, carried from the crowdfund, treasury_board and vesting // suites (measured there, relied on here): // // - testing.SetRealm records the override per FRAME INDEX: stage the // caller INLINE in this test function's own frame before a uassert // call, or inside a helper that stages and calls in one frame of its // own. Never inside a uassert closure — that staging does not take. // // - testing.SkipHeights REPLACES the whole context (OriginCaller, // CurrentRealm, OriginSend included). Re-stage after every skip. // // - Realm globals PERSIST across tests while banker state resets per // test. begin() disarms any leftover envelope and re-seeds the // realm's bank to its carried liabilities, so solvency is asserted in // DELTA form and every test tidies up after itself. // // - A panic caught by uassert does NOT roll back realm globals here, // whereas on-chain the whole transaction reverts. Every abort // asserted below happens strictly BEFORE this realm writes state, and // the one case where that is the POINT of the test // (TestDrawSolvencyRefusalConsumesNothing) asserts the untouched // state directly rather than relying on a rollback that does not // happen in this harness. // // - The SEND envelope is process-global: a staged envelope must be // cleared (SetOriginSend(nil)) before the next non-payable call, or // rejectStraySend fires and the test asserts the harness leak instead // of the guard. // // unsafe.CurrentRealm() is not available to a unit test, so `self` is // seeded from the package path the same way the chain derives it. func init() { self = chain.PackageAddress( "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets") } type baseline struct { held int64 pooled int64 credit int64 fees int64 } func begin() baseline { testing.SetOriginSend(nil) if l := Liabilities(); l > 0 { testing.IssueCoins(Address(), ugnot(l)) } return baseline{ held: Held(), pooled: TotalPooled(), credit: UnclaimedCredit(), fees: FeesAccrued(), } } func ugnot(n int64) chain.Coins { return chain.NewCoins(chain.NewCoin(Denom, n)) } // assertSolvent asserts the two halves of H == P + L + S that can actually // FAIL. The identity itself is definitional here, because Surplus() is // defined as Held() - Liabilities(), so asserting it would be a tautology // dressed as a check. What can fail is that the realm owes more than it // holds, and that L decomposes exactly into delegate credit plus the fee // pot — neither of which follows from any definition in this file. // It also re-derives the P term the slow way. totalPooled is maintained // O(1) for Render's sake, so it is a CACHE of the pools tree, and nothing // in the realm ever recomputes it — exactly the shape that drifts silently. // Sabotage found this gap rather than review: disabling the per-owner debit // in Draw while leaving totalPooled correct was detected only by a later // native-Go banker panic, never by an assertion. func assertSolvent(t *testing.T) { uassert.True(t, Held() >= Liabilities()) uassert.Equal(t, TotalPooled()+UnclaimedCredit()+FeesAccrued(), Liabilities()) sum := int64(0) pools.Iterate("", "", func(_ string, v any) bool { n := v.(int64) // setPool removes a zeroed entry rather than storing it, so every // surviving entry must be strictly positive. uassert.True(t, n > 0) sum += n return false }) uassert.Equal(t, sum, TotalPooled()) } // fundAs stages a direct EOA call with -send and mirrors the envelope into // the realm's bank, as the chain would. func fundAs(cur realm, from address, amount int64) int64 { testing.SetRealm(testing.NewUserRealm(from)) testing.SetOriginSend(ugnot(amount)) testing.IssueCoins(Address(), ugnot(amount)) n := Fund(cross(cur)) testing.SetOriginSend(nil) return n } func grantAs(cur realm, from, spender address, perPeriod, periodLength, total, expiresAt int64) int64 { testing.SetRealm(testing.NewUserRealm(from)) return Grant(cross(cur), spender, "", perPeriod, periodLength, total, expiresAt) } func drawAs(cur realm, from address, id, amount int64) int64 { testing.SetRealm(testing.NewUserRealm(from)) return Draw(cross(cur), id, amount) } func claimAs(cur realm, from address) int64 { testing.SetRealm(testing.NewUserRealm(from)) return Claim(cross(cur)) } func revokeAs(cur realm, from address, id int64) { testing.SetRealm(testing.NewUserRealm(from)) Revoke(cross(cur), id) } func withdrawAllAs(cur realm, from address) int64 { testing.SetRealm(testing.NewUserRealm(from)) return WithdrawPoolAll(cross(cur)) } func balanceOf(addr address) int64 { return coinio.HeldAt(addr, Denom) } // --- the pool --- func TestFundMakesAnOwner(cur realm, t *testing.T) { b := begin() uassert.Equal(t, int64(0), PoolOf(owner1)) pool := fundAs(cur, owner1, MinFunding) uassert.Equal(t, MinFunding, pool) uassert.Equal(t, MinFunding, PoolOf(owner1)) uassert.Equal(t, b.pooled+MinFunding, TotalPooled()) uassert.Equal(t, b.held+MinFunding, Held()) uassert.Equal(t, b.credit, UnclaimedCredit()) uassert.Equal(t, b.fees, FeesAccrued()) assertSolvent(t) // Funding again accumulates into the same pool. pool = fundAs(cur, owner1, 2*MinFunding) uassert.Equal(t, 3*MinFunding, pool) uassert.Equal(t, b.pooled+3*MinFunding, TotalPooled()) // A second owner's pool is independent. fundAs(cur, owner2, MinFunding) uassert.Equal(t, 3*MinFunding, PoolOf(owner1)) uassert.Equal(t, MinFunding, PoolOf(owner2)) assertSolvent(t) uassert.Equal(t, 3*MinFunding, withdrawAllAs(cur, owner1)) uassert.Equal(t, MinFunding, withdrawAllAs(cur, owner2)) uassert.Equal(t, b.pooled, TotalPooled()) // setPool removes a zeroed entry rather than storing 0. uassert.Equal(t, int64(0), PoolOf(owner1)) } func TestFundGuards(cur realm, t *testing.T) { begin() // coinio's receipt shape: a realm-routed payment is refused by the // primitive, not by anything this file could forget to write. testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/attacker")) testing.SetOriginSend(ugnot(MinFunding)) uassert.AbortsWithMessage(t, cur, "coinio: payment must be a direct EOA call with -send (realms and maketx-run are rejected)", func() { Fund(cross(cur)) }) testing.SetOriginSend(nil) // Wrong denomination. testing.SetRealm(testing.NewUserRealm(owner1)) testing.SetOriginSend(chain.NewCoins(chain.NewCoin("foocoin", MinFunding))) uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot", func() { Fund(cross(cur)) }) testing.SetOriginSend(nil) // No envelope at all. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot", func() { Fund(cross(cur)) }) // Below the minimum. Not issued into the bank, so the refused call // leaves no surplus behind. testing.SetRealm(testing.NewUserRealm(owner1)) testing.SetOriginSend(ugnot(MinFunding - 1)) uassert.AbortsWithMessage(t, cur, "fund at least 1000000ugnot", func() { Fund(cross(cur)) }) testing.SetOriginSend(nil) uassert.Equal(t, int64(0), PoolOf(owner1)) assertSolvent(t) } func TestWithdrawPoolIsUnconditional(cur realm, t *testing.T) { b := begin() fundAs(cur, owner1, 3*MinFunding) before := balanceOf(owner1) testing.SetRealm(testing.NewUserRealm(owner1)) left := WithdrawPool(cross(cur), MinFunding) uassert.Equal(t, 2*MinFunding, left) uassert.Equal(t, before+MinFunding, balanceOf(owner1)) uassert.Equal(t, b.pooled+2*MinFunding, TotalPooled()) assertSolvent(t) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "amount exceeds pool", func() { WithdrawPool(cross(cur), 2*MinFunding+1) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "amount must be positive", func() { WithdrawPool(cross(cur), 0) }) // A non-owner has no pool to take, and mallory cannot reach owner1's. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "no pool to withdraw", func() { WithdrawPoolAll(cross(cur)) }) uassert.Equal(t, 2*MinFunding, PoolOf(owner1)) uassert.Equal(t, 2*MinFunding, withdrawAllAs(cur, owner1)) uassert.Equal(t, b.pooled, TotalPooled()) assertSolvent(t) } // TestOwnerCanAlwaysLeave pins the documented consequence of that design: // an owner may empty the pool under a LIVE grant, and the delegate's next // draw then fails on solvency rather than being trapped half-way. A realm // that locked the capital behind the grant would be the worse realm, and // this test is here so the behaviour cannot drift silently either way. func TestOwnerCanAlwaysLeave(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0) uassert.Equal(t, int64(500_000), Drawable(id)) withdrawAllAs(cur, owner1) uassert.Equal(t, int64(0), PoolOf(owner1)) // The AUTHORITY is untouched; only the honest figure moved. uassert.Equal(t, int64(500_000), Remaining(id)) uassert.Equal(t, int64(0), Drawable(id)) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "owner's pool cannot cover this draw", func() { Draw(cross(cur), id, 1_000) }) revokeAs(cur, owner1, id) assertSolvent(t) } // TestSelfGrantRecoversThePoolForTheFeeAlone pins the premise that the // MaxLiveGrants comment rests on, and it exists because the claim that // comment USED to make rotted undetected until an adversarial audit read // it (finding Y1). The lesson was not "that number was wrong" but "a // mitigation asserted nowhere is prose", so the replacement reasoning is // asserted here. // // The reasoning is that no capital bound on grant-slot occupation can be // built out of the pool, because an owner may be its OWN delegate. Make // an owner retain MinFunding while grants are live and this is the way // around it: grant to yourself, draw the pool to zero through your own // grant, Claim the credit back. You recover everything but the draw fee, // the slot is still held, and a retention rule reads as satisfied the // whole way through. The realm permits this deliberately — refusing a // self-grant would not close it either, since a second address the same // operator controls is free. func TestSelfGrantRecoversThePoolForTheFeeAlone(cur realm, t *testing.T) { begin() feesBefore := FeesAccrued() fundAs(cur, owner1, MinFunding) // owner1 is both parties. Nothing refuses it. id := grantAs(cur, owner1, owner1, MinFunding, MinPeriodBlocks, 0, 0) // Drain the whole pool through the owner's own grant. credited := drawAs(cur, owner1, id, MinFunding) fee := MinFunding * DrawFeeBps / 10_000 uassert.Equal(t, MinFunding-fee, credited) uassert.Equal(t, int64(0), PoolOf(owner1)) // ... and take it back out. Net cost of holding the slot: the fee. uassert.Equal(t, MinFunding-fee, claimAs(cur, owner1)) uassert.Equal(t, feesBefore+fee, FeesAccrued()) // The slot is still occupied, which is the whole point: the grant // survived the pool reaching zero, and no third party can remove it // because it carries no expiry. uassert.Equal(t, int64(1), ownerOpen(owner1.String())) uassert.False(t, IsExpired(id)) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: allowance has not expired", func() { Reap(cross(cur), id) }) revokeAs(cur, owner1, id) uassert.Equal(t, int64(0), ownerOpen(owner1.String())) assertSolvent(t) } // --- grants --- func TestGrantRecordsTermsAndParties(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) h := Height() liveBefore := LiveGrants() testing.SetRealm(testing.NewUserRealm(owner1)) id := Grant(cross(cur), delegate1, "monthly ops", 50_000, MinPeriodBlocks, 200_000, h+500) uassert.Equal(t, id, NumGrants()) uassert.Equal(t, liveBefore+1, LiveGrants()) uassert.Equal(t, int64(1), GrantsOf(owner1)) uassert.Equal(t, "monthly ops", GrantLabel(id)) o, s, perPeriod, periodLength, total, expiresAt, anchor, createdAt, feeBps := GrantInfo(id) uassert.Equal(t, owner1, o) uassert.Equal(t, delegate1, s) uassert.Equal(t, int64(50_000), perPeriod) uassert.Equal(t, MinPeriodBlocks, periodLength) uassert.Equal(t, int64(200_000), total) uassert.Equal(t, h+500, expiresAt) // The anchor is the grant height: windows are anchored, not sliding, // so nobody resets a cap by choosing when to call. uassert.Equal(t, h, anchor) uassert.Equal(t, h, createdAt) uassert.Equal(t, DrawFeeBps, feeBps) spentWindow, spentTotal, windowIndex, draws := GrantUsage(id) uassert.Equal(t, int64(0), spentWindow) uassert.Equal(t, int64(0), spentTotal) uassert.Equal(t, int64(0), windowIndex) uassert.Equal(t, int64(0), draws) uassert.False(t, IsExpired(id)) revokeAs(cur, owner1, id) uassert.Equal(t, int64(0), GrantsOf(owner1)) uassert.Equal(t, liveBefore, LiveGrants()) withdrawAllAs(cur, owner1) } func TestGrantValidation(cur realm, t *testing.T) { begin() // No pool: a grant from an address with nothing behind it is refused // before any allowance is opened. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "fund a pool before granting", func() { Grant(cross(cur), delegate1, "", 50_000, MinPeriodBlocks, 0, 0) }) fundAs(cur, owner1, MinFunding) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "spender is not a valid address in canonical (lowercase) form", func() { Grant(cross(cur), address("not-an-address"), "", 50_000, MinPeriodBlocks, 0, 0) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "label exceeds 64 bytes", func() { Grant(cross(cur), delegate1, strings.Repeat("x", MaxLabelLen+1), 50_000, MinPeriodBlocks, 0, 0) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "perPeriod must be at least 1000ugnot", func() { Grant(cross(cur), delegate1, "", MinPerPeriod-1, MinPeriodBlocks, 0, 0) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "periodLength must be 0 (lifetime) or at least 100 blocks", func() { Grant(cross(cur), delegate1, "", 50_000, MinPeriodBlocks-1, 0, 0) }) // An expiry already in the past is spendbook's own refusal: an // allowance that can authorize nothing must not become a live entry. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "spendbook: invalid terms", func() { Grant(cross(cur), delegate1, "", 50_000, MinPeriodBlocks, 0, 1) }) uassert.Equal(t, int64(0), GrantsOf(owner1)) withdrawAllAs(cur, owner1) } // TestGrantRefusesUppercaseSpender measures the premise of Grant's // lowercase clause rather than asserting it from the bech32 spec. If // IsValid() ever stops accepting the uppercase spelling, the first // assertion here fails and the clause's comment must be corrected — which // is the point of measuring it. func TestGrantRefusesUppercaseSpender(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) upper := address(strings.ToUpper(string(delegate1))) uassert.True(t, upper != delegate1) uassert.True(t, upper.IsValid()) // the hazard, measured on this toolchain testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "spender is not a valid address in canonical (lowercase) form", func() { Grant(cross(cur), upper, "", 50_000, MinPeriodBlocks, 0, 0) }) uassert.Equal(t, int64(0), GrantsOf(owner1)) withdrawAllAs(cur, owner1) } // TestGrantPerOwnerCap covers the gap spendbook's own documentation names: // maxLive is a single global figure, so without a per-owner cap one owner // could exhaust it and lock everyone else out. func TestGrantPerOwnerCap(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) ids := []int64{} for i := int64(0); i < MaxGrantsPerOwner; i++ { ids = append(ids, grantAs(cur, owner1, delegate1, MinPerPeriod, MinPeriodBlocks, 0, 0)) } uassert.Equal(t, MaxGrantsPerOwner, GrantsOf(owner1)) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "owner already has 16 live grants", func() { Grant(cross(cur), delegate1, "", MinPerPeriod, MinPeriodBlocks, 0, 0) }) // A DIFFERENT owner is unaffected — the cap is per-owner, not global. fundAs(cur, owner2, MinFunding) other := grantAs(cur, owner2, delegate2, MinPerPeriod, MinPeriodBlocks, 0, 0) uassert.Equal(t, int64(1), GrantsOf(owner2)) // Revoking frees a slot, and the id is not reused. revokeAs(cur, owner1, ids[0]) uassert.Equal(t, MaxGrantsPerOwner-1, GrantsOf(owner1)) fresh := grantAs(cur, owner1, delegate1, MinPerPeriod, MinPeriodBlocks, 0, 0) uassert.True(t, fresh > ids[len(ids)-1]) for _, id := range ids[1:] { revokeAs(cur, owner1, id) } revokeAs(cur, owner1, fresh) revokeAs(cur, owner2, other) uassert.Equal(t, int64(0), GrantsOf(owner1)) uassert.Equal(t, int64(0), GrantsOf(owner2)) withdrawAllAs(cur, owner1) withdrawAllAs(cur, owner2) assertSolvent(t) } func TestGrantAndAmendAreOwnerOnly(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 50_000, MinPeriodBlocks, 0, 0) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "owner only", func() { Amend(cross(cur), id, 10_000, 0, 0) }) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "owner only", func() { Revoke(cross(cur), id) }) // Not even the delegate may amend or revoke their own grant. testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "owner only", func() { Amend(cross(cur), id, 10_000, 0, 0) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "unknown grant id", func() { Revoke(cross(cur), 999_999) }) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) } // --- drawing --- func TestDrawIsDelegateOnlyAndCreditsNetOfFee(cur realm, t *testing.T) { b := begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0) // Nobody but the named delegate — not mallory, and not the owner. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "delegate only", func() { Draw(cross(cur), id, 1_000) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "delegate only", func() { Draw(cross(cur), id, 1_000) }) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "amount must be positive", func() { Draw(cross(cur), id, 0) }) heldBefore := Held() credited := drawAs(cur, delegate1, id, 100_000) // 1% of the gross, floor-rounded in the delegate's favour. uassert.Equal(t, int64(99_000), credited) uassert.Equal(t, int64(99_000), CreditOf(delegate1)) uassert.Equal(t, b.fees+1_000, FeesAccrued()) // The owner's pool is debited the GROSS: the fee is paid out of the // budget, not out of the delegate's authority. uassert.Equal(t, MinFunding-100_000, PoolOf(owner1)) uassert.Equal(t, b.pooled+MinFunding-100_000, TotalPooled()) // Draw moves value between P and L only. H does not move at all, which // is why this transition has no payout that could fail. uassert.Equal(t, heldBefore, Held()) assertSolvent(t) spentWindow, spentTotal, _, draws := GrantUsage(id) uassert.Equal(t, int64(100_000), spentWindow) uassert.Equal(t, int64(100_000), spentTotal) uassert.Equal(t, int64(1), draws) uassert.Equal(t, int64(400_000), Remaining(id)) // Claim is the separate transaction that moves coins. before := balanceOf(delegate1) paid := claimAs(cur, delegate1) uassert.Equal(t, int64(99_000), paid) uassert.Equal(t, before+99_000, balanceOf(delegate1)) uassert.Equal(t, int64(0), CreditOf(delegate1)) uassert.Equal(t, b.credit, UnclaimedCredit()) assertSolvent(t) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "no credit to claim", func() { Claim(cross(cur)) }) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } // TestDrawSolvencyRefusalConsumesNothing is this realm's central // invariant, and the ordering inside Draw is what it proves: the solvency // check runs BEFORE spendbook.Spend, so a draw refused for insolvency // leaves the window capacity intact rather than burning it on a draw that // never happened. // // This is also the one abort in this file where the post-state is the // assertion rather than an afterthought, because uassert does not roll // back realm globals the way a reverted transaction would. func TestDrawSolvencyRefusalConsumesNothing(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) // Authority deliberately EXCEEDS the pool: a budget is authority, not // a reservation, and the realm's promise is that it never credits what // it cannot pay. id := grantAs(cur, owner1, delegate1, 5*MinFunding, MinPeriodBlocks, 0, 0) uassert.Equal(t, 5*MinFunding, Remaining(id)) uassert.Equal(t, MinFunding, Drawable(id)) // the honest figure testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "owner's pool cannot cover this draw", func() { Draw(cross(cur), id, MinFunding+1) }) // Nothing moved, and — the part that matters — nothing was CONSUMED. spentWindow, spentTotal, _, draws := GrantUsage(id) uassert.Equal(t, int64(0), spentWindow) uassert.Equal(t, int64(0), spentTotal) uassert.Equal(t, int64(0), draws) uassert.Equal(t, 5*MinFunding, Remaining(id)) uassert.Equal(t, MinFunding, PoolOf(owner1)) uassert.Equal(t, int64(0), CreditOf(delegate1)) assertSolvent(t) // Exactly the drawable figure succeeds, which is what makes the // refusal above a boundary rather than a blanket. uassert.Equal(t, int64(990_000), drawAs(cur, delegate1, id, MinFunding)) uassert.Equal(t, int64(0), PoolOf(owner1)) claimAs(cur, delegate1) revokeAs(cur, owner1, id) tidyFees(cur, t) assertSolvent(t) } func TestDrawFeeRoundingFavorsTheDelegate(cur realm, t *testing.T) { b := begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 50_000, MinPeriodBlocks, 0, 0) // 1% of 99 floors to 0, so the delegate keeps all of it. There is // deliberately no minimum draw: a floor above a grant's PerPeriod // would make the grant silently undrawable. uassert.Equal(t, int64(99), drawAs(cur, delegate1, id, 99)) uassert.Equal(t, b.fees, FeesAccrued()) uassert.Equal(t, int64(99), CreditOf(delegate1)) // 100 is the first amount that pays a fee at all. uassert.Equal(t, int64(99), drawAs(cur, delegate1, id, 100)) uassert.Equal(t, b.fees+1, FeesAccrued()) uassert.Equal(t, int64(198), CreditOf(delegate1)) assertSolvent(t) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } // TestWindowRefillsAtAnchoredBoundary is spendbook consumer obligation 1 // observed from the consuming side: the clock is runtime.ChainHeight() and // no caller supplies it, so the window cannot be rolled on demand. The // refill is a RESET, not a carry. func TestWindowRefillsAtAnchoredBoundary(cur realm, t *testing.T) { begin() fundAs(cur, owner1, 10*MinFunding) id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0) drawAs(cur, delegate1, id, 100_000) uassert.Equal(t, int64(0), Remaining(id)) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap", func() { Draw(cross(cur), id, 1) }) // One block short of the boundary: still refused. testing.SkipHeights(MinPeriodBlocks - 1) uassert.Equal(t, int64(0), Remaining(id)) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap", func() { Draw(cross(cur), id, 1) }) // The boundary itself. The window index advances and the cap is full // again — not 100_000 plus a carried remainder. testing.SkipHeights(1) uassert.Equal(t, int64(100_000), Remaining(id)) _, _, windowIndex, _ := GrantUsage(id) uassert.Equal(t, int64(1), windowIndex) // THE STALE-PAIR CASE, and it is asserted here rather than left to // inference because this is the only moment it is observable: the // chain has crossed a boundary and NOTHING has been written since, so // Allowance.WindowIndex still names window 0 and Allowance.SpentWindow // still holds window 0's 100_000. Reading either field straight off // the record would publish "spent this window: 100000" directly above // "authorized by the terms: 100000" — a page contradicting itself. // spentInWindow is what makes the first figure 0, and this assertion // is what keeps it that way. staleWindow, _, _, _ := GrantUsage(id) uassert.Equal(t, int64(0), staleWindow) page := Render(itoa(id)) uassert.True(t, strings.Contains(page, "spent this window: 0ugnot")) uassert.True(t, strings.Contains(page, "now in window 1")) drawAs(cur, delegate1, id, 100_000) _, spentTotal, _, draws := GrantUsage(id) uassert.Equal(t, int64(200_000), spentTotal) // lifetime accumulates uassert.Equal(t, int64(2), draws) spentWindow, _, _, _ := GrantUsage(id) uassert.Equal(t, int64(100_000), spentWindow) // the window reset assertSolvent(t) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } func TestLifetimeTotalBindsAcrossARollover(cur realm, t *testing.T) { begin() fundAs(cur, owner1, 10*MinFunding) // Lifetime cap at 1.5 windows: the refill cannot outrun it. id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 150_000, 0) drawAs(cur, delegate1, id, 100_000) testing.SkipHeights(MinPeriodBlocks) // The window refilled to 100_000 but the lifetime cap leaves 50_000, // and Remaining reports the LESSER of the two. uassert.Equal(t, int64(50_000), Remaining(id)) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the lifetime cap", func() { Draw(cross(cur), id, 50_001) }) drawAs(cur, delegate1, id, 50_000) uassert.Equal(t, int64(0), Remaining(id)) // A further window brings no further authority: the lifetime cap is // absolute. testing.SkipHeights(MinPeriodBlocks) uassert.Equal(t, int64(0), Remaining(id)) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the lifetime cap", func() { Draw(cross(cur), id, 1) }) assertSolvent(t) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } // TestLifetimeWindowNeverRefills covers periodLength == 0: ONE window for // the grant's whole life. It is a distinct code path in spendbook // (windowIndex is pinned to 0) and a distinct product shape — a one-off // authority rather than a recurring budget. func TestLifetimeWindowNeverRefills(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 100_000, 0, 0, 0) // GrantInfo's fourth result is periodLength; the third is perPeriod. _, _, _, periodLength, _, _, _, _, _ := GrantInfo(id) uassert.Equal(t, int64(0), periodLength) drawAs(cur, delegate1, id, 100_000) uassert.Equal(t, int64(0), Remaining(id)) testing.SkipHeights(10 * MinPeriodBlocks) uassert.Equal(t, int64(0), Remaining(id)) _, _, windowIndex, _ := GrantUsage(id) uassert.Equal(t, int64(0), windowIndex) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap", func() { Draw(cross(cur), id, 1) }) // And a LOOSENING cannot be queued against a grant with no boundary to // wait for — spendbook refuses rather than accepting an amendment that // would never take effect. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "spendbook: no reachable next window boundary", func() { Amend(cross(cur), id, 200_000, 0, 0) }) assertSolvent(t) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } // --- amendment --- func TestAmendIsOneSided(cur realm, t *testing.T) { begin() fundAs(cur, owner1, 10*MinFunding) id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0) // A TIGHTENING takes effect immediately, mid-window. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.True(t, Amend(cross(cur), id, 20_000, 0, 0)) uassert.Equal(t, int64(20_000), Remaining(id)) _, _, pending := pendingOf(id) uassert.False(t, pending) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap", func() { Draw(cross(cur), id, 20_001) }) // A LOOSENING is queued for the next boundary, not applied now. That // asymmetry is the whole guarantee: the delegate's exposure in the // current window can only ever shrink. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.False(t, Amend(cross(cur), id, 300_000, 0, 0)) uassert.Equal(t, int64(20_000), Remaining(id)) perPeriod, _, pending := pendingOf(id) uassert.True(t, pending) uassert.Equal(t, int64(300_000), perPeriod) testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap", func() { Draw(cross(cur), id, 20_001) }) // At the boundary the pending terms become the live ones. testing.SkipHeights(MinPeriodBlocks) uassert.Equal(t, int64(300_000), Remaining(id)) drawAs(cur, delegate1, id, 300_000) assertSolvent(t) // The realm's own floor still applies to an amendment. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "perPeriod must be at least 1000ugnot", func() { Amend(cross(cur), id, MinPerPeriod-1, 0, 0) }) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } // TestAmendCannotMovePeriodLength asserts a STRUCTURAL property, not a // refusal: Amend has no periodLength parameter at all, so moving every // future boundary is not merely rejected, it is unexpressible. The realm // reads the grant's own periodLength and passes it back unchanged. func TestAmendCannotMovePeriodLength(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0) _, _, _, before, _, _, anchorBefore, _, _ := GrantInfo(id) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.True(t, Amend(cross(cur), id, 10_000, 0, 0)) _, _, _, after, _, _, anchorAfter, _, _ := GrantInfo(id) uassert.Equal(t, before, after) uassert.Equal(t, MinPeriodBlocks, after) // The anchor is immovable too, so the boundaries stay where they were. uassert.Equal(t, anchorBefore, anchorAfter) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) } // --- ending a grant --- func TestRevokeEndsAuthorityButNotEarnedCredit(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0) drawAs(cur, delegate1, id, 100_000) uassert.Equal(t, int64(99_000), CreditOf(delegate1)) revokeAs(cur, owner1, id) // The grant record is gone and the id is never reused, so a later draw // fails as unknown rather than as unauthorized. testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "unknown grant id", func() { Draw(cross(cur), id, 1) }) testing.SetRealm(testing.NewUserRealm(owner1)) uassert.AbortsWithMessage(t, cur, "unknown grant id", func() { Revoke(cross(cur), id) }) // Credit already drawn is the delegate's. Revoke ends FUTURE authority // and claws back nothing. uassert.Equal(t, int64(99_000), CreditOf(delegate1)) before := balanceOf(delegate1) uassert.Equal(t, int64(99_000), claimAs(cur, delegate1)) uassert.Equal(t, before+99_000, balanceOf(delegate1)) assertSolvent(t) withdrawAllAs(cur, owner1) tidyFees(cur, t) } func TestReapIsPermissionlessButOnlyForExpired(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) h := Height() id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, h+200) noExpiry := grantAs(cur, owner1, delegate2, 100_000, MinPeriodBlocks, 0, 0) // Live: refused, for anyone. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "spendbook: allowance has not expired", func() { Reap(cross(cur), id) }) uassert.False(t, IsExpired(id)) testing.SkipHeights(200) uassert.True(t, IsExpired(id)) // An expired allowance can authorize nothing, so its own delegate is // refused too — and a stranger may clear it. testing.SetRealm(testing.NewUserRealm(delegate1)) uassert.AbortsWithMessage(t, cur, "spendbook: allowance has expired", func() { Draw(cross(cur), id, 1) }) liveBefore := LiveGrants() testing.SetRealm(testing.NewUserRealm(mallory)) Reap(cross(cur), id) uassert.Equal(t, liveBefore-1, LiveGrants()) uassert.Equal(t, int64(1), GrantsOf(owner1)) // A grant with NO expiry can never be reaped: only its owner ends it. uassert.False(t, IsExpired(noExpiry)) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "spendbook: allowance has not expired", func() { Reap(cross(cur), noExpiry) }) revokeAs(cur, owner1, noExpiry) withdrawAllAs(cur, owner1) assertSolvent(t) } // --- fees, surplus, and the absence of an admin --- func TestWithdrawFeesGoesOnlyToTheFixedCollector(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0) drawAs(cur, delegate1, id, 500_000) uassert.Equal(t, int64(5_000), FeesAccrued()) // Permissionless, because the destination is a compile-time constant: // there is nothing for a caller to steer. before := balanceOf(FeeCollector) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.Equal(t, int64(5_000), WithdrawFees(cross(cur))) uassert.Equal(t, before+5_000, balanceOf(FeeCollector)) uassert.Equal(t, int64(0), FeesAccrued()) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "no fees accrued", func() { WithdrawFees(cross(cur)) }) assertSolvent(t) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) } func TestSweepSurplusCannotReachTrackedLiabilities(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0) drawAs(cur, delegate1, id, 200_000) // Nothing out of band yet: the reserve is everything the realm holds, // and the primitive ABORTS rather than reporting a transfer of zero. uassert.Equal(t, int64(0), Surplus()) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "coinio: no surplus to sweep for ugnot", func() { SweepSurplus(cross(cur), Denom) }) uassert.Equal(t, MinFunding-200_000, PoolOf(owner1)) uassert.Equal(t, int64(198_000), CreditOf(delegate1)) uassert.Equal(t, int64(2_000), FeesAccrued()) // A foreign denom the realm has never held is the same answer: there // is nothing to move, so nothing claims to have moved. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "coinio: no surplus to sweep for foocoin", func() { SweepSurplus(cross(cur), "foocoin") }) // Coins arriving outside any entrypoint ARE sweepable, and only those. testing.IssueCoins(Address(), ugnot(777)) uassert.Equal(t, int64(777), Surplus()) before := balanceOf(FeeCollector) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.Equal(t, int64(777), SweepSurplus(cross(cur), Denom)) uassert.Equal(t, before+777, balanceOf(FeeCollector)) uassert.Equal(t, int64(0), Surplus()) // Every tracked term survived the sweep untouched. uassert.Equal(t, MinFunding-200_000, PoolOf(owner1)) uassert.Equal(t, int64(198_000), CreditOf(delegate1)) uassert.Equal(t, int64(2_000), FeesAccrued()) assertSolvent(t) claimAs(cur, delegate1) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) tidyFees(cur, t) } // TestStraySendIsRejectedOnEveryNonPayableEntrypoint sweeps the whole // non-payable surface. A coin attached to any of these would otherwise // become sweepable surplus — silently converting a user's coins into // operator funds — so aborting and reverting the transfer is the only safe // answer. The count assertion is deliberate: a table that silently lost // its rows would otherwise report green. func TestStraySendIsRejectedOnEveryNonPayableEntrypoint(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0) // No IssueCoins: the envelope is staged but never mirrored into the // bank, so a refused call cannot leave surplus behind. calls := []func(){ func() { WithdrawPool(cross(cur), 1) }, func() { WithdrawPoolAll(cross(cur)) }, func() { Grant(cross(cur), delegate2, "", 100_000, MinPeriodBlocks, 0, 0) }, func() { Amend(cross(cur), id, 100_000, 0, 0) }, func() { Revoke(cross(cur), id) }, func() { Reap(cross(cur), id) }, func() { Draw(cross(cur), id, 1_000) }, func() { Claim(cross(cur)) }, func() { WithdrawFees(cross(cur)) }, func() { SweepSurplus(cross(cur), Denom) }, } uassert.Equal(t, 10, len(calls)) for _, call := range calls { testing.SetRealm(testing.NewUserRealm(owner1)) testing.SetOriginSend(ugnot(1)) uassert.AbortsWithMessage(t, cur, "this entrypoint does not accept coins", call) testing.SetOriginSend(nil) } // The guard runs FIRST in every one of them, which is why even calls // that would have failed on their own arguments abort with this // message — and why nothing above changed any state. uassert.Equal(t, MinFunding, PoolOf(owner1)) uassert.Equal(t, int64(1), GrantsOf(owner1)) uassert.Equal(t, int64(100_000), Remaining(id)) assertSolvent(t) revokeAs(cur, owner1, id) withdrawAllAs(cur, owner1) } // TestDrawableIsTheLesserOfAuthorityAndPool pins the realm's own // contribution to the view surface. spendbook bounds PERMISSION and says // so; it never speaks about solvency. Remaining is the authority figure // and Drawable is the honest one, and conflating them is what would make // the realm promise what it does not hold. func TestDrawableIsTheLesserOfAuthorityAndPool(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) // Authority below the pool: authority binds. small := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0) uassert.Equal(t, int64(100_000), Remaining(small)) uassert.Equal(t, int64(100_000), Drawable(small)) // Authority above the pool: the pool binds, and Remaining does NOT // move — the two views answer different questions. big := grantAs(cur, owner1, delegate2, 5*MinFunding, MinPeriodBlocks, 0, 0) uassert.Equal(t, 5*MinFunding, Remaining(big)) uassert.Equal(t, MinFunding, Drawable(big)) // Two grants, one pool: three grants of 100 against a pool of 100 are // all three valid and the first delegate to draw gets the money. The // sum of what is drawable deliberately EXCEEDS the pool, because a // budget is authority and not a reservation. uassert.True(t, Drawable(small)+Drawable(big) > PoolOf(owner1)) drawAs(cur, delegate1, small, 100_000) uassert.Equal(t, MinFunding-100_000, Drawable(big)) // the other shrank claimAs(cur, delegate1) revokeAs(cur, owner1, small) revokeAs(cur, owner1, big) withdrawAllAs(cur, owner1) tidyFees(cur, t) } func TestViewsRefuseUnknownIds(cur realm, t *testing.T) { begin() // PanicsWithMessage, not AbortsWithMessage, and the distinction is the // view/entrypoint one rather than a style choice. revive() catches an // ABORT, which is what a panic becomes when it crosses a realm // boundary; these six are non-crossing view functions called straight // from the test's own frame, so their panic never crosses anything and // stays a plain panic. Every other refusal asserted in this file goes // through a crossing helper (drawAs, grantAs, …) and is correctly an // abort. Getting this backwards does not weaken the assertion — it // fails loudly, which is how it was found. uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { GrantInfo(999_999) }) uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { GrantUsage(999_999) }) uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { GrantLabel(999_999) }) uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { Remaining(999_999) }) uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { Drawable(999_999) }) uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { IsExpired(999_999) }) // The zero values of the non-panicking views are "nothing", not an // error, which is correct for an address that simply has no state. uassert.Equal(t, int64(0), PoolOf(mallory)) uassert.Equal(t, int64(0), CreditOf(mallory)) uassert.Equal(t, int64(0), GrantsOf(mallory)) } // --- render --- func TestRenderPages(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) h := Height() testing.SetRealm(testing.NewUserRealm(owner1)) id := Grant(cross(cur), delegate1, "ops <script>alert(1)</script>", 100_000, MinPeriodBlocks, 500_000, h+1_000) drawAs(cur, delegate1, id, 40_000) index := Render("") uassert.True(t, strings.Contains(index, "# Budgets")) uassert.True(t, strings.Contains(index, "conservation: OK")) uassert.True(t, strings.Contains(index, "[live]")) uassert.True(t, strings.Contains(index, "#"+itoa(id))) // The free-text label reaches markdown only through the ecosystem // sanitizer, so the raw tag must not survive into the page. uassert.False(t, strings.Contains(index, "<script>")) page := Render(itoa(id)) uassert.True(t, strings.Contains(page, "# Grant #"+itoa(id))) uassert.True(t, strings.Contains(page, owner1.String())) uassert.True(t, strings.Contains(page, delegate1.String())) uassert.True(t, strings.Contains(page, "spent this window: 40000ugnot")) uassert.True(t, strings.Contains(page, "authorized by the terms: 60000ugnot")) uassert.True(t, strings.Contains(page, "drawable: 60000ugnot")) uassert.True(t, strings.Contains(page, "draw fee: 1%")) uassert.False(t, strings.Contains(page, "<script>")) // The not-funded case is called by its name rather than shown as a // bare zero, because "authorized but unfunded" and "nothing // authorized" are different situations for a delegate. withdrawAllAs(cur, owner1) page = Render(itoa(id)) uassert.True(t, strings.Contains(page, "authorized by the terms: 60000ugnot")) uassert.True(t, strings.Contains(page, "drawable: 0ugnot")) uassert.True(t, strings.Contains(page, "Authorized but **not funded**")) // A queued loosening is disclosed on the page, with the terms that // actually govern until the boundary. testing.SetRealm(testing.NewUserRealm(owner1)) uassert.False(t, Amend(cross(cur), id, 900_000, 0, 0)) page = Render(itoa(id)) uassert.True(t, strings.Contains(page, "A **looser** amendment is queued")) // Expiry, and the keeper prompt that comes with it. testing.SkipHeights(1_000) page = Render(itoa(id)) uassert.True(t, strings.Contains(page, "has **expired**")) uassert.True(t, strings.Contains(page, "Reap("+itoa(id)+")")) index = Render("") uassert.True(t, strings.Contains(index, "expired (reapable)")) uassert.True(t, strings.Contains(index, "## Keeper work")) // Bad paths render a warning rather than panicking a page. uassert.True(t, strings.Contains(Render("not-a-number"), "invalid grant id")) uassert.True(t, strings.Contains(Render("999999"), "unknown grant id")) testing.SetRealm(testing.NewUserRealm(mallory)) Reap(cross(cur), id) claimAs(cur, delegate1) tidyFees(cur, t) uassert.True(t, strings.Contains(Render(""), "None yet")) } func TestRenderIndexIsBounded(cur realm, t *testing.T) { begin() fundAs(cur, owner1, MinFunding) fundAs(cur, owner2, MinFunding) ids := []int64{} for i := int64(0); i < int64(MaxRenderRows+2); i++ { from, to := owner1, delegate1 if i >= MaxGrantsPerOwner { from, to = owner2, delegate2 } ids = append(ids, grantAs(cur, from, to, MinPerPeriod, MinPeriodBlocks, 0, 0)) } uassert.Equal(t, MaxRenderRows+2, len(ids)) index := Render("") uassert.True(t, strings.Contains(index, "Showing the 20 most recent of 22")) // Newest first, and the two oldest are off the page. uassert.True(t, strings.Contains(index, "#"+itoa(ids[len(ids)-1]))) uassert.False(t, strings.Contains(index, "#"+itoa(ids[0])+"]")) for i, id := range ids { from := owner1 if int64(i) >= MaxGrantsPerOwner { from = owner2 } revokeAs(cur, from, id) } withdrawAllAs(cur, owner1) withdrawAllAs(cur, owner2) assertSolvent(t) } // --- the realm's own arithmetic --- func TestBpsRendersExactly(t *testing.T) { uassert.Equal(t, "1%", bps(100)) uassert.Equal(t, "5%", bps(500)) uassert.Equal(t, "0%", bps(0)) uassert.Equal(t, "0.5%", bps(50)) uassert.Equal(t, "0.05%", bps(5)) uassert.Equal(t, "2.25%", bps(225)) uassert.Equal(t, "100%", bps(10_000)) } func TestPadIDOrdersNumerically(t *testing.T) { uassert.True(t, padID(2) > padID(1)) uassert.True(t, padID(10) > padID(9)) uassert.True(t, padID(100) > padID(99)) uassert.Equal(t, 20, len(padID(1))) uassert.Equal(t, 20, len(padID(1_000_000))) } func TestCheckedAddRefusesOverflow(t *testing.T) { const max = int64(1<<63 - 1) sum, ok := checkedAdd(2, 3) uassert.True(t, ok) uassert.Equal(t, int64(5), sum) _, ok = checkedAdd(max, 1) uassert.False(t, ok) _, ok = checkedAdd(max-1, 2) uassert.False(t, ok) sum, ok = checkedAdd(max-1, 1) uassert.True(t, ok) uassert.Equal(t, max, sum) } // --- helpers used by the cases above --- // pendingOf unpacks PendingTerms without the four-value call site noise. func pendingOf(id int64) (perPeriod, total int64, pending bool) { p, tot, _, has := PendingTerms(id) return p, tot, has } // tidyFees drains the fee pot between cases so each test's fee // assertions are absolute rather than cumulative. It is a test fixture, // not a privileged path: WithdrawFees is permissionless and its // destination is a compile-time constant, so there is nothing here a real // caller could not also do. func tidyFees(cur realm, t *testing.T) { if FeesAccrued() == 0 { return } testing.SetRealm(testing.NewUserRealm(mallory)) WithdrawFees(cross(cur)) uassert.Equal(t, int64(0), FeesAccrued()) } // TestRenderShowsGoverningTermsPastAnUnobservedBoundary pins the audit // 2026-11-04 N1 repair. A queued loosening GOVERNS enforcement from the // first boundary after it was queued -- spendbook's at() applies Pending // whenever the window has rolled -- but the stored record still holds // the old Terms until the next write. The grant page used to print the // old per-window cap with a note claiming "the terms above are what // govern": risk-understatement to the owner, on the same page whose // "authorized by the terms" figure already used the pending cap. The // page must derive its terms exactly as the gate does. func TestRenderShowsGoverningTermsPastAnUnobservedBoundary(cur realm, t *testing.T) { ownr := testutils.TestAddress("n1-owner") spndr := testutils.TestAddress("n1-spender") fundAs(cur, ownr, 2_000_000) id := grantAs(cur, ownr, spndr, 1_000, MinPeriodBlocks, 0, 0) // Queue a loosening: raised per-window cap. It must NOT govern yet. testing.SetRealm(testing.NewUserRealm(ownr)) applied := Amend(cross(cur), id, 2_000, 0, 0) uassert.False(t, applied) // queued, not applied page := Render(itoa(id)) if !strings.Contains(page, "per-window cap: 1000ugnot") || !strings.Contains(page, "Until then the terms above are what govern") { t.Fatalf("pre-boundary page wrong:\n%s", page) } // Cross the boundary with NO intervening write. The pending terms // now govern enforcement (at() applies them); the page must say so. testing.SkipHeights(MinPeriodBlocks) page = Render(itoa(id)) if !strings.Contains(page, "per-window cap: 2000ugnot") { t.Fatalf("page still prints the superseded cap past the boundary:\n%s", page) } if strings.Contains(page, "Until then the terms above are what govern") { t.Fatalf("page still claims the stored terms govern past the boundary") } }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets" gno = "0.9"
  8. #8/gno.MemPackageType
  9. #9 MPUserAll

Result log

msg:0,success:true,log:,events:[]