Transaction
1BB554BCB365F4…27854B743BB3
Block 1,314,523 · index 0 · indexed
Summary
- Hash
- 1BB554BCB365F4E203A3C7BEA2C7BCBF75C0DFDEFB130862D37227854B743BB3
- Block
- 1,314,523
- Size
- 105334 bytes
- Gas used
- 136,359,685 / 250,000,000
- Fee
- 250000 ugnot
- Status
- success
Messages
- Attached funds
- 20000000 ugnot
Arguments · 9
- #1spendbook
- #2gnomod.toml
- #3module = "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/spendbook/v2" gno = "0.9"
- #4spendbook.gno
- #5// Package spendbook is a pure allowance primitive for realms that need to // let one party spend another party's funds, up to a bounded amount, over // many transactions. It is to *delegated spending* what duebook is to // *time*: it holds no coins, performs no effects, imports no chain APIs, // and owns no package-level state. The importing realm owns a *Book, // supplies the clock, and performs its own effects. // // # Why v2 exists // // v1 of this package is deployed and immutable. Its API is unchanged here; // ONE method body differs, and it is a correctness fix: // // v1: func (a Allowance) IsExpired(now int64) bool { // return a.Terms.ExpiresAt != 0 && now >= a.Terms.ExpiresAt // } // // a.Terms is the LAST-WRITTEN record, not the terms in force. Every other // live question in this package is routed through at(), which resolves a // pending amendment at the window boundary it is due at; IsExpired was the // one exported predicate that read the stored field instead. Because an // amendment that moves ExpiresAt in the LOOSER direction can only ever be // QUEUED -- tighterOrEqualTo refuses both "push the expiry out" and "remove // the expiry" as immediate -- queueing is the only path such an amendment // has, so the stale read was on that path every time. // // The consequence is not one bug but two, pointing opposite ways, and the // second is the dangerous one: // // - UNDER-authorization. An owner extends the expiry. The amendment is // accepted and queued, the boundary arrives, at() puts the new expiry in // force -- and IsExpired still answers from the old one. At the OLD // expiry: Remaining reports 0, Spend returns ErrExpired, and Reap, which // is PERMISSIONLESS, lets any third party delete an allowance the owner // had just extended. The owner's accepted amendment is silently // discarded, which the v1 doc calls "the failure mode this package is // least able to explain after the fact" -- about a different case, in // Amend, four lines from the guard that does not cover this one. // // - OVER-authorization. An amendment may be looser in PerPeriod and // TIGHTER in ExpiresAt; tighterOrEqualTo is weakly-tighter in EVERY // dimension, so that mix queues as a whole. Past the boundary, at() // hands Spend the new PerPeriod while IsExpired reports the allowance // alive on the OLD, later expiry. The spender draws -- at the raised cap // -- past an expiry the owner themselves set. No bound in this package // stops it, because the bound was moved and the gate was not told. // // Both are repaired by resolving the expiry through at(). Neither could be // repaired in v1: a /p/ is immutable after addpkg, and v1's two live // consumers are themselves immutable realms with v1's bytes compiled into // every path they run. v1 therefore stays live and keeps the defect; it is // not retired by this package existing, and nothing here migrates it. // // The v1 workaround, for a consumer stuck on it: restating the SAME // amendment inside [next boundary, old expiry) applies it immediately -- // Amend rolls the window first, promoting the pending terms into a.Terms, // and the restatement is then equal-to rather than looser. It is owner-only, // moves no funds, and needs no spend. It must be done BEFORE the old expiry; // afterwards v1's Amend refuses with ErrExpired, and Revoke plus a fresh // Open is the only route left. // // # Why this package exists // // The ecosystem's existing allowance primitive, // gno.land/p/jaekwon/allowancesender/v0, is scoped to a single call and // says so in its own documentation: // // "Allowance survives across N txs but not N+1 txs." Use a session // counter pattern in the granter realm; this package's Close is // binary, not deadline-based. // // It then declines block-height expiry on the grounds that it "would // require runtime cooperation". That is true only for a package that reads // the clock itself. spendbook does not: every operation takes `now` as an // argument, which makes the window arithmetic a pure function and the // whole package unit-testable without a chain. Three application realms in // the searched sources each hand-rolled this mechanism internally rather // than importing one; see DISCOVERY.md. // // # The one idea // // An allowance is not a balance. It is a *permission with a ceiling that // refills*. The owner says "this spender may draw at most PerPeriod per // window, at most Total ever, until ExpiresAt, and I can revoke it now": // // if err := book.Spend(id, spenderKey, now, amount); err != nil { // panic(err) // revoked, expired, over the window cap, over the total // } // // ... the realm moves the coins here, under its own authority // // Spend checks every bound and CONSUMES the allowance in the same call, // before returning. The realm then acts. Because the realm performs the // effect itself, no closure, callback, or capability ever crosses a realm // boundary. spendbook cannot be handed code to run, so it cannot be // tricked into running the wrong code. // // # Windows are anchored, not sliding // // A window is derived from a fixed origin — the Anchor set at Open — by // integer division: // // index(now) = (now - Anchor) / PeriodLength // // so the boundaries are the same for everyone and a spender cannot reset a // window by choosing when to call. A sliding "PerPeriod per trailing N // blocks" window would let a spender draw the full cap at the end of one // trailing span and again at the start of the next; anchoring removes the // choice. The refill is a RESET, not a carry: an unused allowance does not // accumulate into the next window, because the point of the cap is to bound // worst-case loss per window, and a carry would unbound it. // // PeriodLength == 0 means one window that never rolls: a pure lifetime // allowance, where PerPeriod is the only cap that matters. // // # Amendments cannot enlarge the live window // // An owner who could raise PerPeriod mid-window would be able to undo the // bound a spender is relying on, and — worse in the other direction — an // owner who drains a relationship and then lowers terms retroactively // could strand a spender mid-settlement. The rule here is one-sided and // enforced in Amend: // // - TIGHTER-OR-EQUAL terms apply IMMEDIATELY. Lowering a ceiling can // never authorize a draw that was not already authorized, so there is // no reason to delay it, and delaying it would make the owner's only // fast remedy Revoke. // - LOOSER terms are PENDING and take effect at the next window // boundary, never inside the current one. // // This is the same adversarial problem that r/samcrew/agent_registry_v2 // solves by locking its price while any credit entry exists; the shape // here is a deferral rather than a lock, so terms can still move. // // With PeriodLength == 0 there is no next boundary, so a loosening // amendment is refused with ErrNoBoundary rather than silently queued // forever. PeriodLength itself is IMMUTABLE: changing it would move every // future boundary, which is an enlargement vector wearing a different hat. // // # State growth // // Revoked allowances are removed, not archived. IDs are allocated // monotonically and never reused, so a revoked ID can never be resurrected // and there is nothing to keep. Live allowances are capped per Book at // construction, so storage is bounded by maxLive regardless of how many // allowances have ever existed. Audit history belongs in the consuming // realm's events. // // An EXPIRED allowance is not self-removing, and bounded storage is not the // same as a usable Book: Spend refuses it forever while it still holds a // slot. Reap exists for exactly that and is permissionless, because an // expired allowance can authorize nothing and so removing it takes nothing // from anyone. // // What this package does NOT bound is how many of a Book's slots ONE owner // may hold — maxLive is a single global figure, so any owner permitted to // Open can exhaust it and lock out the rest. That is a policy question with // no answer a primitive can pick (per-owner quota? first-come? owner // whitelist?), so it is left where the authentication already lives: 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. Named // here rather than implied, because "bounded" reads like "safe to share". // // # Consumer contract (the parts the package cannot enforce) // // 1. SUPPLY A REAL CLOCK. spendbook cannot verify that `now` came from // runtime.ChainHeight() or time.Now().Unix(). A realm that lets a // caller choose `now` has handed the spender the power to roll the // window at will, which defeats every cap in this package. Pass the // chain's own clock, and use the same units for the life of the Book. // // 2. AUTHENTICATE THE SPENDER KEY. spendbook compares the key string it // is given against the one recorded at Open. It cannot tell whether // that string is the real caller. Derive it from // cur.Previous().Address(), never from an argument the caller supplies. // // 3. SPEND BEFORE SENDING. Spend consumes the allowance and returns; the // realm then moves coins. Doing it in the other order means a failed // send leaves the allowance consumed, or a successful send leaves it // unconsumed — both are real losses. State first, effect second. // // 4. AN ALLOWANCE IS NOT CUSTODY. spendbook authorizing 100 does not mean // 100 exists. The realm must check its own held balance separately; // this package bounds permission, not solvency. package spendbook import ( "errors" "strconv" "gno.land/p/nt/avl/v0" ) // MaxLiveLimit is the largest maxLive a Book may be constructed with. It // bounds the worst-case cost of Iterate, which scans the live set. const MaxLiveLimit = 10000 // MaxKeyLen bounds an owner or spender key. A bech32 gno address is 40 // characters; the headroom exists so a realm may namespace its keys. const MaxKeyLen = 128 // maxInt64 is the overflow boundary for every accumulation here. const maxInt64 = int64(1<<63 - 1) // Errors returned by Book operations. var ( ErrInvalidConfig = errors.New("spendbook: invalid book configuration") ErrEmptyOwner = errors.New("spendbook: empty owner key") ErrEmptySpender = errors.New("spendbook: empty spender key") ErrKeyTooLong = errors.New("spendbook: key exceeds MaxKeyLen") ErrInvalidNow = errors.New("spendbook: now must be non-negative") ErrInvalidTerms = errors.New("spendbook: invalid terms") ErrInvalidAmount = errors.New("spendbook: amount must be positive") ErrBookFull = errors.New("spendbook: live allowance cap reached") ErrNotFound = errors.New("spendbook: no such live allowance") ErrNotExpired = errors.New("spendbook: allowance has not expired") ErrNotOwner = errors.New("spendbook: caller does not own this allowance") ErrNotSpender = errors.New("spendbook: caller is not this allowance's spender") ErrExpired = errors.New("spendbook: allowance has expired") ErrNotStarted = errors.New("spendbook: allowance has not started") ErrPeriodExceeded = errors.New("spendbook: amount exceeds the window cap") ErrTotalExceeded = errors.New("spendbook: amount exceeds the lifetime cap") ErrNoBoundary = errors.New("spendbook: no reachable next window boundary") ErrOverflow = errors.New("spendbook: int64 overflow") ErrIDExhausted = errors.New("spendbook: identifier space exhausted") ) // Terms are the ceilings on an allowance. Zero has a distinct meaning in // three of the four fields and it is never "zero allowed": // // PerPeriod > 0 required. The cap per window. // PeriodLength 0 means ONE window that never rolls (lifetime). // Total 0 means no lifetime cap. // ExpiresAt 0 means no expiry. type Terms struct { PerPeriod int64 PeriodLength int64 Total int64 ExpiresAt int64 } // valid reports whether the terms are self-consistent, given the anchor the // allowance is (or would be) opened at. func (t Terms) valid(anchor int64) bool { if t.PerPeriod <= 0 || t.PeriodLength < 0 || t.Total < 0 { return false } if t.ExpiresAt < 0 { return false } // An allowance that has already expired at its own anchor can never // authorize anything, so accepting it would only create a live entry // that every Spend refuses. if t.ExpiresAt != 0 && t.ExpiresAt <= anchor { return false } // A Total below PerPeriod is not an error -- it is a narrower lifetime // cap, which Spend enforces. A Total of exactly 0 is "unlimited". return true } // tighterOrEqualTo reports whether t is weakly tighter than u in every // dimension, i.e. whether t can authorize nothing u would not have. This is // the predicate that decides whether an amendment applies immediately. // // The zero-means-unlimited fields invert the comparison, which is the whole // subtlety: Total 0 is LOOSER than any positive Total, and ExpiresAt 0 is // LOOSER than any positive ExpiresAt. Reading those two as "smaller is // tighter" would let an owner remove a lifetime cap and an expiry // mid-window and call it a tightening. func (t Terms) tighterOrEqualTo(u Terms) bool { if t.PeriodLength != u.PeriodLength { return false // immutable; see the package doc } if t.PerPeriod > u.PerPeriod { return false } switch { case u.Total == 0: // u was unlimited; anything is tighter or equal. case t.Total == 0 || t.Total > u.Total: return false } switch { case u.ExpiresAt == 0: // u never expired; any expiry is tighter or equal. case t.ExpiresAt == 0 || t.ExpiresAt > u.ExpiresAt: return false } return true } // Allowance is one owner's grant to one spender. It is returned BY VALUE: // holders cannot reach into a Book through it. type Allowance struct { ID uint64 Owner string Spender string Terms Terms Anchor int64 // window origin; also the earliest spendable instant CreatedAt int64 SpentTotal int64 // over the allowance's whole life SpentWindow int64 // within WindowIndex WindowIndex int64 // Pending terms, applied at the next window boundary. Meaningful only // when HasPending is true. Pending Terms HasPending bool } // IsExpired reports whether the allowance is past its expiry at now. An // allowance with no expiry is never expired. // // It resolves the expiry LIVE, through at(), and that is the whole difference // between this package and v1 -- see "# Why v2 exists" in the package doc. // Reading a.Terms.ExpiresAt directly answers from the last-written record, // and after a boundary that record is stale in BOTH directions. // // The now < Anchor guard is not a behaviour change and is not decoration. It // preserves windowIndex's documented precondition: Remaining, Spend and Amend // each reject a pre-anchor now before reaching here, but Reap does not, and // IsExpired is EXPORTED, so a consumer may hand it any now at all. Without the // guard such a now would reach a negative numerator in windowIndex, where Go's // truncation-toward-zero folds two windows into one. false is also what v1 // answered for every valid allowance there, so nothing moves: valid() requires // ExpiresAt > Anchor, hence now < Anchor implies now < ExpiresAt. func (a Allowance) IsExpired(now int64) bool { if now < a.Anchor { return false } terms, _, _ := a.at(now) return terms.ExpiresAt != 0 && now >= terms.ExpiresAt } // windowIndex returns the anchored window number containing now. A lifetime // allowance (PeriodLength == 0) is always in window 0. // // now < Anchor cannot reach here. Remaining, Spend and Amend each reject it // first (ErrNotStarted, or 0), and IsExpired -- which Reap reaches with no // anchor check of its own -- carries its own now < Anchor guard for exactly // this reason. That keeps this division away from negative numerators, where // Go's truncation-toward-zero would fold two windows into one. func (a Allowance) windowIndex(now int64) int64 { if a.Terms.PeriodLength == 0 { return 0 } return (now - a.Anchor) / a.Terms.PeriodLength } // nextBoundary returns the instant window idx+1 begins, and whether that // instant is reachable at all. // // Unreachable has three causes and they collapse to the same answer, which is // why this returns a bool rather than an error: a lifetime allowance has no // boundary by construction, and an arithmetically unrepresentable boundary // (PeriodLength so large, or the clock so far along, that Anchor + steps * // PeriodLength overflows int64) is one no clock will ever reach. Computing it // unguarded would wrap to a small number and report a boundary in the PAST, // which is the dangerous direction: Amend would queue a loosening as though // it were about to take effect. func (a Allowance) nextBoundary(idx int64) (int64, bool) { pl := a.Terms.PeriodLength if pl == 0 || idx < 0 || idx == maxInt64 { return 0, false } steps := idx + 1 if steps > maxInt64/pl { return 0, false } off := steps * pl if off > maxInt64-a.Anchor { return 0, false } return a.Anchor + off, true } // at computes the allowance's effective state at now WITHOUT mutating it: // the terms in force, the window it falls in, and how much has been spent // within that window. // // Spend, Remaining and IsExpired ALL go through this one function. That is // deliberate -- a read that answered "how much is left" or "is this still // alive" with its own copy of the rollover and amendment logic would be a // second implementation of the rule, free to disagree with the one that // enforces it, and the disagreement would surface as an allowance that // reports more than it honors (or honors more than it reports). // // IsExpired was that second implementation in v1, and it is the reason this // package has a v2. The routing is the invariant: anything that asks a live // question about an allowance asks it HERE. A future field with a // zero-means-unlimited reading gets the same treatment or inherits the same // bug. func (a Allowance) at(now int64) (Terms, int64, int64) { idx := a.windowIndex(now) if idx == a.WindowIndex { return a.Terms, idx, a.SpentWindow } // The window has rolled. A pending amendment takes effect exactly here, // and the in-window spend resets -- it does not carry. terms := a.Terms if a.HasPending { terms = a.Pending } return terms, idx, 0 } // Remaining reports how much the spender may draw right now: the lesser of // what the current window leaves and what the lifetime cap leaves. It is 0 // for an allowance that is expired, not yet started, or fully drawn. // // It is a projection, not a reservation. Nothing is held, and the figure can // change in the same block if someone else's call advances the clock past a // boundary. func (a Allowance) Remaining(now int64) int64 { if now < a.Anchor || a.IsExpired(now) { return 0 } terms, _, spentWindow := a.at(now) left := terms.PerPeriod - spentWindow if left < 0 { // Reachable: a tightening amendment can drop PerPeriod below what // the window already spent. The allowance is drawn, not negative. left = 0 } if terms.Total != 0 { lifeLeft := terms.Total - a.SpentTotal if lifeLeft < 0 { lifeLeft = 0 } if lifeLeft < left { left = lifeLeft } } return left } // Book holds the live allowances of one consuming realm. The zero value is // not usable; construct with New. type Book struct { maxLive int nextID uint64 // never decreases; IDs are never reused live *avl.Tree // padded id -> Allowance } // New returns an empty Book. maxLive caps simultaneously live allowances // and must be in [1, MaxLiveLimit]. func New(maxLive int) (*Book, error) { if maxLive < 1 || maxLive > MaxLiveLimit { return nil, ErrInvalidConfig } return &Book{maxLive: maxLive, nextID: 1, live: avl.NewTree()}, nil } // MustNew is New but panics on error. func MustNew(maxLive int) *Book { b, err := New(maxLive) if err != nil { panic(err) } return b } // MaxLive returns the Book's cap on simultaneously live allowances. func (b *Book) MaxLive() int { return b.maxLive } // Count returns the number of live allowances. func (b *Book) Count() int { return b.live.Size() } // key pads an id to a fixed width so avl's lexical order matches numeric // order. 20 digits covers the whole uint64 range. func key(id uint64) string { s := strconv.FormatUint(id, 10) for len(s) < 20 { s = "0" + s } return s } func checkKeys(owner, spender string) error { if owner == "" { return ErrEmptyOwner } if spender == "" { return ErrEmptySpender } if len(owner) > MaxKeyLen || len(spender) > MaxKeyLen { return ErrKeyTooLong } return nil } // Open records a new allowance and returns its id. now becomes the window // anchor and the earliest instant the spender may draw. // // An owner may open several allowances for the same spender; they are // independent, and the ids are what tell them apart. spendbook does not // merge or deduplicate them, because "one allowance per pair" is a policy // some consuming realms want and others do not, and a primitive that // decided it here could not be talked out of it. func (b *Book) Open(owner, spender string, now int64, terms Terms) (uint64, error) { if err := checkKeys(owner, spender); err != nil { return 0, err } if now < 0 { return 0, ErrInvalidNow } if !terms.valid(now) { return 0, ErrInvalidTerms } if b.live.Size() >= b.maxLive { return 0, ErrBookFull } if b.nextID == 0 { return 0, ErrIDExhausted } id := b.nextID b.nextID++ b.live.Set(key(id), Allowance{ ID: id, Owner: owner, Spender: spender, Terms: terms, Anchor: now, CreatedAt: now, }) return id, nil } // get fetches a live allowance by id. // // avl's Get returns a bare `any` with no found flag, so absence is a nil // value. That is sound here only because nothing nil is ever stored: Set is // called in exactly three places and all three pass an Allowance value. func (b *Book) get(id uint64) (Allowance, bool) { v := b.live.Get(key(id)) if v == nil { return Allowance{}, false } a, ok := v.(Allowance) if !ok { return Allowance{}, false } return a, true } // Get returns a copy of a live allowance. func (b *Book) Get(id uint64) (Allowance, error) { a, ok := b.get(id) if !ok { return Allowance{}, ErrNotFound } return a, nil } // Remaining reports what the spender of id may draw at now, or 0 if there // is no such live allowance. A caller that must distinguish "nothing left" // from "no such allowance" should use Get. func (b *Book) Remaining(id uint64, now int64) int64 { a, ok := b.get(id) if !ok { return 0 } return a.Remaining(now) } // Spend consumes amount from allowance id on behalf of spender and returns // nil if every bound permits it. The caller then performs its own effect. // // On any error NOTHING is consumed. The write happens once, after the last // check, so a refused Spend leaves the book exactly as it was. func (b *Book) Spend(id uint64, spender string, now int64, amount int64) error { if now < 0 { return ErrInvalidNow } if amount <= 0 { return ErrInvalidAmount } a, ok := b.get(id) if !ok { return ErrNotFound } if a.Spender != spender { return ErrNotSpender } if now < a.Anchor { return ErrNotStarted } if a.IsExpired(now) { return ErrExpired } terms, idx, spentWindow := a.at(now) // Window cap. Checked as a subtraction so the sum cannot overflow: // spentWindow and PerPeriod are both >= 0 and PerPeriod > 0. if amount > terms.PerPeriod-spentWindow { return ErrPeriodExceeded } // Lifetime cap. if terms.Total != 0 && amount > terms.Total-a.SpentTotal { return ErrTotalExceeded } // SpentTotal is unbounded when Total == 0, so it is the one // accumulation that can genuinely reach the int64 ceiling. if a.SpentTotal > maxInt64-amount { return ErrOverflow } a.Terms = terms if idx != a.WindowIndex { a.HasPending = false a.Pending = Terms{} } a.WindowIndex = idx a.SpentWindow = spentWindow + amount a.SpentTotal += amount b.live.Set(key(id), a) return nil } // Revoke removes an allowance. Only its owner may revoke, and it takes // effect immediately -- there is no window to wait for, because revocation // is the owner's remedy and a delayed remedy is not one. // // The id is not reused, so a revoked allowance cannot be resurrected: a // later Spend on it returns ErrNotFound forever. func (b *Book) Revoke(id uint64, owner string) error { a, ok := b.get(id) if !ok { return ErrNotFound } if a.Owner != owner { return ErrNotOwner } b.live.Remove(key(id)) return nil } // Amend changes an allowance's terms. Only its owner may amend. // // PeriodLength is immutable and must be passed unchanged. Terms that are // weakly tighter in every dimension apply immediately; anything looser // becomes pending and takes effect at the next window boundary. A lifetime // allowance has no next boundary, so a loosening amendment on one is // refused with ErrNoBoundary. // // The returned bool reports whether the amendment applied immediately // (true) or was queued (false). A caller that ignores it cannot tell // whether the new ceiling is in force, which is exactly the thing worth // knowing. func (b *Book) Amend(id uint64, owner string, now int64, terms Terms) (bool, error) { if now < 0 { return false, ErrInvalidNow } a, ok := b.get(id) if !ok { return false, ErrNotFound } if a.Owner != owner { return false, ErrNotOwner } if now < a.Anchor { return false, ErrNotStarted } if a.IsExpired(now) { return false, ErrExpired } // Validated against the ANCHOR rather than now, which is the LOOSER of // the two and is deliberate. valid() only needs ExpiresAt to be after the // window origin; an owner naming an expiry between the anchor and now is // tightening the allowance until it is dead, which is their prerogative. // Revoke is the cleaner way to say that, and Reap then reclaims the slot. if !terms.valid(a.Anchor) { return false, ErrInvalidTerms } if terms.PeriodLength != a.Terms.PeriodLength { return false, ErrInvalidTerms } // Roll the window first if the clock has moved past a boundary, so an // amendment made after a quiet stretch is judged against the terms // actually in force rather than the ones a stale record still holds. cur, idx, spentWindow := a.at(now) if idx != a.WindowIndex { a.Terms = cur a.WindowIndex = idx a.SpentWindow = spentWindow a.HasPending = false a.Pending = Terms{} } if terms.tighterOrEqualTo(a.Terms) { a.Terms = terms // A tightening supersedes a queued loosening. Leaving it queued // would mean the owner's tightening silently expires at the next // boundary and the looser terms they just replaced come back. a.HasPending = false a.Pending = Terms{} b.live.Set(key(id), a) return true, nil } // A loosening is only queueable if the boundary it waits for will arrive // while the allowance is still alive. Queueing one that cannot be reached // would report "accepted, pending" for terms that never take effect -- // the owner's amendment silently discarded, which is the failure mode // this package is least able to explain after the fact. // // Two ways it is unreachable. The allowance has no boundary at all // (PeriodLength == 0, or an unrepresentable one), or the current terms // expire at or before the next boundary. The second is reachable in // ordinary use: a tightening of ExpiresAt applies IMMEDIATELY, so an // owner can tighten the expiry below the next boundary and then loosen // PerPeriod, and without this check the second call would be accepted // against a window that never opens. nb, ok := a.nextBoundary(a.WindowIndex) if !ok { return false, ErrNoBoundary } if a.Terms.ExpiresAt != 0 && nb >= a.Terms.ExpiresAt { return false, ErrNoBoundary } a.Pending = terms a.HasPending = true b.live.Set(key(id), a) return false, nil } // Reap removes an allowance that has expired at now, and may be called by // anyone. It returns ErrNotFound for an unknown id and ErrNotExpired for one // that is still alive. // // Permissionless on purpose, and the reasoning is the whole justification: // an expired allowance can never authorize another draw, so removing it takes // nothing from the owner and grants nothing to the caller. Without it an // expiry is a slow leak against maxLive -- Spend refuses the dead allowance // forever while it holds a slot, so a Book shared between owners could fill // with corpses and refuse new Opens, and the only remedy would be the owner // of each corpse noticing and calling Revoke. // // This relies on `now` exactly as much as Spend does. A realm that lets a // caller choose `now` has already broken consumer contract item 1, and such a // realm would let a caller reap a LIVE allowance by naming a future instant. func (b *Book) Reap(id uint64, now int64) error { if now < 0 { return ErrInvalidNow } a, ok := b.get(id) if !ok { return ErrNotFound } if !a.IsExpired(now) { return ErrNotExpired } b.live.Remove(key(id)) return nil } // Iterate calls fn for every live allowance in id order, stopping early if // fn returns true. The allowance is passed by value, so fn cannot mutate // the book through it. func (b *Book) Iterate(fn func(Allowance) bool) { b.live.Iterate("", "", func(_ string, v any) bool { return fn(v.(Allowance)) }) }
- #6spendbook_test.gno
- #7package spendbook import "testing" const ( alice = "g1alice" // owner bob = "g1bob" // spender carol = "g1carol" // neither ) // day is the default PeriodLength: a round number so window boundaries in the // tests are readable as multiples of it from the anchor. const day = int64(100) // openDefault opens one allowance anchored at `anchor` with a window cap of // 50 per `day`, no lifetime cap and no expiry. func openDefault(t *testing.T, anchor int64) (*Book, uint64) { t.Helper() b := MustNew(8) id, err := b.Open(alice, bob, anchor, Terms{PerPeriod: 50, PeriodLength: day}) if err != nil { t.Fatalf("Open: %v", err) } return b, id } // mustSpend fails the test if a permitted draw is refused. func mustSpend(t *testing.T, b *Book, id uint64, now, amount int64) { t.Helper() if err := b.Spend(id, bob, now, amount); err != nil { t.Fatalf("Spend(%d, now=%d, amt=%d): %v", id, now, amount, err) } } // refuse fails the test unless the draw is refused with want. func refuse(t *testing.T, b *Book, id uint64, now, amount int64, want error) { t.Helper() if err := b.Spend(id, bob, now, amount); err != want { t.Fatalf("Spend(%d, now=%d, amt=%d): got %v, want %v", id, now, amount, err, want) } } // --- construction --------------------------------------------------------- func TestNewRejectsBadConfig(t *testing.T) { for _, maxLive := range []int{0, -1, MaxLiveLimit + 1} { if _, err := New(maxLive); err != ErrInvalidConfig { t.Errorf("New(%d): got %v, want ErrInvalidConfig", maxLive, err) } } } func TestNewAcceptsTheBoundaries(t *testing.T) { for _, maxLive := range []int{1, MaxLiveLimit} { b, err := New(maxLive) if err != nil { t.Fatalf("New(%d): %v", maxLive, err) } if b.MaxLive() != maxLive { t.Errorf("MaxLive: got %d, want %d", b.MaxLive(), maxLive) } if b.Count() != 0 { t.Errorf("fresh Count: got %d, want 0", b.Count()) } } } func TestMustNewPanicsOnBadConfig(t *testing.T) { defer func() { if recover() == nil { t.Error("MustNew(0) did not panic") } }() MustNew(0) } // --- Open ----------------------------------------------------------------- func TestOpenRejectsBadKeys(t *testing.T) { b := MustNew(4) long := "" for i := 0; i <= MaxKeyLen; i++ { long += "x" } ok := Terms{PerPeriod: 10, PeriodLength: day} cases := []struct { name string owner, spender string want error }{ {"empty owner", "", bob, ErrEmptyOwner}, {"empty spender", alice, "", ErrEmptySpender}, {"owner too long", long, bob, ErrKeyTooLong}, {"spender too long", alice, long, ErrKeyTooLong}, } for _, c := range cases { if _, err := b.Open(c.owner, c.spender, 0, ok); err != c.want { t.Errorf("%s: got %v, want %v", c.name, err, c.want) } } // The boundary itself is allowed: MaxKeyLen characters exactly. exact := long[:MaxKeyLen] if _, err := b.Open(exact, exact, 0, ok); err != nil { t.Errorf("key of exactly MaxKeyLen: %v", err) } } func TestOpenRejectsNegativeNow(t *testing.T) { b := MustNew(4) _, err := b.Open(alice, bob, -1, Terms{PerPeriod: 10, PeriodLength: day}) if err != ErrInvalidNow { t.Errorf("got %v, want ErrInvalidNow", err) } } func TestOpenRejectsInvalidTerms(t *testing.T) { b := MustNew(4) cases := []struct { name string terms Terms }{ {"zero PerPeriod", Terms{PerPeriod: 0, PeriodLength: day}}, {"negative PerPeriod", Terms{PerPeriod: -1, PeriodLength: day}}, {"negative PeriodLength", Terms{PerPeriod: 10, PeriodLength: -1}}, {"negative Total", Terms{PerPeriod: 10, PeriodLength: day, Total: -1}}, {"negative ExpiresAt", Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: -1}}, // An expiry at or before the anchor can never authorize anything. {"expiry at anchor", Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 1000}}, {"expiry before anchor", Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 999}}, } for _, c := range cases { if _, err := b.Open(alice, bob, 1000, c.terms); err != ErrInvalidTerms { t.Errorf("%s: got %v, want ErrInvalidTerms", c.name, err) } } // One past the anchor is the first acceptable expiry. ok := Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 1001} if _, err := b.Open(alice, bob, 1000, ok); err != nil { t.Errorf("expiry one past the anchor: %v", err) } } func TestOpenAcceptsTotalBelowPerPeriod(t *testing.T) { // Not a contradiction: it is a narrower lifetime cap, and Spend is what // enforces the narrower of the two. b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 10}) if err != nil { t.Fatalf("Open: %v", err) } if got := b.Remaining(id, 0); got != 10 { t.Errorf("Remaining: got %d, want 10 (the lifetime cap)", got) } refuse(t, b, id, 0, 11, ErrTotalExceeded) mustSpend(t, b, id, 0, 10) } func TestOpenEnforcesMaxLive(t *testing.T) { b := MustNew(2) terms := Terms{PerPeriod: 10, PeriodLength: day} id1, err := b.Open(alice, bob, 0, terms) if err != nil { t.Fatalf("first Open: %v", err) } if _, err := b.Open(alice, bob, 0, terms); err != nil { t.Fatalf("second Open: %v", err) } if _, err := b.Open(alice, bob, 0, terms); err != ErrBookFull { t.Fatalf("third Open: got %v, want ErrBookFull", err) } // Revoking frees a slot; the new id is NOT the revoked one. if err := b.Revoke(id1, alice); err != nil { t.Fatalf("Revoke: %v", err) } id3, err := b.Open(alice, bob, 0, terms) if err != nil { t.Fatalf("Open after Revoke: %v", err) } if id3 == id1 { t.Errorf("id %d was reused after revocation", id3) } } func TestAFreedSlotDoesNotOverwriteALiveSibling(t *testing.T) { // Allocating the next id from the LIVE COUNT rather than a monotonic // counter looks right until a revocation in the middle: the count falls // back onto an id that is still in use, and the next Open silently // replaces that allowance instead of adding one. Count is what shows it, // which is why this asserts the population and not just the ids. b := MustNew(3) terms := Terms{PerPeriod: 10, PeriodLength: day} id1, _ := b.Open(alice, "s1", 0, terms) id2, _ := b.Open(alice, "s2", 0, terms) id3, _ := b.Open(alice, "s3", 0, terms) if err := b.Revoke(id2, alice); err != nil { t.Fatalf("Revoke: %v", err) } id4, err := b.Open(alice, "s4", 0, terms) if err != nil { t.Fatalf("Open into the freed slot: %v", err) } if b.Count() != 3 { t.Errorf("Count: got %d, want 3 -- an Open replaced a live allowance", b.Count()) } for _, c := range []struct { id uint64 spender string }{{id1, "s1"}, {id3, "s3"}, {id4, "s4"}} { a, err := b.Get(c.id) if err != nil { t.Errorf("Get(%d): %v", c.id, err) continue } if a.Spender != c.spender { t.Errorf("id %d: spender %q, want %q", c.id, a.Spender, c.spender) } } if _, err := b.Get(id2); err != ErrNotFound { t.Errorf("the revoked id came back: %v", err) } } func TestOpenAllocatesDistinctIDsForTheSamePair(t *testing.T) { // Two allowances for one (owner, spender) pair are independent, and the // id is what tells them apart. Drawing one down must not touch the other. b := MustNew(4) terms := Terms{PerPeriod: 50, PeriodLength: day} id1, _ := b.Open(alice, bob, 0, terms) id2, _ := b.Open(alice, bob, 0, terms) if id1 == id2 { t.Fatalf("duplicate id %d", id1) } mustSpend(t, b, id1, 0, 50) if got := b.Remaining(id1, 0); got != 0 { t.Errorf("drawn allowance: got %d, want 0", got) } if got := b.Remaining(id2, 0); got != 50 { t.Errorf("sibling allowance: got %d, want 50", got) } } // --- Get / Remaining lookups --------------------------------------------- func TestGetUnknownID(t *testing.T) { b := MustNew(4) if _, err := b.Get(99); err != ErrNotFound { t.Errorf("Get: got %v, want ErrNotFound", err) } // Remaining cannot distinguish the two cases and documents that it // answers 0 for both; this pins the documented behavior. if got := b.Remaining(99, 0); got != 0 { t.Errorf("Remaining of unknown id: got %d, want 0", got) } } func TestGetReturnsACopy(t *testing.T) { b, id := openDefault(t, 0) a, err := b.Get(id) if err != nil { t.Fatalf("Get: %v", err) } a.Terms.PerPeriod = 1 << 40 a.SpentWindow = -500 again, _ := b.Get(id) if again.Terms.PerPeriod != 50 || again.SpentWindow != 0 { t.Errorf("mutating the returned value reached the book: %+v", again) } } // --- Spend: the per-window cap ------------------------------------------- func TestSpendWithinWindowCap(t *testing.T) { b, id := openDefault(t, 1000) mustSpend(t, b, id, 1000, 20) if got := b.Remaining(id, 1000); got != 30 { t.Errorf("Remaining: got %d, want 30", got) } mustSpend(t, b, id, 1050, 30) if got := b.Remaining(id, 1050); got != 0 { t.Errorf("Remaining after full draw: got %d, want 0", got) } refuse(t, b, id, 1050, 1, ErrPeriodExceeded) } func TestSpendRejectsBadArguments(t *testing.T) { b, id := openDefault(t, 1000) refuse(t, b, id, 1000, 0, ErrInvalidAmount) refuse(t, b, id, 1000, -5, ErrInvalidAmount) refuse(t, b, id, -1, 10, ErrInvalidNow) if err := b.Spend(99, bob, 1000, 10); err != ErrNotFound { t.Errorf("unknown id: got %v, want ErrNotFound", err) } } func TestSpendRejectsTheWrongSpender(t *testing.T) { b, id := openDefault(t, 1000) // Not the owner's either: owning an allowance does not mean drawing it. for _, who := range []string{carol, alice, ""} { if err := b.Spend(id, who, 1000, 10); err != ErrNotSpender { t.Errorf("Spend as %q: got %v, want ErrNotSpender", who, err) } } } func TestSpendBeforeTheAnchor(t *testing.T) { b, id := openDefault(t, 1000) refuse(t, b, id, 999, 10, ErrNotStarted) // The anchor instant itself is spendable. mustSpend(t, b, id, 1000, 10) } func TestRemainingBeforeTheAnchorIsZero(t *testing.T) { b, id := openDefault(t, 1000) if got := b.Remaining(id, 999); got != 0 { t.Errorf("got %d, want 0", got) } } func TestARefusedSpendConsumesNothing(t *testing.T) { b, id := openDefault(t, 1000) mustSpend(t, b, id, 1000, 40) before, _ := b.Get(id) // Every refusal path, in one book, each asserted to leave no trace. refuse(t, b, id, 1000, 11, ErrPeriodExceeded) refuse(t, b, id, 1000, 0, ErrInvalidAmount) refuse(t, b, id, -1, 5, ErrInvalidNow) if err := b.Spend(id, carol, 1000, 5); err != ErrNotSpender { t.Fatalf("wrong spender: %v", err) } refuse(t, b, id, 999, 5, ErrNotStarted) after, _ := b.Get(id) if after != before { t.Errorf("a refused Spend mutated the allowance:\n before %+v\n after %+v", before, after) } } // --- Spend: anchored windows --------------------------------------------- func TestWindowsAreAnchoredNotSliding(t *testing.T) { // Anchor 1000, period 100, so boundaries are 1100, 1200, ... b, id := openDefault(t, 1000) // Draw the whole cap at the very end of window 0. mustSpend(t, b, id, 1099, 50) refuse(t, b, id, 1099, 1, ErrPeriodExceeded) // One instant later is window 1, and the cap is whole again. Under a // SLIDING trailing window this draw would have to wait until 1199. mustSpend(t, b, id, 1100, 50) if got := b.Remaining(id, 1100); got != 0 { t.Errorf("window 1 after a full draw: got %d, want 0", got) } // The boundary is a property of the anchor, not of when anyone called: // window 1 ends at 1200 no matter that the first draw in it was at 1100. refuse(t, b, id, 1199, 1, ErrPeriodExceeded) mustSpend(t, b, id, 1200, 50) } func TestWindowRefillIsAResetNotACarry(t *testing.T) { b, id := openDefault(t, 0) // Spend nothing in windows 0 and 1, then everything available in 2. mustSpend(t, b, id, 200, 50) // If unused allowance carried, 150 would have been available and 50 more // would still be left. The cap bounds worst-case loss PER window, which a // carry would unbound. refuse(t, b, id, 200, 1, ErrPeriodExceeded) if got := b.Remaining(id, 200); got != 0 { t.Errorf("Remaining: got %d, want 0", got) } } func TestWindowIndexSkipsAheadCorrectly(t *testing.T) { b, id := openDefault(t, 1000) mustSpend(t, b, id, 1000, 50) // Jump many windows in one step; the index must land on the real one, not // merely increment. mustSpend(t, b, id, 1000+37*day+1, 50) a, _ := b.Get(id) if a.WindowIndex != 37 { t.Errorf("WindowIndex: got %d, want 37", a.WindowIndex) } } func TestLifetimeAllowanceNeverRolls(t *testing.T) { b := MustNew(4) // PeriodLength 0 == one window that never rolls. id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: 0}) if err != nil { t.Fatalf("Open: %v", err) } mustSpend(t, b, id, 0, 50) for _, now := range []int64{1, 100, 1 << 40, maxInt64} { if got := b.Remaining(id, now); got != 0 { t.Errorf("Remaining at %d: got %d, want 0 (never refills)", now, got) } refuse(t, b, id, now, 1, ErrPeriodExceeded) } } // --- Spend: the lifetime cap and expiry ---------------------------------- func TestTotalCapSurvivesWindowRollover(t *testing.T) { b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 60}) if err != nil { t.Fatalf("Open: %v", err) } mustSpend(t, b, id, 0, 50) // Window 1 refills the per-window cap, but the lifetime cap does not // refill -- only 10 of the 60 is left. if got := b.Remaining(id, day); got != 10 { t.Errorf("Remaining in window 1: got %d, want 10", got) } refuse(t, b, id, day, 11, ErrTotalExceeded) mustSpend(t, b, id, day, 10) for _, now := range []int64{day, 2 * day, 99 * day} { if got := b.Remaining(id, now); got != 0 { t.Errorf("Remaining at %d: got %d, want 0 (lifetime spent)", now, got) } } } func TestExpiry(t *testing.T) { b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 250}) if err != nil { t.Fatalf("Open: %v", err) } a, _ := b.Get(id) if a.IsExpired(249) { t.Error("expired one instant early") } if !a.IsExpired(250) { t.Error("ExpiresAt is exclusive: 250 must be expired") } mustSpend(t, b, id, 249, 10) refuse(t, b, id, 250, 1, ErrExpired) if got := b.Remaining(id, 250); got != 0 { t.Errorf("Remaining when expired: got %d, want 0", got) } } func TestNoExpiryNeverExpires(t *testing.T) { b, id := openDefault(t, 0) a, _ := b.Get(id) if a.IsExpired(maxInt64) { t.Error("an allowance with ExpiresAt 0 reported expired") } } func TestOverflowGuardOnTheLifetimeAccumulator(t *testing.T) { // With Total == 0 (unlimited) SpentTotal is the one accumulation that can // genuinely reach the int64 ceiling, so it is the one that needs a guard. b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: maxInt64, PeriodLength: day}) if err != nil { t.Fatalf("Open: %v", err) } mustSpend(t, b, id, 0, maxInt64-5) a, _ := b.Get(id) if a.SpentTotal != maxInt64-5 { t.Fatalf("SpentTotal: got %d", a.SpentTotal) } // Next window: the per-window cap permits this draw, and only the // overflow guard stands between it and a wrapped SpentTotal. refuse(t, b, id, day, 10, ErrOverflow) mustSpend(t, b, id, day, 5) a, _ = b.Get(id) if a.SpentTotal != maxInt64 { t.Errorf("SpentTotal at the ceiling: got %d, want %d", a.SpentTotal, maxInt64) } refuse(t, b, id, 2*day, 1, ErrOverflow) } // --- Revoke --------------------------------------------------------------- func TestRevoke(t *testing.T) { b, id := openDefault(t, 0) if err := b.Revoke(id, carol); err != ErrNotOwner { t.Errorf("Revoke as a stranger: got %v, want ErrNotOwner", err) } if err := b.Revoke(id, bob); err != ErrNotOwner { t.Errorf("Revoke as the spender: got %v, want ErrNotOwner", err) } if err := b.Revoke(99, alice); err != ErrNotFound { t.Errorf("Revoke unknown: got %v, want ErrNotFound", err) } if err := b.Revoke(id, alice); err != nil { t.Fatalf("Revoke: %v", err) } if b.Count() != 0 { t.Errorf("Count after Revoke: got %d, want 0", b.Count()) } // Revocation is immediate and permanent -- the id is never resurrected. refuse(t, b, id, 0, 1, ErrNotFound) if err := b.Revoke(id, alice); err != ErrNotFound { t.Errorf("second Revoke: got %v, want ErrNotFound", err) } } func TestRevokeIsImmediateNotDeferredToABoundary(t *testing.T) { // Revocation is the owner's remedy, and a remedy that waits for a window // boundary is not one. b, id := openDefault(t, 0) mustSpend(t, b, id, 0, 10) if err := b.Revoke(id, alice); err != nil { t.Fatalf("Revoke: %v", err) } refuse(t, b, id, 0, 1, ErrNotFound) } // --- Reap ----------------------------------------------------------------- func TestReapOnlyTakesExpiredAllowances(t *testing.T) { b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 250}) if err != nil { t.Fatalf("Open: %v", err) } if err := b.Reap(id, 249); err != ErrNotExpired { t.Errorf("Reap a live allowance: got %v, want ErrNotExpired", err) } if err := b.Reap(id, -1); err != ErrInvalidNow { t.Errorf("Reap with a negative clock: got %v, want ErrInvalidNow", err) } if err := b.Reap(99, 250); err != ErrNotFound { t.Errorf("Reap unknown: got %v, want ErrNotFound", err) } // Permissionless: carol is neither owner nor spender and there is no // caller argument at all, which is the point. if err := b.Reap(id, 250); err != nil { t.Fatalf("Reap an expired allowance: %v", err) } if b.Count() != 0 { t.Errorf("Count after Reap: got %d, want 0", b.Count()) } if err := b.Reap(id, 250); err != ErrNotFound { t.Errorf("second Reap: got %v, want ErrNotFound", err) } } func TestReapNeverTakesAnAllowanceWithNoExpiry(t *testing.T) { b, id := openDefault(t, 0) if err := b.Reap(id, maxInt64); err != ErrNotExpired { t.Errorf("got %v, want ErrNotExpired", err) } } func TestReapReclaimsASlot(t *testing.T) { // The defect Reap exists for: without it an expired allowance refuses // every Spend forever while still holding a slot against maxLive. b := MustNew(1) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 50}) if err != nil { t.Fatalf("Open: %v", err) } live := Terms{PerPeriod: 10, PeriodLength: day} if _, err := b.Open(alice, carol, 50, live); err != ErrBookFull { t.Fatalf("Open over a corpse: got %v, want ErrBookFull", err) } if err := b.Reap(id, 50); err != nil { t.Fatalf("Reap: %v", err) } if _, err := b.Open(alice, carol, 50, live); err != nil { t.Fatalf("Open after Reap: %v", err) } } // --- Amend: tighter applies immediately ---------------------------------- func TestAmendTighterAppliesImmediately(t *testing.T) { b, id := openDefault(t, 0) mustSpend(t, b, id, 10, 20) now, err := b.Amend(id, alice, 10, Terms{PerPeriod: 30, PeriodLength: day}) if err != nil { t.Fatalf("Amend: %v", err) } if !now { t.Fatal("a tightening was queued instead of applied") } // 30 cap, 20 already spent in this window. if got := b.Remaining(id, 10); got != 10 { t.Errorf("Remaining: got %d, want 10", got) } refuse(t, b, id, 10, 11, ErrPeriodExceeded) mustSpend(t, b, id, 10, 10) } func TestAmendTighteningBelowWhatIsSpentClampsToZero(t *testing.T) { // Reachable, and the allowance must read as drawn rather than negative. b, id := openDefault(t, 0) mustSpend(t, b, id, 0, 40) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 10, PeriodLength: day}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Fatal("tightening was queued") } if got := b.Remaining(id, 0); got != 0 { t.Errorf("Remaining: got %d, want 0", got) } refuse(t, b, id, 0, 1, ErrPeriodExceeded) // The next window refills to the NEW, tighter cap. if got := b.Remaining(id, day); got != 10 { t.Errorf("Remaining in the next window: got %d, want 10", got) } } func TestAmendEqualTermsApplyImmediately(t *testing.T) { // "Tighter OR EQUAL": re-stating the same terms is not a loosening and // must not be deferred, or a no-op amendment would sit queued. b, id := openDefault(t, 0) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Error("identical terms were queued") } } // --- Amend: looser is deferred to the next boundary ---------------------- func TestAmendLooserIsPendingUntilTheNextBoundary(t *testing.T) { b, id := openDefault(t, 0) mustSpend(t, b, id, 0, 50) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}) if err != nil { t.Fatalf("Amend: %v", err) } if applied { t.Fatal("a loosening took effect inside the live window") } // The live window is untouched: this is the drain-then-raise-terms // attack, and the whole point is that the raise cannot reach back. if got := b.Remaining(id, 0); got != 0 { t.Errorf("Remaining in the live window: got %d, want 0", got) } refuse(t, b, id, 99, 1, ErrPeriodExceeded) // At the boundary the pending terms are in force. if got := b.Remaining(id, day); got != 500 { t.Errorf("Remaining at the boundary: got %d, want 500", got) } mustSpend(t, b, id, day, 500) // And they are now the allowance's real terms, not a one-window grant. a, _ := b.Get(id) if a.HasPending { t.Error("the pending amendment was not consumed") } if a.Terms.PerPeriod != 500 { t.Errorf("Terms.PerPeriod: got %d, want 500", a.Terms.PerPeriod) } if got := b.Remaining(id, 2*day); got != 500 { t.Errorf("Remaining two windows on: got %d, want 500", got) } } func TestPendingAmendmentAppliesThroughRemainingWithoutASpend(t *testing.T) { // Remaining and Spend share one implementation of the rollover rule, so a // read must not report the old terms just because nobody has spent yet. b, id := openDefault(t, 0) if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil { t.Fatalf("Amend: %v", err) } if got := b.Remaining(id, day); got != 500 { t.Errorf("Remaining: got %d, want 500", got) } // ... and what the read promised is what the write honors. mustSpend(t, b, id, day, 500) } func TestATighteningSupersedesAQueuedLoosening(t *testing.T) { // Otherwise the owner's tightening silently expires at the next boundary // and the looser terms they just replaced come back. b, id := openDefault(t, 0) if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil { t.Fatalf("loosening Amend: %v", err) } applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 20, PeriodLength: day}) if err != nil { t.Fatalf("tightening Amend: %v", err) } if !applied { t.Fatal("the tightening was queued") } a, _ := b.Get(id) if a.HasPending { t.Error("the queued loosening survived the tightening") } if got := b.Remaining(id, 0); got != 20 { t.Errorf("Remaining now: got %d, want 20", got) } if got := b.Remaining(id, day); got != 20 { t.Errorf("Remaining after the boundary: got %d, want 20", got) } if got := b.Remaining(id, 99*day); got != 20 { t.Errorf("Remaining much later: got %d, want 20", got) } } func TestASecondLooseningReplacesTheQueuedOne(t *testing.T) { b, id := openDefault(t, 0) if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil { t.Fatalf("first: %v", err) } if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 300, PeriodLength: day}); err != nil { t.Fatalf("second: %v", err) } // 300 is looser than the live 50 (so still queued) but tighter than the // queued 500: the owner's latest word wins. if got := b.Remaining(id, day); got != 300 { t.Errorf("Remaining at the boundary: got %d, want 300", got) } } func TestAmendAfterAQuietStretchJudgesAgainstTheTermsInForce(t *testing.T) { // The clock has moved several windows past a pending amendment without a // Spend to roll the record. Amend must roll first, or it would compare // the new terms against stale ones. b, id := openDefault(t, 0) if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil { t.Fatalf("loosening: %v", err) } // Window 5. The pending 500 is in force here, so 200 is a TIGHTENING and // must apply immediately. Judged against the stale 50 it would look like // a loosening and be queued for a boundary that has already passed. applied, err := b.Amend(id, alice, 5*day, Terms{PerPeriod: 200, PeriodLength: day}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Fatal("judged against stale terms: a tightening was queued") } if got := b.Remaining(id, 5*day); got != 200 { t.Errorf("Remaining: got %d, want 200", got) } } func TestRollingThroughAmendResetsTheWindowSpend(t *testing.T) { b, id := openDefault(t, 0) mustSpend(t, b, id, 0, 50) // Amend in window 3 with equal terms; the roll it performs must reset the // in-window spend rather than carry window 0's 50 forward. if _, err := b.Amend(id, alice, 3*day, Terms{PerPeriod: 50, PeriodLength: day}); err != nil { t.Fatalf("Amend: %v", err) } a, _ := b.Get(id) if a.WindowIndex != 3 || a.SpentWindow != 0 { t.Errorf("WindowIndex=%d SpentWindow=%d, want 3 and 0", a.WindowIndex, a.SpentWindow) } if a.SpentTotal != 50 { t.Errorf("SpentTotal: got %d, want 50 (lifetime does not reset)", a.SpentTotal) } mustSpend(t, b, id, 3*day, 50) } // --- Amend: the zero-means-unlimited inversions -------------------------- func TestAmendTotalZeroIsLooserNotTighter(t *testing.T) { // Total 0 means NO lifetime cap, so replacing a positive Total with 0 is // removing a cap. Reading "smaller is tighter" would let an owner lift it // mid-window and call it a tightening. b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 100}) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 0}) if err != nil { t.Fatalf("Amend: %v", err) } if applied { t.Error("removing the lifetime cap applied immediately") } if got := b.Remaining(id, 0); got != 50 { t.Errorf("Remaining: got %d, want 50 (still capped by the live 100)", got) } } func TestAmendTotalDownwardIsTighter(t *testing.T) { b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 100}) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 40}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Error("lowering the lifetime cap was queued") } if got := b.Remaining(id, 0); got != 40 { t.Errorf("Remaining: got %d, want 40", got) } } func TestAmendAddingATotalToAnUncappedAllowanceIsTighter(t *testing.T) { b, id := openDefault(t, 0) // Total 0 == unlimited applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 60}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Error("adding a lifetime cap was queued") } if got := b.Remaining(id, 0); got != 50 { t.Errorf("Remaining: got %d, want 50", got) } mustSpend(t, b, id, 0, 50) if got := b.Remaining(id, day); got != 10 { t.Errorf("Remaining in window 1: got %d, want 10", got) } } func TestAmendExpiresAtZeroIsLooserNotTighter(t *testing.T) { b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 10 * day}) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 0}) if err != nil { t.Fatalf("Amend: %v", err) } if applied { t.Error("removing the expiry applied immediately") } // The live expiry still bites until the boundary consumes the pending // terms, which here it does at day 1 -- well before the old expiry. a, _ := b.Get(id) if !a.HasPending { t.Fatal("no pending amendment recorded") } mustSpend(t, b, id, day, 50) a, _ = b.Get(id) if a.Terms.ExpiresAt != 0 { t.Errorf("ExpiresAt after the boundary: got %d, want 0", a.Terms.ExpiresAt) } if a.IsExpired(100 * day) { t.Error("still expiring on the old terms") } } func TestAmendShorteningTheExpiryIsTighter(t *testing.T) { b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 10 * day}) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 2 * day}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Fatal("shortening the expiry was queued") } mustSpend(t, b, id, day, 10) refuse(t, b, id, 2*day, 1, ErrExpired) } func TestAmendAddingAnExpiryToAnUnexpiringAllowanceIsTighter(t *testing.T) { b, id := openDefault(t, 0) // ExpiresAt 0 == never applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 2 * day}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Fatal("adding an expiry was queued") } refuse(t, b, id, 2*day, 1, ErrExpired) } func TestAmendMixedDirectionsIsLooser(t *testing.T) { // Tighter in one dimension and looser in another is NOT tighter: the // predicate is weakly tighter in EVERY dimension. b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 100}) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 10, PeriodLength: day, Total: 1000}) if err != nil { t.Fatalf("Amend: %v", err) } if applied { t.Error("a lower PerPeriod with a raised Total applied immediately") } if got := b.Remaining(id, 0); got != 50 { t.Errorf("Remaining: got %d, want 50 (live terms unchanged)", got) } } // --- Amend: PeriodLength is immutable ------------------------------------ func TestAmendCannotChangePeriodLength(t *testing.T) { // Moving every future boundary is an enlargement vector wearing a // different hat, so it is refused in BOTH directions rather than queued. b, id := openDefault(t, 0) for _, pl := range []int64{day - 1, day + 1, 0, 1} { _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: pl}) if err != ErrInvalidTerms { t.Errorf("PeriodLength %d: got %v, want ErrInvalidTerms", pl, err) } } // And the tighter-or-equal predicate independently refuses it, so the // check in Amend is not the only thing standing in the way. a := Terms{PerPeriod: 1, PeriodLength: day} c := Terms{PerPeriod: 1, PeriodLength: day + 1} if a.tighterOrEqualTo(c) || c.tighterOrEqualTo(a) { t.Error("tighterOrEqualTo accepted a PeriodLength change") } } // --- Amend: no reachable boundary ---------------------------------------- func TestAmendLooserOnALifetimeAllowanceIsRefused(t *testing.T) { // PeriodLength 0 has no next boundary, so queueing would park the // amendment forever and report it as accepted. b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: 0}) _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: 0}) if err != ErrNoBoundary { t.Errorf("got %v, want ErrNoBoundary", err) } // A tightening on a lifetime allowance is still fine: it needs no window. applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 10, PeriodLength: 0}) if err != nil { t.Fatalf("tightening a lifetime allowance: %v", err) } if !applied { t.Error("the tightening was queued") } } func TestAmendLooserIsRefusedWhenTheExpiryPrecedesTheNextBoundary(t *testing.T) { // The ordinary-use case: tightening ExpiresAt applies IMMEDIATELY, so an // owner can pull the expiry in below the next boundary and then try to // loosen PerPeriod. The boundary the loosening waits for never opens. b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 10 * day}) applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day / 2}) if err != nil { t.Fatalf("tightening the expiry: %v", err) } if !applied { t.Fatal("the tightening was queued") } // Next boundary is `day`; the allowance dies at day/2. _, err = b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day, ExpiresAt: day / 2}) if err != ErrNoBoundary { t.Errorf("got %v, want ErrNoBoundary", err) } // Pushing the expiry back out is itself a loosening, so it cannot be the // owner's way out: it is judged against the same dead window and refused // identically. The tightening is one-way without a reachable boundary, // and Revoke plus a fresh Open is the only route. if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 5 * day}); err != ErrNoBoundary { t.Errorf("re-expanding the expiry: got %v, want ErrNoBoundary", err) } a, _ := b.Get(id) if a.Terms.ExpiresAt != day/2 { t.Errorf("a loosened expiry applied immediately: %d", a.Terms.ExpiresAt) } } func TestTheUnreachableBoundaryTestIsGreaterOrEqualNotGreater(t *testing.T) { // An expiry exactly AT the next boundary is unreachable, because the // window opens at `day` and IsExpired is already true at `day`. Off by // one in the comparison would queue an amendment into a window that // exists for zero instants. b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day}) looser := Terms{PerPeriod: 500, PeriodLength: day, ExpiresAt: day} if _, err := b.Amend(id, alice, 0, looser); err != ErrNoBoundary { t.Errorf("expiry at the boundary: got %v, want ErrNoBoundary", err) } // One instant later the boundary IS reachable, which is what keeps the // check above from being a tool that merely always refuses. b2 := MustNew(4) id2, _ := b2.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day + 1}) applied, err := b2.Amend(id2, alice, 0, Terms{PerPeriod: 500, PeriodLength: day, ExpiresAt: day + 1}) if err != nil { t.Fatalf("expiry one past the boundary: %v", err) } if applied { t.Fatal("the loosening applied immediately") } // And the one instant it is alive for actually honors the new terms. mustSpend(t, b2, id2, day, 500) } func TestNextBoundaryRefusesUnrepresentableArithmetic(t *testing.T) { // An unguarded Anchor + (idx+1)*PeriodLength would wrap and report a // boundary in the PAST, which is the dangerous direction: Amend would // queue a loosening as though it were about to take effect. a := Allowance{Anchor: 0, Terms: Terms{PerPeriod: 1, PeriodLength: maxInt64}} if _, ok := a.nextBoundary(1); ok { t.Error("steps * PeriodLength overflow was not detected") } if nb, ok := a.nextBoundary(0); !ok || nb != maxInt64 { t.Errorf("the representable boundary: got %d/%v, want %d/true", nb, ok, maxInt64) } // Overflow in the Anchor + offset term rather than the product. a2 := Allowance{Anchor: maxInt64 - 5, Terms: Terms{PerPeriod: 1, PeriodLength: 10}} if _, ok := a2.nextBoundary(0); ok { t.Error("Anchor + offset overflow was not detected") } // A lifetime allowance has none by construction. a3 := Allowance{Terms: Terms{PerPeriod: 1, PeriodLength: 0}} if _, ok := a3.nextBoundary(0); ok { t.Error("a lifetime allowance reported a boundary") } } func TestAmendLooserIsRefusedWhenTheBoundaryIsUnrepresentable(t *testing.T) { b := MustNew(4) id, _ := b.Open(alice, bob, maxInt64-5, Terms{PerPeriod: 50, PeriodLength: 10}) _, err := b.Amend(id, alice, maxInt64-5, Terms{PerPeriod: 500, PeriodLength: 10}) if err != ErrNoBoundary { t.Errorf("got %v, want ErrNoBoundary", err) } } // --- Amend: authorization and argument checks ---------------------------- func TestAmendRejectsNonOwners(t *testing.T) { b, id := openDefault(t, 0) tighter := Terms{PerPeriod: 10, PeriodLength: day} for _, who := range []string{bob, carol, ""} { if _, err := b.Amend(id, who, 0, tighter); err != ErrNotOwner { t.Errorf("Amend as %q: got %v, want ErrNotOwner", who, err) } } if _, err := b.Amend(99, alice, 0, tighter); err != ErrNotFound { t.Errorf("unknown id: got %v, want ErrNotFound", err) } } func TestAmendRejectsBadArgumentsAndDeadAllowances(t *testing.T) { b := MustNew(4) id, _ := b.Open(alice, bob, 1000, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 2000}) tighter := Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 2000} if _, err := b.Amend(id, alice, -1, tighter); err != ErrInvalidNow { t.Errorf("negative now: got %v, want ErrInvalidNow", err) } if _, err := b.Amend(id, alice, 999, tighter); err != ErrNotStarted { t.Errorf("before the anchor: got %v, want ErrNotStarted", err) } if _, err := b.Amend(id, alice, 2000, tighter); err != ErrExpired { t.Errorf("expired: got %v, want ErrExpired", err) } if _, err := b.Amend(id, alice, 1000, Terms{PerPeriod: 0, PeriodLength: day}); err != ErrInvalidTerms { t.Errorf("invalid terms: got %v, want ErrInvalidTerms", err) } } func TestAmendIsValidatedAgainstTheAnchorNotNow(t *testing.T) { // Documented and deliberate: an expiry between the anchor and now is a // tightening to the point of death, which is the owner's prerogative. b := MustNew(4) id, _ := b.Open(alice, bob, 1000, Terms{PerPeriod: 50, PeriodLength: day}) applied, err := b.Amend(id, alice, 1050, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 1001}) if err != nil { t.Fatalf("Amend: %v", err) } if !applied { t.Fatal("it was queued") } refuse(t, b, id, 1050, 1, ErrExpired) // And the slot is reclaimable, which is what keeps this from being a leak. if err := b.Reap(id, 1050); err != nil { t.Fatalf("Reap: %v", err) } } // --- Iterate -------------------------------------------------------------- func TestIterateVisitsEveryAllowanceInIDOrder(t *testing.T) { b := MustNew(16) terms := Terms{PerPeriod: 10, PeriodLength: day} // Enough entries that a lexical key order would diverge from numeric // order without the zero padding (9 then 10). var want []uint64 for i := 0; i < 12; i++ { id, err := b.Open(alice, bob, 0, terms) if err != nil { t.Fatalf("Open %d: %v", i, err) } want = append(want, id) } var got []uint64 b.Iterate(func(a Allowance) bool { got = append(got, a.ID) return false }) if len(got) != len(want) { t.Fatalf("visited %d, want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("order diverged at %d: got %v, want %v", i, got, want) } } } func TestIterateStopsEarly(t *testing.T) { b := MustNew(8) terms := Terms{PerPeriod: 10, PeriodLength: day} for i := 0; i < 5; i++ { b.Open(alice, bob, 0, terms) } n := 0 b.Iterate(func(a Allowance) bool { n++ return n == 2 }) if n != 2 { t.Errorf("visited %d, want 2", n) } } func TestIteratePassesByValue(t *testing.T) { b, id := openDefault(t, 0) b.Iterate(func(a Allowance) bool { a.Terms.PerPeriod = 1 << 40 a.SpentWindow = -1 return false }) after, _ := b.Get(id) if after.Terms.PerPeriod != 50 || after.SpentWindow != 0 { t.Errorf("fn mutated the book through its argument: %+v", after) } } func TestIterateOnAnEmptyBook(t *testing.T) { b := MustNew(4) b.Iterate(func(a Allowance) bool { t.Error("fn was called on an empty book") return false }) } // --- the shared-implementation property ---------------------------------- func TestRemainingAgreesWithWhatSpendHonors(t *testing.T) { // Spend and Remaining share one `at`, so the read and the write cannot // disagree -- an allowance must never report more than it honors, nor // honor more than it reports. This walks a clock across boundaries, // amendments and caps, asserting both halves at every step. clocks := []int64{0, 10, 99, 100, 150, 199, 200, 201, 500, 501, 900, 1000} for _, c := range clocks { b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 120, ExpiresAt: 1000}) if err != nil { t.Fatalf("Open: %v", err) } // Some history, so the state at c is not pristine. if err := b.Spend(id, bob, 0, 30); err != nil { t.Fatalf("seed Spend: %v", err) } if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day, Total: 120, ExpiresAt: 1000}); err != nil { t.Fatalf("seed Amend: %v", err) } r := b.Remaining(id, c) if r < 0 { t.Fatalf("clock %d: Remaining is negative: %d", c, r) } if r > 0 { // Exactly what it promised must be spendable... if err := b.Spend(id, bob, c, r); err != nil { t.Errorf("clock %d: Remaining said %d but Spend refused it: %v", c, r, err) } // ...and nothing beyond it. if err := b.Spend(id, bob, c, 1); err == nil { t.Errorf("clock %d: honored 1 past the reported %d", c, r) } } else { if err := b.Spend(id, bob, c, 1); err == nil { t.Errorf("clock %d: Remaining said 0 but Spend honored a draw", c) } } } } // --- the last-written record vs the live view ------------------------------ // // The three tests below were written AFTER the fact, from measurements taken // on onyx-1 against the deployed bytes, and they are CHARACTERIZATION tests: // they assert the behaviour this package actually has, not the behaviour it // ought to have. They exist because that behaviour misled its own first // consumer, and a hazard nothing asserts is a hazard that gets re-discovered. // // The hazard, stated once: Allowance's exported FIELDS are the last-WRITTEN // record, while the only live-resolved view is behind the unexported at(), // reachable from outside this package through Remaining alone. Between a // window boundary and the next write there is no write to update the record, // so Terms, Pending, HasPending, WindowIndex and SpentWindow all describe a // window the clock has left -- and a consumer that reads them to DISPLAY // state publishes figures that disagree with what Spend will honor. // // Nothing here is a correctness bug in enforcement. Spend and Remaining both // route through at() and were correct in all ten live transactions; see // onyx/DEPLOYMENT.md, Group 9. What is wrong is the shape of the API. func TestExportedFieldsAreTheLastWrittenRecordNotTheLiveView(t *testing.T) { // This is the on-chain sequence of budgets grant #1, in miniature: // open, spend, tighten, queue a loosening, then let the window roll with // no write at all. Measured on onyx-1 at height 189411, 31 windows after // the grant was opened at 186267. b, id := openDefault(t, 0) // PerPeriod 50, PeriodLength 100 mustSpend(t, b, id, 10, 30) // Tighter-or-equal applies now. applied, err := b.Amend(id, alice, 20, Terms{PerPeriod: 20, PeriodLength: day}) if err != nil { t.Fatalf("tightening Amend: %v", err) } if !applied { t.Fatalf("a tightening amendment should apply immediately") } // Looser is queued for the next boundary. applied, err = b.Amend(id, alice, 30, Terms{PerPeriod: 80, PeriodLength: day}) if err != nil { t.Fatalf("loosening Amend: %v", err) } if applied { t.Fatalf("a loosening amendment should be queued, not applied") } // Now cross several boundaries WITHOUT writing. now = 3150 is window 31, // matching the live measurement. const now = int64(3150) a, err := b.Get(id) if err != nil { t.Fatalf("Get: %v", err) } // The live view, through the only exported door that resolves it. if got := b.Remaining(id, now); got != 80 { t.Fatalf("Remaining: got %d, want 80 (the queued terms took effect "+ "at the boundary and the window spend reset)", got) } // And enforcement agrees with the live view, which is the half that // matters: on chain this was a real 800000ugnot draw at height 189436 // (tx f58bc91016b055bfdfeae5e60f7aea1f6652d0f9d45152c89f7a0bcc0bafbf64) // against a record still reporting a cap of 200000. if err := b.Spend(id, bob, now, 80); err != nil { t.Fatalf("Spend of the full live Remaining was refused: %v", err) } // The record, meanwhile, says three things that are all false at `now`. // Each is asserted individually so a future change breaks the specific // claim rather than a lump. if a.Terms.PerPeriod != 20 { t.Errorf("Allowance.Terms.PerPeriod: got %d, want the SUPERSEDED 20 "+ "-- the field is the last-written record", a.Terms.PerPeriod) } if !a.HasPending { t.Errorf("Allowance.HasPending: got false, want true -- the pending " + "flag stays set after its boundary until the next write, so a " + "consumer reports an amendment as 'queued' that is already live") } if a.Pending.PerPeriod != 80 { t.Errorf("Allowance.Pending.PerPeriod: got %d, want 80", a.Pending.PerPeriod) } if a.WindowIndex != 0 { t.Errorf("Allowance.WindowIndex: got %d, want the LEFT window 0", a.WindowIndex) } if a.SpentWindow != 30 { t.Errorf("Allowance.SpentWindow: got %d, want the left window's 30", a.SpentWindow) } // The disagreement, stated as the one assertion a consumer cares about: // a display path reading the field publishes a cap of 20 over the very // window in which the enforcer just honored 80 -- a factor of four, and // in the dangerous direction (the owner is shown LESS authority than the // delegate actually holds). if a.Terms.PerPeriod >= 80 { t.Fatalf("Terms.PerPeriod is %d, which no longer understates the "+ "honored cap of 80: this test is describing behaviour the "+ "package has stopped having", a.Terms.PerPeriod) } } func TestTheStaleRecordSelfHealsOnTheNextWrite(t *testing.T) { // The hazard is bounded: it lasts from a boundary until the next write, // and any write ends it. Measured on chain -- after the draw at 189436, // GrantInfo reported 800000 and PendingTerms reported nothing queued. b, id := openDefault(t, 0) mustSpend(t, b, id, 10, 30) if _, err := b.Amend(id, alice, 30, Terms{PerPeriod: 80, PeriodLength: day}); err != nil { t.Fatalf("Amend: %v", err) } const now = int64(3150) mustSpend(t, b, id, now, 5) a, err := b.Get(id) if err != nil { t.Fatalf("Get: %v", err) } if a.Terms.PerPeriod != 80 { t.Errorf("after a write, Terms.PerPeriod: got %d, want 80", a.Terms.PerPeriod) } if a.HasPending { t.Errorf("after a write, HasPending should be cleared") } if a.WindowIndex != 31 { t.Errorf("after a write, WindowIndex: got %d, want 31", a.WindowIndex) } if a.SpentWindow != 5 { t.Errorf("after a write, SpentWindow: got %d, want 5", a.SpentWindow) } } func TestRemainingIsTheOnlyExportedLiveView(t *testing.T) { // The remedy a future version wants is an exported live-resolved view -- // Allowance.At(now), say -- so a consumer's display path cannot read a // stale field by accident. // // THIS TEST DOES NOT ENFORCE THAT ABSENCE, and the first draft of this // comment claimed it did ("it fails the moment such a method is added"). // That was false: nothing below would fail if At were added, and Gno // offers no reflection over a method set to write the check with. The // claim is removed rather than reworded, because a test whose comment // promises a guard it does not have is worse than no test -- a reader // stops looking, which is the §7a/C11 failure in miniature. // // What it actually does: pin the two live-resolving doors that DO exist, // so a change to either breaks here. The deliberateness of At's absence // lives in prose (onyx/p/spendbook/MAINNET_ASSESSMENT.md) and nowhere // else; that is a documented gap, not an enforced one. b, id := openDefault(t, 0) mustSpend(t, b, id, 10, 30) // IsExpired resolves live. In v1 this comment read "IsExpired resolves // live, but expiry does not depend on the window, so it is not affected // by the hazard" -- and the second half was FALSE about the package it // was written in. Expiry DID depend on the window, because a pending // amendment can move ExpiresAt and v1's IsExpired read the stored field // rather than asking at(). That is Y2, stated in the affirmative, in a // test comment. It is corrected rather than deleted, because the // sentence is a record of how the defect stayed invisible: the claim // read like a reason not to look. // // This case is unaffected either way -- the allowance has no expiry and // no pending expiry -- which is exactly why it never caught anything. // The claim is enforced in the Y2 regression block at the bottom of // this file. if a, _ := b.Get(id); a.IsExpired(3150) { t.Errorf("an allowance with no expiry is never expired") } // Remaining resolves live and IS affected -- it is the whole remedy a // consumer has today. if got := b.Remaining(id, 3150); got != 50 { t.Errorf("Remaining across a boundary: got %d, want the refilled 50", got) } if a, _ := b.Get(id); a.Remaining(3150) != 50 { t.Errorf("Allowance.Remaining must agree with Book.Remaining") } } // --- Y2 regression: the expiry is resolved LIVE, in both directions ------- // // EVERY case below carries its own "v1:" line saying whether it fails or passes // against v1, and where it fails, v1's wrong answer is written out beside the // assertion. The first draft of this header instead claimed "every case below // FAILS against v1 unless it is marked otherwise" -- one blanket adjective over // a whole subject set, which is the defect this repo keeps catching in its own // prose. Measured, it was wrong about five of the fourteen: a preservation case // (authorization unchanged, the zero value unchanged, the pre-anchor guard, the // pending terms not promoted early) is SUPPOSED to pass against v1, and reading // the header would have made a reader expect it to fail and distrust the suite. // // The split is not restated here; it is re-derivable, and the probe that // derives it is the non-vacuity control for this whole block -- a regression // test that passes against the source it was written to indict proves nothing: // // mkdir /tmp/v1probe && cd /tmp/v1probe // cp <repo>/onyx/p/spendbook/spendbook.gno . # the DEPLOYED bytes // cp <repo>/onyx/p/spendbook/v2/spendbook_test.gno . # this file // cp <repo>/onyx/p/spendbook/v2/gnomod.toml . // gno test -v . # expect exit 1; read WHICH names fail // // The block is organised by the two directions the stale read pointed: // // UNDER-authorization an extension or a removal of the expiry is accepted, // queued, comes into force at the boundary, and is then // ignored at the OLD expiry -- so the owner's own // amendment is discarded and a third party may Reap. // OVER-authorization an amendment that RAISES PerPeriod and LOWERS // ExpiresAt queues as a whole, and past the boundary the // raised cap is honoured while the lowered expiry is // not -- a draw past an expiry the owner set. // openExtendable opens the shape every extension case below needs: a window // of 100 with a cap of 50, expiring at 300, so the next boundary (100) lands // strictly before the expiry and a loosening is therefore queueable. func openExtendable(t *testing.T) (*Book, uint64) { t.Helper() b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 3 * day}) if err != nil { t.Fatalf("Open: %v", err) } return b, id } // queueExtension raises PerPeriod to 80 and pushes ExpiresAt out to 1000. // Both moves are looser, so the whole amendment queues; raising PerPeriod // alongside the expiry is what makes "the pending terms are not in force // yet" observable before the boundary, where the expiry alone is not. func queueExtension(t *testing.T, b *Book, id uint64, now int64) { t.Helper() applied, err := b.Amend(id, alice, now, Terms{PerPeriod: 80, PeriodLength: day, ExpiresAt: 10 * day}) if err != nil { t.Fatalf("Amend(extend): %v", err) } if applied { t.Fatalf("an extension of the expiry applied immediately; it is " + "LOOSER and tighterOrEqualTo must queue it") } } func TestAnExtensionCanOnlyEverBeQueuedNotApplied(t *testing.T) { // The premise of the whole defect, asserted first because without it // there would be a workaround: there is NO way to extend an expiry // immediately. tighterOrEqualTo reads ExpiresAt 0 as unlimited, so a // later expiry is looser in that dimension and a later-or-removed expiry // is the only kind of expiry change that can be queued at all. // // v1: PASSES -- it is about tighterOrEqualTo, which the fix does not // touch. It is here so a reader cannot conclude the queueing path was // avoidable. live := Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 3 * day} for _, tc := range []struct { name string to Terms }{ {"pushed out", Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 4 * day}}, {"removed", Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 0}}, } { if tc.to.tighterOrEqualTo(live) { t.Errorf("%s: tighterOrEqualTo says an expiry %s is tighter; it is "+ "looser, and treating it as tighter would let an owner undo an "+ "expiry mid-window", tc.name, tc.name) } } } func TestTheOldExpiryIsNeverReachableWhileStillInForce(t *testing.T) { // A structural consequence of Amend's queueability guard, and the reason // the stale read had no legitimate window to be right in: a loosening is // only queued when the next boundary arrives STRICTLY before the current // expiry (nb >= ExpiresAt is refused). So by the time the clock reaches // the old expiry, the boundary has already passed and the pending terms // are already the terms in force. v1 consulted the old expiry at an // instant where it was, without exception, superseded. // // v1: PASSES -- it is Amend's guard, which the fix does not touch. b := MustNew(4) // Expiry at 50, inside window 0, so the next boundary (100) is past it. id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day / 2}) if err != nil { t.Fatalf("Open: %v", err) } if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 80, PeriodLength: day, ExpiresAt: 10 * day}); err != ErrNoBoundary { t.Fatalf("queueing against an expiry inside the current window: got %v, "+ "want ErrNoBoundary", err) } } func TestPendingTermsAreNotInForceBeforeTheBoundary(t *testing.T) { // The other half of the fix's correctness: routing IsExpired through at() // must not promote a pending amendment EARLY. Window 0 still runs on the // old terms in every dimension. // // v1: PASSES -- and that is the point. This is a PRESERVATION case: it // pins the behaviour the fix must not disturb, so it is supposed to hold // on both sides. A reader who expects it to fail against v1 has misread // which half of the fix it covers. b, id := openExtendable(t) queueExtension(t, b, id, 10) if got := b.Remaining(id, 50); got != 50 { t.Errorf("Remaining inside window 0: got %d, want the OLD cap 50", got) } if got := b.Remaining(id, day-1); got != 50 { t.Errorf("Remaining at the last instant of window 0: got %d, want 50", got) } refuse(t, b, id, 50, 51, ErrPeriodExceeded) a, _ := b.Get(id) if a.Terms.ExpiresAt != 3*day { t.Errorf("the live expiry in window 0: got %d, want the OLD %d", a.Terms.ExpiresAt, 3*day) } if a.IsExpired(day - 1) { t.Error("not expired yet at the last instant of window 0") } } func TestPendingTermsTakeEffectExactlyAtTheBoundary(t *testing.T) { // Off by one here would either delay the extension by a whole window or // grant it a window early. // // v1: PASSES -- Remaining already resolved through at() in v1, so the // boundary arithmetic was never the broken part. IsExpired was. b, id := openExtendable(t) queueExtension(t, b, id, 10) if got := b.Remaining(id, day-1); got != 50 { t.Errorf("one instant before the boundary: got %d, want 50", got) } if got := b.Remaining(id, day); got != 80 { t.Errorf("AT the boundary: got %d, want the pending 80", got) } } func TestAQueuedExpiryExtensionSurvivesTheOldExpiry(t *testing.T) { // THE Y2 CASE. No write of any kind between queueing the amendment and // the old expiry -- that is the whole point, because any write promotes // the pending terms into the record and ends the hazard. // // v1: FAILS. At now=300: IsExpired reads the stored ExpiresAt of 300, reports // EXPIRED, Remaining answers 0 and Spend returns ErrExpired -- on an // allowance whose owner extended it to 1000 and was told "accepted". b, id := openExtendable(t) queueExtension(t, b, id, 10) const oldExpiry = 3 * day // 300 const newExpiry = 10 * day a, _ := b.Get(id) if a.IsExpired(oldExpiry) { t.Fatalf("IsExpired(%d) is true: the extension to %d was accepted and "+ "queued, took effect at the boundary, and has been discarded", oldExpiry, newExpiry) } if got := b.Remaining(id, oldExpiry); got != 80 { t.Errorf("Remaining(%d): got %d, want the extended terms' 80", oldExpiry, got) } // Enforcement must agree with the view, which is the half that moves value. if err := b.Spend(id, bob, oldExpiry, 80); err != nil { t.Fatalf("Spend at the old expiry was refused: %v", err) } // And the new expiry does bite, at exactly the right instant. if got := b.Remaining(id, newExpiry-1); got == 0 { t.Error("one instant before the new expiry the allowance must be alive") } if got := b.Remaining(id, newExpiry); got != 0 { t.Errorf("Remaining at the new expiry %d: got %d, want 0", newExpiry, got) } refuse(t, b, id, newExpiry, 1, ErrExpired) } func TestAThirdPartyCannotReapAnExtendedAllowance(t *testing.T) { // v1: FAILS. // // Reap is PERMISSIONLESS, which is sound only because an expired // allowance can authorize nothing. Under v1 the extended allowance read // as expired at 300, so the justification failed and any caller could // destroy a live grant -- the one place the defect let a third party act. b, id := openExtendable(t) queueExtension(t, b, id, 10) const oldExpiry = 3 * day if err := b.Reap(id, oldExpiry); err != ErrNotExpired { t.Fatalf("Reap at the old expiry: got %v, want ErrNotExpired -- a "+ "third party just deleted a live allowance", err) } if b.Count() != 1 { t.Fatalf("live count after the refused Reap: got %d, want 1", b.Count()) } // Once genuinely expired it is reapable, which is the behaviour Reap is // for and must not be lost by fixing the predicate. if err := b.Reap(id, 10*day); err != nil { t.Fatalf("Reap at the real expiry: %v", err) } if b.Count() != 0 { t.Errorf("live count after the Reap: got %d, want 0", b.Count()) } } func TestExpiryRemovalSurvivesTheOldExpiryWithNoWrite(t *testing.T) { // ExpiresAt -> 0 is the same class as an extension by way of // tighterOrEqualTo's other arm: 0 means unlimited, so removing the expiry // is LOOSER and can only be queued. // // v1: FAILS. v1 covered this only on the WRITTEN path -- see // TestAmendExpiresAtZeroIsLooserNotTighter above, which spends at the // boundary and so promotes the pending terms before reading them. With no // write the removal was discarded at the old expiry exactly as an // extension was, and "no write" is the ordinary case for an allowance // nobody has drawn on yet. b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 3 * day}) if err != nil { t.Fatalf("Open: %v", err) } applied, err := b.Amend(id, alice, 10, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 0}) if err != nil { t.Fatalf("Amend(remove expiry): %v", err) } if applied { t.Fatal("removing the expiry applied immediately") } const oldExpiry = 3 * day a, _ := b.Get(id) if a.IsExpired(oldExpiry) { t.Fatalf("IsExpired(%d) is true after the expiry was REMOVED", oldExpiry) } if got := b.Remaining(id, oldExpiry); got != 50 { t.Errorf("Remaining(%d): got %d, want 50", oldExpiry, got) } if err := b.Reap(id, oldExpiry); err != ErrNotExpired { t.Errorf("Reap(%d): got %v, want ErrNotExpired", oldExpiry, err) } // "No expiry" means no expiry, at any instant, forever. if a.IsExpired(maxInt64) { t.Error("an allowance whose expiry was removed must never expire") } if err := b.Spend(id, bob, 1000*day, 50); err != nil { t.Fatalf("Spend long after the removed expiry: %v", err) } // The stored record is still stale -- the fix does not write, it stops // READING the stale field. Asserted so the mechanism is not mistaken for // a migration of stored state. a, _ = b.Get(id) if a.Terms.ExpiresAt != 0 { t.Errorf("after the spend promoted the terms, ExpiresAt: got %d, want 0", a.Terms.ExpiresAt) } } func TestAQueuedTighteningOfTheExpiryStopsAuthorizingDraws(t *testing.T) { // v1: FAILS -- THE MIRROR CASE, and the dangerous direction: v1 // OVER-authorized here. // // tighterOrEqualTo is weakly-tighter in EVERY dimension, so an amendment // that raises PerPeriod and lowers ExpiresAt is looser as a whole and // queues as a whole. Past the boundary, v1's at() handed Spend the RAISED // cap while v1's IsExpired reported the allowance alive on the OLD, later // expiry -- so the spender could draw, at the new higher cap, past an // expiry the owner themselves had set. No bound in the package stopped // it: the bound had moved and the gate had not been told. b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 100, PeriodLength: 1000, ExpiresAt: 5000}) if err != nil { t.Fatalf("Open: %v", err) } applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 200, PeriodLength: 1000, ExpiresAt: 3000}) if err != nil { t.Fatalf("Amend(raise cap, pull expiry in): %v", err) } if applied { t.Fatal("a mixed-direction amendment applied immediately") } // Before the new expiry the owner's intent holds in full: raised cap. if got := b.Remaining(id, 1500); got != 200 { t.Errorf("Remaining(1500): got %d, want the raised 200", got) } // At and after the new expiry it is dead, even though the record still // says 5000. a, _ := b.Get(id) if a.Terms.ExpiresAt != 5000 { t.Fatalf("precondition: the stored expiry should still be the stale "+ "5000, got %d", a.Terms.ExpiresAt) } for _, now := range []int64{3000, 3500, 4999} { if !a.IsExpired(now) { t.Errorf("IsExpired(%d) is false: the owner pulled the expiry in to "+ "3000 and v1 would authorize a 200 draw here", now) } if got := b.Remaining(id, now); got != 0 { t.Errorf("Remaining(%d): got %d, want 0", now, got) } } refuse(t, b, id, 3500, 1, ErrExpired) refuse(t, b, id, 3500, 200, ErrExpired) // It is now genuinely dead, so Reap's permissionless justification holds // and the slot is reclaimable. if err := b.Reap(id, 3500); err != nil { t.Errorf("Reap on the correctly-expired allowance: %v", err) } } func TestRevokeStillWorksOnWhatTheFixDeclaresExpired(t *testing.T) { // The owner's fast remedy must not be taken away by making IsExpired // stricter. Revoke carries no IsExpired gate, deliberately, and the // mirror case is the first state in which the distinction is observable: // the allowance is expired on terms that are not in the record. // // v1: FAILS, on its SECOND half only. Revoke passes on both sides -- it has // no expiry gate to get wrong. The Amend assertion is what v1 misses: at // 3500 v1 reads the stored 5000, calls the allowance alive, and lets the // owner amend a grant that is dead on its own queued terms. b := MustNew(4) id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 100, PeriodLength: 1000, ExpiresAt: 5000}) if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 200, PeriodLength: 1000, ExpiresAt: 3000}); err != nil { t.Fatalf("Amend: %v", err) } if err := b.Revoke(id, alice); err != nil { t.Fatalf("Revoke: %v", err) } if b.Count() != 0 { t.Errorf("live count after Revoke: got %d, want 0", b.Count()) } // Amend, by contrast, IS gated on expiry and now correctly refuses -- // the owner cannot amend a grant that is dead on its own queued terms. b2 := MustNew(4) id2, _ := b2.Open(alice, bob, 0, Terms{PerPeriod: 100, PeriodLength: 1000, ExpiresAt: 5000}) if _, err := b2.Amend(id2, alice, 0, Terms{PerPeriod: 200, PeriodLength: 1000, ExpiresAt: 3000}); err != nil { t.Fatalf("Amend: %v", err) } if _, err := b2.Amend(id2, alice, 3500, Terms{PerPeriod: 10, PeriodLength: 1000, ExpiresAt: 3000}); err != ErrExpired { t.Errorf("Amend on a correctly-expired allowance: got %v, want ErrExpired", err) } } func TestTheFixGrantsNoAuthorityToAnyoneNew(t *testing.T) { // The extended window is a new state, so the authorization checks are // re-asserted inside it rather than assumed to carry. // // v1: PASSES -- a PRESERVATION case. The defect was never an authorization // defect for Spend, Amend or Revoke; it was a liveness defect that let a // permissionless Reap act (covered above) and let an authorized spender // draw past an expiry. Nobody new ever gained a right, on either side. b, id := openExtendable(t) queueExtension(t, b, id, 10) const oldExpiry = 3 * day if err := b.Spend(id, carol, oldExpiry, 1); err != ErrNotSpender { t.Errorf("Spend by a stranger: got %v, want ErrNotSpender", err) } if err := b.Spend(id, alice, oldExpiry, 1); err != ErrNotSpender { t.Errorf("Spend by the OWNER: got %v, want ErrNotSpender", err) } if _, err := b.Amend(id, carol, oldExpiry, Terms{PerPeriod: 999, PeriodLength: day}); err != ErrNotOwner { t.Errorf("Amend by a stranger: got %v, want ErrNotOwner", err) } if _, err := b.Amend(id, bob, oldExpiry, Terms{PerPeriod: 999, PeriodLength: day}); err != ErrNotOwner { t.Errorf("Amend by the SPENDER: got %v, want ErrNotOwner", err) } if err := b.Revoke(id, carol); err != ErrNotOwner { t.Errorf("Revoke by a stranger: got %v, want ErrNotOwner", err) } if err := b.Revoke(id, bob); err != ErrNotOwner { t.Errorf("Revoke by the SPENDER: got %v, want ErrNotOwner", err) } if b.Count() != 1 { t.Errorf("live count after six refused calls: got %d, want 1", b.Count()) } } func TestAccountingIsUnchangedAcrossTheExtendedBoundary(t *testing.T) { // The fix must move the expiry gate and nothing else. The window reset, // the lifetime accumulator and the window index all behave exactly as the // package doc describes, measured across the boundary the extension made // reachable. // // v1: FAILS -- not on an accounting assertion, but on mustSpend at the old // expiry, which v1 refuses with ErrExpired. The accounting it was written // to pin is therefore UNREACHABLE on v1: there is no v1 state in which // these figures could be compared, which is itself the measurement. b := MustNew(4) id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 120, ExpiresAt: 3 * day}) if err != nil { t.Fatalf("Open: %v", err) } mustSpend(t, b, id, 10, 40) // window 0 queueExtension(t, b, id, 20) // queueExtension keeps Total at 0 -- it names no Total, so the pending // terms REMOVE the lifetime cap. Assert that premise rather than relying // on it silently, since it is what makes the draw below permitted. a, _ := b.Get(id) if a.Pending.Total != 0 || a.Terms.Total != 120 { t.Fatalf("precondition: pending Total %d, live Total %d", a.Pending.Total, a.Terms.Total) } mustSpend(t, b, id, 3*day, 80) // past the old expiry, on the new terms a, _ = b.Get(id) if a.SpentTotal != 120 { t.Errorf("SpentTotal: got %d, want 40+80=120", a.SpentTotal) } if a.SpentWindow != 80 { t.Errorf("SpentWindow: got %d, want 80 (the window reset, it did not "+ "carry the 40)", a.SpentWindow) } if a.WindowIndex != 3 { t.Errorf("WindowIndex: got %d, want 3", a.WindowIndex) } if a.HasPending { t.Error("HasPending must be cleared once the write promotes the terms") } if a.Terms.ExpiresAt != 10*day { t.Errorf("promoted ExpiresAt: got %d, want %d", a.Terms.ExpiresAt, 10*day) } // The window cap still bites at the new figure, not above it. refuse(t, b, id, 3*day, 1, ErrPeriodExceeded) } func TestRemainingAgreesWithSpendAcrossEveryQueuedExpiryChange(t *testing.T) { // The cross-check the package doc names as the reason everything routes // through at(): a view that disagrees with the enforcer is the defect, // whichever way it points. Each row gets a FRESH book, because Spend // writes and a write ends the hazard being measured. // // v1: FAILS. Note WHERE: v1 keeps Remaining and Spend in AGREEMENT on // every row -- both route through at() -- so the agreement invariant alone // would have passed against the defective package. What fails is the // `alive` column: v1 calls the three post-boundary live rows dead. Two // views agreeing is not the same as either being right, which is why the // expected liveness is spelled out per row instead of only cross-checked. for _, tc := range []struct { name string now int64 alive bool }{ {"extension: inside the original window", 50, true}, {"extension: at the boundary", day, true}, {"extension: at the old expiry", 3 * day, true}, {"extension: one before the new expiry", 10*day - 1, true}, {"extension: at the new expiry", 10 * day, false}, {"extension: long after the new expiry", 100 * day, false}, } { b, id := openExtendable(t) queueExtension(t, b, id, 10) remaining := b.Remaining(id, tc.now) err := b.Spend(id, bob, tc.now, 1) if tc.alive { if remaining <= 0 { t.Errorf("%s: Remaining=%d, want > 0", tc.name, remaining) } if err != nil { t.Errorf("%s: Spend=%v, want nil", tc.name, err) } } else { if remaining != 0 { t.Errorf("%s: Remaining=%d, want 0", tc.name, remaining) } if err != ErrExpired { t.Errorf("%s: Spend=%v, want ErrExpired", tc.name, err) } } if (remaining > 0) != (err == nil) { t.Errorf("%s: the view and the enforcer disagree (Remaining=%d, "+ "Spend=%v)", tc.name, remaining, err) } } } func TestIsExpiredToleratesAPreAnchorNow(t *testing.T) { // IsExpired is EXPORTED and now routes through at(), so it is the one // place a caller-supplied now can reach windowIndex without an upstream // ErrNotStarted. Reap is the in-package caller with no anchor check of // its own. // // v1: PASSES -- and the agreement is the claim. v1 could not reach // windowIndex from IsExpired at all, so this case exists to prove the new // path did not introduce a divergence where v1 had no exposure. A FAIL // here would mean the fix had moved behaviour it was supposed to leave // alone, so "passes on both sides" is the only acceptable outcome. // // First, the hazard the guard exists for, measured rather than asserted: // a negative numerator folds roughly two windows into one, because Go // truncates toward zero. a := Allowance{Anchor: 500, Terms: Terms{PerPeriod: 1, PeriodLength: day}} if a.windowIndex(401) != a.windowIndex(599) { t.Fatalf("precondition: windowIndex(401)=%d windowIndex(599)=%d -- the "+ "fold this guard avoids is not present, so the guard's stated "+ "reason is wrong", a.windowIndex(401), a.windowIndex(599)) } // Then the guard: every pre-anchor now answers false, which is also what // v1 answered for any allowance valid() would accept, since valid() // requires ExpiresAt > Anchor. e := Allowance{ Anchor: 500, Terms: Terms{PerPeriod: 1, PeriodLength: day, ExpiresAt: 1000}, } for _, now := range []int64{-1, 0, 401, 499} { if e.IsExpired(now) { t.Errorf("IsExpired(%d) with Anchor 500: got true, want false", now) } } // Reach it through Reap, the caller that has no anchor check. b := MustNew(4) id, err := b.Open(alice, bob, 500, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 1000}) if err != nil { t.Fatalf("Open: %v", err) } if err := b.Reap(id, 100); err != ErrNotExpired { t.Errorf("Reap before the anchor: got %v, want ErrNotExpired", err) } if err := b.Reap(id, -1); err != ErrInvalidNow { t.Errorf("Reap with a negative now: got %v, want ErrInvalidNow", err) } if err := b.Reap(id, 1000); err != nil { t.Errorf("Reap at the expiry: %v", err) } } func TestThePreAnchorGuardIsLoadBearingOnACallerBuiltAllowance(t *testing.T) { // The companion to the test above, and it exists because the sabotage // harness found the gap: deleting the `now < a.Anchor` guard outright was // UNCAUGHT by the whole suite. TestIsExpiredToleratesAPreAnchorNow could // not catch it, and that is a property of the DOMAIN rather than a // weakness in the test -- over allowances valid() accepts the guard is // genuinely inert, which is also what README.md claims, in a sentence // carefully scoped to "every allowance it would accept". // // Measured rather than reasoned: a sweep of 4,176 valid()-accepted // (anchor, PeriodLength, ExpiresAt, pending, now) combinations found ZERO // inputs distinguishing the guarded body from the unguarded one, counting // a panic as a difference. Widening past validity found 3,570. // // So the observable domain is exactly `0 != ExpiresAt <= now < Anchor`, // and valid() rejects every member of it (`ExpiresAt <= anchor` is one of // its refusals). It is reachable anyway, which is the whole reason this // test is here rather than a comment saying the term cannot be sabotaged: // Get returns an Allowance BY VALUE and Iterate passes one, IsExpired is // exported with a value receiver, so a consumer can hold a copy, mutate // its Terms, and call IsExpired on a shape the Book would never store. // // v1: PASSES. v1 read a.Terms.ExpiresAt directly and never reached // windowIndex from here, so it answered false too -- another PRESERVATION // case. The guard is what keeps v2 agreeing with it. // // What is NOT claimed: no authorization consequence. Reap resolves the // STORED allowance, which is always valid()-accepted, so a caller's // mutated copy cannot make Reap delete anything. This pins consistency of // an exported method, not a permission. a := Allowance{ Owner: alice, Spender: bob, Anchor: 1000, Terms: Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 5}, } if a.Terms.valid(a.Anchor) { t.Fatalf("precondition: valid(1000) accepted ExpiresAt 5, so this case " + "is no longer about a shape the Book refuses to store") } // now sits at or after the expiry and strictly before the anchor: the // guard answers false, the unguarded body would answer true. for _, now := range []int64{5, 6, 100, 999} { if a.IsExpired(now) { t.Errorf("IsExpired(%d) with Anchor 1000 / ExpiresAt 5: got true, "+ "want false -- the pre-anchor guard is not in force, so an "+ "exported method disagrees with v1 on a caller-built shape", now) } } // The same with no window at all, so both of windowIndex's arms are // covered on this path and not just the folding one. lifetime := a lifetime.Terms.PeriodLength = 0 if lifetime.IsExpired(100) { t.Errorf("IsExpired(100) on a lifetime allowance with Anchor 1000 / " + "ExpiresAt 5: got true, want false") } // The control: past the anchor the expiry IS honoured, so the guard is // proven to be a pre-anchor shortcut and not a blanket `return false`. // Without this, deleting the guard's condition and keeping `return false` // would pass everything above. if !a.IsExpired(1000) { t.Errorf("IsExpired(1000) at the anchor with ExpiresAt 5: got false, " + "want true -- the guard has become an unconditional false and " + "IsExpired no longer reports expiry at all") } } func TestTheZeroAllowanceIsUnchangedByTheFix(t *testing.T) { // A hand-built Allowance is reachable: Get returns one by value and // Iterate passes one. The zero value must answer exactly as it did in // v1, or the fix has changed a shape no consumer declared. // // v1: PASSES -- a PRESERVATION case, and the one with the least margin: // the zero value has Anchor 0 and PeriodLength 0, so it takes the guard's // false branch on now < 0 and windowIndex's PeriodLength == 0 branch // otherwise. Both arms are exercised below. var z Allowance for _, now := range []int64{-1, 0, 1, maxInt64} { if z.IsExpired(now) { t.Errorf("the zero Allowance has ExpiresAt 0 and must never expire; "+ "IsExpired(%d) said true", now) } } }
- #8/gno.MemPackageType
- #9 MPUserAll
Result log
msg:0,success:true,log:,events:[]