Transaction

C1C91A3892FB22…6C72C268FC31

Block 387,646 · index 0 · indexed

Summary

Hash
C1C91A3892FB22A27FA99D7DAEC9B404A09BCA522722AC6A80ED6C72C268FC31
Block
387,646
Size
92390 bytes
Gas used
119,843,868 / 250,000,000
Fee
250000 ugnot
Status
success

Messages

Attached funds
12000000 ugnot

Arguments · 9

  1. #1vesting
  2. #2gnomod.toml
  3. #3module = "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/vesting" gno = "0.9"
  4. #4vesting.gno
  5. #5// Realm vesting escrows native GNOT for a named beneficiary and releases // it in DISCRETE, INDIVIDUALLY DATED TRANCHES — each payable exactly once, // at or after its own due height. // // # DISCRETE, NOT LINEAR — read this first // // Everywhere else in this ecosystem "vesting" means CONTINUOUS LINEAR // ACCRUAL: a formula of the form amount * elapsed / duration, evaluated // whenever someone asks. This realm does no such arithmetic. A funder // states an explicit list of (delay, amount) pairs at funding time, and // nothing accrues between them: a tranche is worth zero until its due // height and its full amount from that height on. // // That is a deliberate choice, not a simplification: // // - It is the shape real schedules actually have — cliffs, quarterly // tranches, milestone payments, a lump sum on a date. // - It has no `amount * elapsed` term, so the int64 overflow class that // has already shipped a NEGATIVE vested amount in two independent // ecosystem packages cannot occur here: that MULTIPLICATIVE class is // designed out rather than guarded against. The additive sums that do // exist are guarded, not designed out — Fund checks the tranche total // and the escrow total with checkedAdd, each with its own panic, // before any state changes. Both halves are true and neither replaces // the other. // - It maps exactly onto duebook, whose exactly-once Claim is what makes // a double release structurally impossible rather than merely checked. // // There is no default curve to be surprised by: every tranche is stated // explicitly at creation, or the schedule does not exist. // // # COMPOSITION // // This realm owns only the schedule state machine. Everything else is // delegated to primitives already audited and deployed: // // duebook — the entire schedule: due heights, and exactly-once release // coinio — coin custody: escrow-in, payout, surplus sweep // feeledger — claimable balances and the fee pot // sanitize — funder-supplied free text, before it reaches markdown // // It is duebook's FIRST PRODUCTIVE CONSUMER. duebook_demo is the // falsification experiment and holds no coins by design; this realm is the // first to put value behind the exactly-once claim. // // # LIFECYCLE // // Fund (EOA + -send) : one transaction creates the schedule, escrows // the exact total, and registers every tranche // in duebook. There is no draft state and no // half-funded schedule. // Release (ANYONE) : once a tranche is due, anyone may release it. // The value is credited to the RECORDED // BENEFICIARY, never to the caller. // Revoke (funder only) : returns a NOT-YET-DUE tranche to the funder, // and only if the schedule was created // revocable. A due tranche can never be revoked. // Withdraw / WithdrawAll : pays out the caller's own claimable balance. // // # WHY NOTHING CAN BE TRAPPED, AND WHY THERE IS NO EXPIRY // // Tranches are scheduled with duebook's ttl == 0, so they NEVER expire. // Vested value cannot be lost by a beneficiary who is slow, offline, or // gone. The sibling realms in this portfolio use a permissionless expiry // valve to guarantee escrow is never trapped; here the valve is different // and stronger: Release is PERMISSIONLESS, so every tranche becomes // claimable-by-anyone the moment it is due, and the schedule drains itself // without depending on any particular actor staying alive. Combined with // "a due tranche can never be revoked", every ugnot in escrow has exactly // one reachable destination and a height after which nobody can block it. // // # VESTED MEANS IRREVOCABLE, WITH NO RACE // // Revoke refuses any tranche whose due height has arrived, even though // duebook's Cancel would happily consume it. THAT CHECK IS THIS REALM'S, // not duebook's. Without it a funder could watch for the due height and // front-run the beneficiary's Release to claw back value already earned. // Likewise, `revocable` is fixed at funding and has no setter: an // irrevocable schedule cannot be made revocable later, which is the only // thing that makes the promise worth anything. // // # AUTHORIZATION // // Every identity is derived from the crossing entrypoint's // cur.Previous().Address(). No function takes a caller identity as a // parameter, so acting as another account is impossible by construction. // Release is permissionless *because* it pays the recorded beneficiary: // the caller's identity is not used to route value, so there is nothing // for a stranger to gain. // // # HOW IT WIRES DUEBOOK CORRECTLY (duebook's five-point consumer contract) // // 1. THE CLOCK IS runtime.ChainHeight(), never a call argument. A realm // that lets a caller choose `now` has no delay at all. // 2. HEIGHTS ARE THE ONLY UNIT. No timestamp appears anywhere. // 3. THE *Book IS NEVER EXPORTED. It is an unexported package var and is // never returned across a realm boundary; handing it out would hand out // the right to schedule, cancel and claim. // 4. THE ACTOR IS AUTHORIZED from cur.Previous().Address(); the duebook // owner recorded at Schedule is the funder, and duebook checks Cancel // against that recorded value, never against an argument. // 5. THE EFFECT HAPPENS AFTER A SUCCESSFUL Claim, IN THE SAME // TRANSACTION. // // The deferral PAYLOAD is a human-readable label for events and rendering. // THIS REALM NEVER PARSES IT. Every authoritative fact about a tranche — // its amount, its schedule, its beneficiary — comes from this realm's own // `tranches` tree, keyed by the deferral ID that duebook guarantees is // never reused. // // # MONETARY INVARIANT (conservation) // // Let H be ugnot held at this realm's address, E = escrowTotal (the sum of // amounts over tranches still pending), U the ledger's claimable balances, // F the fee pot, and S >= 0 out-of-band surplus: // // H == E + U + F + S // // Fund raises H and E together (coinio.Receive is the receipt-guaranteed // shape). Release moves one tranche amount from E into U+F, with feeledger // guaranteeing credited + fee == amount. Revoke moves a tranche amount from // E into U, fee-free. Withdrawals debit the ledger BEFORE coinio.Payout // moves the identical amount out (checks-effects-interactions). Any panic // aborts the whole transaction. This realm never issues or removes coins. // Surplus is recoverable only via SweepDenom, which reserves Liabilities(). // // # ONLY GNOT // // Fund rejects any envelope that is not exactly one positive ugnot coin. // Foreign denominations sit in surplus and are sweepable. package vesting import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger" "gno.land/p/nt/avl/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // Denom is the only asset this realm escrows. const Denom = "ugnot" // MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%. const MaxFeeBps = int64(1000) // Tranche status values. A tranche is created pending and reaches exactly // one terminal state; both terminals are frozen. const ( StatusPending = "pending" StatusReleased = "released" StatusRevoked = "revoked" ) // Bounds. All are compile-time constants: none is admin-mutable, so a // schedule's shape cannot be changed under its funder or beneficiary. const ( // MinDelayBlocks is the floor on how far ahead the FIRST tranche may // fall. One block is enough to prove the delay is enforced while // keeping the realm exercisable on a live chain. MinDelayBlocks = int64(1) // MaxDelayBlocks caps the furthest tranche at roughly 10.8 years at // pearl-1's ~3.42s blocks. It also bounds now+delay far inside int64. MaxDelayBlocks = int64(100_000_000) // MaxTranches bounds one schedule. It bounds the Fund transaction's // work, the RevokeAll loop, and the schedule detail render. MaxTranches = 64 // MaxOpenTranches caps pending tranches across EVERY schedule at once // — it is a whole-realm limit, not a per-schedule one, because it is // duebook's open-deferral cap. Fund fails when a new schedule would // cross it. Must be <= duebook.MaxOpenLimit. MaxOpenTranches = 5000 // MaxOpenPerFunder bounds how many tranches ONE funder may hold // pending at once, so that MaxOpenTranches cannot be consumed by a // single account. // // Without this bound the whole-realm cap is a denial-of-service // surface that costs nothing to use: 79 Fund calls of 64 one-ugnot // tranches fill the realm, Fund then aborts for everybody, and the // attacker recovers 100% of the escrow fee-free with RevokeAll. Two // deliberate choices elsewhere remove every recovery path — ttl is // always 0, so duebook's permissionless Expire valve never opens, and // MaxOpenTranches is a compile-time constant with no admin setter. The // denial would therefore be total and permanent, which is out of all // proportion to its cost. // // 256 leaves one funder four full MaxTranches schedules open at once — // generous for the intended use — while exhausting the realm now takes // at least ceil(5000/256) = 20 separately funded accounts. // // That is a SYBIL BOUND and nothing more, so the residual is quantified // here rather than left to be inferred from the 20: the cap counts // TRANCHES, never value. parseSpec accepts amount == 1, so 256 pending // tranches cost 256ugnot and all 20 accounts together escrow about // 5000ugnot — every bit of it recoverable fee-free, since revokeOne // refunds at 0 bps. The real cost of the realm-wide denial is therefore // ~80 Fund transactions' gas and 20 funded addresses, NOT capital. // Anyone sizing the residual risk from the "20 accounts" figure alone // will size it wrong by orders of magnitude. // // Bounding the attack by VALUE instead would take a per-tranche minimum // amount. That is a change to what the application is — it would price // small legitimate tranches out — so it is a reviewer's decision, not a // routine fix, and it is deliberately not made here. MaxOpenPerFunder = 256 // MaxTitleLen bounds a schedule title. Titles are charset-restricted // so they are list-safe in Render without escaping. MaxTitleLen = 80 // MaxMemoLen bounds the free-text memo. It is a BYTE length, not a // rune count, because its job is to bound storage. MaxMemoLen = 1000 // MaxSpecLen bounds the tranche specification string, so parsing cost // is bounded before any parsing begins. MaxSpecLen = 1400 // MaxListed caps how many entries a single view or render returns. MaxListed = 50 ) // tranche is one dated payment. It is this realm's authoritative record; // the matching duebook deferral carries only the due height and a label. // Records are kept after they terminate, so history remains answerable // once duebook has consumed the deferral. type tranche struct { deferralID uint64 scheduleID int64 seq int64 // 1-based position within its schedule amount int64 dueAt int64 status string settledAt int64 // height of release or revocation; 0 while pending } // schedule is one funded vesting agreement. type schedule struct { id int64 funder address beneficiary address title string memo string total int64 // Σ tranche amounts, fixed at funding feeBps int64 // snapshotted at funding, charged at each release revocable bool // fixed at funding; there is no setter createdAt int64 trancheIDs []uint64 // in tranche order pending int64 // count of tranches still pending releasedGross int64 // Σ amounts released (before fee) revokedTotal int64 // Σ amounts returned to the funder } var ( admin address // may set fee, fee recipient, and propose a successor pendingAdmin address // must accept before the handover takes effect feeRecipient address // may withdraw fees and sweep surplus feeBps int64 // fee snapshotted into NEW schedules self address // this realm's address, captured at deploy nextScheduleID int64 escrowTotal int64 // == Σ amount over pending tranches (the E term) schedules = avl.NewTree() // padID(scheduleID) -> *schedule tranches = avl.NewTree() // padU(deferralID) -> *tranche // openByFunder counts each funder's still-pending tranches, for the // MaxOpenPerFunder cap. An entry is REMOVED at zero rather than left // holding 0, so the tree tracks live funders and does not accumulate a // dead key per address that ever funded anything. openByFunder = avl.NewTree() // funder.String() -> int64 ledger = feeledger.MustNew(MaxFeeBps) // book is unexported and never returned (duebook consumer contract 3). book = duebook.MustNew(MinDelayBlocks, MaxDelayBlocks, MaxOpenTranches) ) func init() { admin = unsafe.OriginCaller() feeRecipient = admin self = unsafe.CurrentRealm().Address() } // rejectStraySend aborts if an EOA attached coins to an entrypoint that has // no use for them. Only Fund accepts value; without this guard a mistyped // send would be silently absorbed into surplus. func rejectStraySend(cur realm) { if !cur.IsCurrent() { // every call site passes the entrypoint's own first cur; this // pins that discipline against future call-site drift, which is // the house pattern from fee_split/v2. Unexported and with all // 11 call sites forwarding a live cur, this is unreachable // today — it is here because the artifact is immutable and the // guard's answer decides whether coins are rejected or absorbed panic("realm capability is not current") } if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 { panic("this entrypoint does not accept coins; only Fund does") } } // Fund creates a vesting schedule in a single transaction: it escrows the // attached GNOT, registers every tranche with duebook, and returns the new // schedule id. Only direct EOA calls with -send are accepted. // // spec lists the tranches as "delay:amount" pairs separated by commas, for // example "100:500,200:500,300:1000". Delays are in BLOCKS FROM NOW and // must be STRICTLY INCREASING — two payments at the same height are one // tranche with the amounts added. Every amount must be positive, and the // amounts must sum to EXACTLY the attached send: this realm will not // silently absorb a surplus or create an underfunded schedule. // // revocable decides, permanently, whether the funder may reclaim // not-yet-due tranches. There is no setter; an irrevocable schedule stays // irrevocable. // // maxFeeBps is the ceiling the caller signs for. Funding is rejected if the // live fee exceeds it, which closes the race where an admin sequences a fee // raise ahead of this transaction. Pass MaxFeeBps to accept any legal fee. func Fund(cur realm, beneficiary address, title, memo, spec string, revocable bool, maxFeeBps int64) int64 { // Receive first and exactly once per transaction (coinio's contract): // it READS the send envelope and cannot mark it spent, so crediting // per call would mint unbacked liabilities from a single -send. funder, amount := coinio.Receive(0, cur, Denom) if feeBps > maxFeeBps { panic("current fee " + itoa(feeBps) + " bps exceeds the caller's maximum " + itoa(maxFeeBps)) } var zero address if beneficiary == zero { panic("empty beneficiary address") } // This realm can never call its own Withdraw, so a balance credited to // its own address is claimable by nobody while still counting inside // Liabilities() — permanently stranded AND permanently shrinking // SweepDenom's reach. Unlike an arbitrary typo, self is a uniquely // knowable address this realm publishes itself via Address(), so it is // the one wrong beneficiary worth spending a comparison on if beneficiary == self { panic("beneficiary must not be this realm's own address") } assertValidTitle(title) if len(memo) > MaxMemoLen { panic("memo exceeds " + itoa(MaxMemoLen) + " bytes") } delays, amounts := parseSpec(spec) n := int64(len(delays)) // The declared total must match the escrow exactly, in both directions. declared := int64(0) for _, a := range amounts { sum, ok := checkedAdd(declared, a) if !ok { panic("tranche amounts overflow int64") } declared = sum } if declared != amount { panic("tranche amounts total " + itoa(declared) + Denom + " but " + itoa(amount) + Denom + " was sent; they must be equal") } // Whole-realm capacity check, ahead of any state change, for a clear // message. duebook would refuse with ErrBookFull anyway, and a panic // there would abort this transaction wholly — the pre-check only // improves the error. if int64(book.OpenCount())+n > int64(MaxOpenTranches) { panic("realm tranche capacity reached: " + itoa(int64(book.OpenCount())) + " pending of " + itoa(int64(MaxOpenTranches)) + ", this schedule needs " + itoa(n)) } // Per-funder capacity, so one account cannot consume the whole-realm // cap above and lock everybody out. See MaxOpenPerFunder. if mine := openOf(funder); mine+n > int64(MaxOpenPerFunder) { panic("funder tranche capacity reached: " + itoa(mine) + " pending of " + itoa(int64(MaxOpenPerFunder)) + ", this schedule needs " + itoa(n)) } newEscrow, ok := checkedAdd(escrowTotal, amount) if !ok { panic("escrow overflow") } now := runtime.ChainHeight() // the chain's clock (contract 1), heights only (contract 2) nextScheduleID++ s := &schedule{ id: nextScheduleID, funder: funder, beneficiary: beneficiary, title: title, memo: memo, total: amount, feeBps: feeBps, revocable: revocable, createdAt: now, pending: n, } for i := 0; i < len(delays); i++ { seq := int64(i + 1) // The payload is a label for humans and events. It is NEVER parsed // (see the package doc): the authoritative record is the tranche // struct stored under the deferral ID below. label := "schedule " + itoa(s.id) + " tranche " + itoa(seq) + "/" + itoa(n) // ttl 0 means the tranche never expires: vested value cannot be // lost by a slow or absent beneficiary. did, err := book.Schedule(funder.String(), label, now, delays[i], 0) if err != nil { panic(err) } d, found := book.Get(did) if !found { panic("internal: scheduled deferral not found") } tranches.Set(padU(did), &tranche{ deferralID: did, scheduleID: s.id, seq: seq, amount: amounts[i], dueAt: d.DueAt, status: StatusPending, }) s.trancheIDs = append(s.trancheIDs, did) chain.Emit("TrancheScheduled", "schedule", itoa(s.id), "tranche", utoa(did), "seq", itoa(seq), "amount", itoa(amounts[i]), "dueAt", itoa(d.DueAt), ) } schedules.Set(padID(s.id), s) escrowTotal = newEscrow addOpen(funder, n) // commits with escrowTotal: both count the same tranches chain.Emit("ScheduleFunded", "schedule", itoa(s.id), "funder", funder.String(), "beneficiary", beneficiary.String(), "total", itoa(amount), "tranches", itoa(n), "feeBps", itoa(s.feeBps), "revocable", btoa(revocable), ) return s.id } // Release pays out a due tranche. ANYONE may call it: the value is credited // to the schedule's RECORDED BENEFICIARY, never to the caller, so a // stranger calling this gains nothing and the beneficiary needs no liveness // of their own. The protocol fee snapshotted at funding is charged here. // // Aborts if the tranche does not exist, is not due yet, or has already been // released or revoked — the last of those is duebook's guarantee, not a // check written here. func Release(cur realm, trancheID int64) string { rejectStraySend(cur) did := mustID(trancheID) now := runtime.ChainHeight() // CONSUME FIRST (duebook consumer contract 5). Claim removes the // deferral before returning, so by the time the effect below runs no // second Claim of this ID can ever succeed — not in this transaction, // and not in any future one. This realm writes no replay guard of its // own because duebook's is structural. d, err := book.Claim(did, now) if err != nil { panic(err) } // EFFECT SECOND, same transaction. t := mustTranche(d.ID) if t.status != StatusPending { // Unreachable: a successful Claim proves the deferral was open, and // this realm terminates the tranche record in the same transaction // it consumes the deferral. Kept as a consistency assertion — if it // ever fires, the two stores have diverged and the transaction must // not proceed. panic("internal: claimed a deferral whose tranche is " + t.status) } s := mustSchedule(t.scheduleID) // feeledger validates before mutating, and guarantees // credited + fee == amount. credited, fee, err := ledger.Deposit(s.beneficiary.String(), t.amount, s.feeBps) if err != nil { panic(err) } escrowTotal -= t.amount // >= 0: escrowTotal == Σ pending amounts >= t.amount t.status = StatusReleased t.settledAt = now s.pending-- s.releasedGross += t.amount addOpen(s.funder, -1) // this tranche no longer counts against the funder chain.Emit("TrancheReleased", "schedule", itoa(s.id), "tranche", utoa(d.ID), "seq", itoa(t.seq), "beneficiary", s.beneficiary.String(), "credited", itoa(credited), "fee", itoa(fee), "caller", cur.Previous().Address().String(), "height", itoa(now), ) return "released tranche " + utoa(d.ID) + " (" + itoa(credited) + Denom + " to " + s.beneficiary.String() + ")" } // Revoke returns a NOT-YET-DUE tranche to the funder's claimable balance, // fee-free. Only the funder may call it, and only on a schedule created // revocable. // // A tranche whose due height has arrived can never be revoked, even though // duebook's Cancel would consume it: that refusal is this realm's, and it // is what stops a funder front-running the beneficiary's Release to claw // back value already vested. func Revoke(cur realm, trancheID int64) string { rejectStraySend(cur) caller := cur.Previous().Address() did := mustID(trancheID) t := mustTranche(did) s := mustSchedule(t.scheduleID) assertRevocableBy(s, caller) now := runtime.ChainHeight() if now >= t.dueAt { panic("tranche " + utoa(did) + " is due at height " + itoa(t.dueAt) + " (now " + itoa(now) + "): a vested tranche can never be revoked") } revokeOne(cur, s, t, now) return "revoked tranche " + utoa(did) + " (" + itoa(t.amount) + Denom + " to the funder)" } // RevokeAll returns every not-yet-due tranche of a schedule to the funder's // claimable balance in one transaction, and returns how many were revoked. // Already-due and already-settled tranches are skipped, not an error. // Funder only, revocable schedules only. Aborts if nothing was revocable, // so a no-op cannot be mistaken for success. func RevokeAll(cur realm, scheduleID int64) string { rejectStraySend(cur) caller := cur.Previous().Address() s := mustSchedule(scheduleID) assertRevocableBy(s, caller) now := runtime.ChainHeight() // Bounded by MaxTranches, so the loop cost is bounded at funding time. count := int64(0) total := int64(0) for _, did := range s.trancheIDs { v := tranches.Get(padU(did)) if v == nil { panic("internal: schedule references an unknown tranche") } t := v.(*tranche) if t.status != StatusPending || now >= t.dueAt { continue } amount := t.amount revokeOne(cur, s, t, now) count++ total += amount // bounded by s.total, which was overflow-checked at funding } if count == 0 { panic("no revocable tranches: every tranche of this schedule is already due or settled") } chain.Emit("ScheduleRevoked", "schedule", itoa(s.id), "tranches", itoa(count), "amount", itoa(total), "height", itoa(now), ) return "revoked " + itoa(count) + " tranches (" + itoa(total) + Denom + " to the funder)" } // revokeOne cancels one pending, not-yet-due tranche and credits the funder // fee-free. Callers have already verified authorization, revocability and // the due-height refusal. func revokeOne(cur realm, s *schedule, t *tranche, now int64) { // CONSUME FIRST. Ownership is checked by duebook against the funder // address this realm recorded at Schedule, never against an argument // (duebook consumer contract 4). d, err := book.Cancel(t.deferralID, s.funder.String()) if err != nil { panic(err) } _ = d // EFFECT SECOND, same transaction. Refunds are always fee-free. if _, _, err := ledger.Deposit(s.funder.String(), t.amount, 0); err != nil { panic(err) } escrowTotal -= t.amount t.status = StatusRevoked t.settledAt = now s.pending-- s.revokedTotal += t.amount addOpen(s.funder, -1) // revoking frees the funder's slot too chain.Emit("TrancheRevoked", "schedule", itoa(s.id), "tranche", utoa(t.deferralID), "seq", itoa(t.seq), "funder", s.funder.String(), "amount", itoa(t.amount), "height", itoa(now), ) } // Withdraw sends amount ugnot of the caller's claimable balance (released // tranches for a beneficiary, revocations for a funder) to the caller. func Withdraw(cur realm, amount int64) string { rejectStraySend(cur) caller := cur.Previous().Address() // Debit the ledger BEFORE the coins move (coinio's ordering contract). if err := ledger.Withdraw(caller.String(), amount); err != nil { panic(err) } coinio.Payout(0, cur, caller, Denom, amount) chain.Emit("Withdrawn", "to", caller.String(), "amount", itoa(amount)) return "withdrew " + itoa(amount) + Denom } // WithdrawAll sends the caller's entire claimable balance to the caller. // Aborts if there is nothing to withdraw. func WithdrawAll(cur realm) string { rejectStraySend(cur) caller := cur.Previous().Address() amount, err := ledger.WithdrawAll(caller.String()) if err != nil { panic(err) } if amount == 0 { panic("nothing to withdraw") } coinio.Payout(0, cur, caller, Denom, amount) chain.Emit("Withdrawn", "to", caller.String(), "amount", itoa(amount)) return "withdrew " + itoa(amount) + Denom } // WithdrawFees sends the accrued fee pot to the fee recipient. Only the fee // recipient may call it. func WithdrawFees(cur realm) string { rejectStraySend(cur) caller := cur.Previous().Address() if caller != feeRecipient { panic("only the fee recipient may withdraw fees") } if ledger.FeesAccrued() == 0 { panic("no fees accrued") } amount := ledger.WithdrawFees() coinio.Payout(0, cur, feeRecipient, Denom, amount) chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount)) return "withdrew " + itoa(amount) + Denom + " in fees" } // SweepDenom sends the surplus of a single denomination to the fee // recipient. For ugnot only the excess over Liabilities() moves — escrowed // tranches and claimable balances are untouchable by construction. Other // denominations move wholly. Only the fee recipient may call it. func SweepDenom(cur realm, denom string) string { rejectStraySend(cur) caller := cur.Previous().Address() if caller != feeRecipient { panic("only the fee recipient may sweep surplus") } reserve := int64(0) if denom == Denom { reserve = Liabilities() } swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve) chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom) return "swept " + itoa(swept) + denom } // --- administration --- // SetFeeBps sets the protocol fee snapshotted into FUTURE schedules at // funding. Existing schedules keep the fee they were funded under, so an // in-flight schedule's economics cannot be changed. Admin only. func SetFeeBps(cur realm, bps int64) { rejectStraySend(cur) assertAdmin(cur.Previous().Address()) if bps < 0 || bps > MaxFeeBps { panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]") } old := feeBps feeBps = bps chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps)) } // SetFeeRecipient re-points the fee/surplus role. The pot accrued under the // OUTGOING recipient is settled to that recipient's own claimable balance // first, so the successor inherits the role and not the earnings. Admin // only; the zero address is rejected. func SetFeeRecipient(cur realm, next address) { rejectStraySend(cur) assertAdmin(cur.Previous().Address()) var zero address if next == zero { panic("empty fee recipient") } old := feeRecipient // The fee pot is a single scalar — feeledger keeps no per-recipient // accounting — so a bare re-point would hand every basis point earned // under `old` to `next`, at the admin's sole discretion and with no // timelock. That is a privilege the rest of this realm does not grant: // the admin role itself is two-step, the fee is capped and snapshotted // per schedule, and the funder signs a maxFeeBps ceiling. // // Settling to `old`'s claimable balance moves F into U by the same // amount, so no coins move here and conservation is untouched; `old` // then withdraws on their own initiative like any other creditor. // // Refusing the call while fees are accrued would be the other available // fix and is worse: WithdrawFees is recipient-only, so a recipient who // simply declined to withdraw could hold the role hostage against the // admin indefinitely. if old != next && ledger.FeesAccrued() > 0 { pot := ledger.WithdrawFees() credited, fee, err := ledger.Deposit(old.String(), pot, 0) if err != nil { panic(err) } if credited != pot || fee != 0 { // Unreachable at 0 bps, where feeledger's credited+fee == amount // guarantee forces credited == amount. Asserted because this is // the one place the realm moves value between two of its own // liability terms, and a silent shortfall here would be a leak. panic("internal: fee settlement did not credit the full pot") } chain.Emit("FeesSettled", "to", old.String(), "amount", itoa(pot)) } feeRecipient = next chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String()) } // ProposeAdmin nominates a successor admin. The handover takes effect only // when that account calls AcceptAdmin, so a typo cannot strand the role. // Admin only; the zero address clears a pending proposal. func ProposeAdmin(cur realm, next address) { rejectStraySend(cur) assertAdmin(cur.Previous().Address()) pendingAdmin = next chain.Emit("AdminProposed", "pending", next.String()) } // AcceptAdmin completes a handover. Only the proposed account may call it. func AcceptAdmin(cur realm) { rejectStraySend(cur) caller := cur.Previous().Address() var zero address if pendingAdmin == zero || caller != pendingAdmin { panic("only the proposed admin may accept") } old := admin admin = caller pendingAdmin = zero chain.Emit("AdminTransferred", "old", old.String(), "new", admin.String()) } // --- read-only views --- // ScheduleInfo returns a schedule's fields by value. func ScheduleInfo(id int64) (funder, beneficiary address, title string, total, feeBps int64, revocable bool, createdAt, numTranches, pending, releasedGross, revokedTotal int64) { s := mustSchedule(id) return s.funder, s.beneficiary, s.title, s.total, s.feeBps, s.revocable, s.createdAt, int64(len(s.trancheIDs)), s.pending, s.releasedGross, s.revokedTotal } // Memo returns a schedule's raw memo text. func Memo(id int64) string { return mustSchedule(id).memo } // TrancheInfo returns one tranche's fields by value. It answers for settled // tranches too, after duebook has consumed the deferral. func TrancheInfo(trancheID int64) (scheduleID, seq, amount, dueAt int64, status string, settledAt int64) { t := mustTranche(mustID(trancheID)) return t.scheduleID, t.seq, t.amount, t.dueAt, t.status, t.settledAt } // TranchesOf lists a schedule's tranches, newline-separated, one per line // as "id seq amount dueAt status". func TranchesOf(id int64) string { s := mustSchedule(id) out := "" for _, did := range s.trancheIDs { t := tranches.Get(padU(did)).(*tranche) out += utoa(did) + " " + itoa(t.seq) + " " + itoa(t.amount) + " " + itoa(t.dueAt) + " " + t.status + "\n" } return out } // VestedOf returns, for a schedule, the amount whose due height has arrived // and which is therefore no longer revocable — whether or not it has been // released yet — alongside the amount still revocable by the funder. func VestedOf(id int64) (vested, unvested int64) { s := mustSchedule(id) now := runtime.ChainHeight() for _, did := range s.trancheIDs { t := tranches.Get(padU(did)).(*tranche) if t.status == StatusRevoked { continue } if now >= t.dueAt { vested += t.amount } else { unvested += t.amount } } return vested, unvested } // ReleasableNow lists tranche ids that are due and still pending, up to // MaxListed, newline-separated. Anyone may Release each of them. func ReleasableNow() string { now := runtime.ChainHeight() due := book.Due(now, MaxListed) out := "" for _, d := range due { out += utoa(d.ID) + "\n" } return out } // Admin returns the current admin. func Admin() address { return admin } // PendingAdmin returns the proposed successor admin, or the zero address. func PendingAdmin() address { return pendingAdmin } // FeeRecipient returns who may withdraw fees and sweep surplus. func FeeRecipient() address { return feeRecipient } // FeeBps returns the fee that will be snapshotted into newly funded // schedules. Existing schedules keep their own snapshot. func FeeBps() int64 { return feeBps } // FeeOn previews the fee and net credit for a tranche of amount at the // CURRENT FeeBps. func FeeOn(amount int64) (fee, credited int64) { f, err := feeledger.FeeFor(amount, feeBps) if err != nil { panic(err) } return f, amount - f } // NumSchedules returns how many schedules have ever been funded. func NumSchedules() int64 { return nextScheduleID } // OpenTrancheCount returns how many tranches are pending across the whole // realm, against the MaxOpenTranches cap. func OpenTrancheCount() int { return book.OpenCount() } // OpenTranchesOfFunder returns how many tranches funder currently holds // pending, against the MaxOpenPerFunder cap. A funder can read this before // funding to see the headroom they have left. func OpenTranchesOfFunder(funder address) int64 { return openOf(funder) } // FundersWithOpenTranches returns how many distinct funders hold at least // one pending tranche. It is the width of the per-funder cap: exhausting // MaxOpenTranches requires this to reach ceil(MaxOpenTranches/MaxOpenPerFunder). func FundersWithOpenTranches() int { return openByFunder.Size() } // EscrowTotal returns the escrow held by pending tranches (the E term). func EscrowTotal() int64 { return escrowTotal } // BalanceOf returns addr's claimable balance (released tranches for a // beneficiary, revocations for a funder). func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) } // UsersTotal returns the sum of all claimable balances (the U term). func UsersTotal() int64 { return ledger.UsersTotal() } // FeesAccrued returns the fee pot (the F term). func FeesAccrued() int64 { return ledger.FeesAccrued() } // Liabilities returns everything this realm owes: // EscrowTotal + UsersTotal + FeesAccrued. func Liabilities() int64 { return escrowTotal + ledger.Liabilities() } // Held returns the ugnot actually held at this realm's address (H). func Held() int64 { return coinio.HeldAt(self, Denom) } // Surplus returns Held() - Liabilities() (the S term). func Surplus() int64 { return Held() - Liabilities() } // Address returns this realm's address (the escrow target). func Address() address { return self } // Height returns the current chain height. Delays in Fund's spec are // relative to the height at which Fund executes, so this is an aid for // composing a spec, not an input to one. func Height() int64 { return runtime.ChainHeight() } // --- render --- // Render shows the realm at "" and a schedule detail at "<id>". Free text // (titles are charset-restricted; memos are not) passes through the // ecosystem sanitizer before it reaches markdown. func Render(path string) string { if path != "" { return renderSchedule(path) } held := Held() liab := Liabilities() status := "OK" if held < liab { status = "VIOLATED" } now := runtime.ChainHeight() out := "# Vesting — dated tranche disbursement\n\n" out += "Escrowed GNOT released in **discrete, individually dated tranches** — not by " out += "linear accrual. A tranche is worth nothing before its due height and its full " out += "amount from that height on.\n\n" out += "Schedule and exactly-once release via `duebook`; coin custody via `coinio`; " out += "accounting via `feeledger`.\n\n" out += "## Configuration\n\n" out += "- fee for new schedules: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) + " bps; each schedule keeps the fee snapshotted at funding)\n" out += "- tranches per schedule: up to " + itoa(MaxTranches) + "\n" out += "- pending tranches realm-wide: " + itoa(int64(book.OpenCount())) + " of " + itoa(int64(MaxOpenTranches)) + "\n" out += "- pending tranches per funder: up to " + itoa(int64(MaxOpenPerFunder)) + " (" + itoa(int64(openByFunder.Size())) + " funder(s) hold at least one)\n" out += "- delay range: " + itoa(MinDelayBlocks) + " to " + itoa(MaxDelayBlocks) + " blocks\n" out += "- fee recipient: " + feeRecipient.String() + "\n" out += "- admin: " + admin.String() + "\n" out += "- current height: " + itoa(now) + "\n\n" out += "## Accounting (H == E + U + F + S)\n\n" out += "- escrowed in pending tranches (E): " + itoa(escrowTotal) + Denom + "\n" out += "- claimable (U): " + itoa(ledger.UsersTotal()) + Denom + "\n" out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n" out += "- held (H): " + itoa(held) + Denom + "\n" out += "- conservation: " + status + "\n\n" out += "## Latest schedules\n\n" if nextScheduleID == 0 { out += "No schedules yet.\n" return out } shown := 0 schedules.ReverseIterate("", "", func(_ string, v any) bool { s := v.(*schedule) kind := "irrevocable" if s.revocable { kind = "revocable" } out += "- [#" + itoa(s.id) + "](/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/vesting:" + itoa(s.id) + ") " + s.title + " — " + itoa(s.total) + Denom + " in " + itoa(int64(len(s.trancheIDs))) + " tranches (" + itoa(s.pending) + " pending, " + kind + ")\n" shown++ return shown >= 20 }) return out } func renderSchedule(path string) string { id, err := strconv.ParseInt(path, 10, 64) if err != nil { return "> [!WARNING]\n> invalid schedule id\n" } v := schedules.Get(padID(id)) if v == nil { return "> [!WARNING]\n> unknown schedule id\n" } s := v.(*schedule) now := runtime.ChainHeight() vested, unvested := VestedOf(s.id) out := "# Schedule #" + itoa(s.id) + ": " + s.title + "\n\n" out += "- funder: " + s.funder.String() + "\n" out += "- beneficiary: " + s.beneficiary.String() + "\n" out += "- total: " + itoa(s.total) + Denom + " in " + itoa(int64(len(s.trancheIDs))) + " tranches\n" out += "- fee (snapshot): " + itoa(s.feeBps) + " bps\n" if s.revocable { out += "- revocable by the funder — but only for tranches that are NOT yet due\n" } else { out += "- **irrevocable**: the funder cannot reclaim any tranche\n" } out += "- funded at height: " + itoa(s.createdAt) + " (now " + itoa(now) + ")\n" out += "- vested (no longer revocable): " + itoa(vested) + Denom + "\n" out += "- unvested (still revocable if revocable): " + itoa(unvested) + Denom + "\n" out += "- released so far: " + itoa(s.releasedGross) + Denom + "; revoked: " + itoa(s.revokedTotal) + Denom + "\n\n" out += "## Tranches\n\n" out += "| # | id | amount | due at | status |\n|---|---|---|---|---|\n" shown := 0 for _, did := range s.trancheIDs { if shown >= MaxListed { out += "| … | | | | (" + itoa(int64(len(s.trancheIDs))-int64(shown)) + " more) |\n" break } t := tranches.Get(padU(did)).(*tranche) note := t.status if t.status == StatusPending && now >= t.dueAt { note = "**releasable now**" } out += "| " + itoa(t.seq) + " | " + utoa(did) + " | " + itoa(t.amount) + Denom + " | " + itoa(t.dueAt) + " | " + note + " |\n" shown++ } if s.memo != "" { out += "\n## Memo\n\n" + sanitize.InlineText(s.memo) + "\n" } return out } // --- internals --- func assertAdmin(caller address) { if caller != admin { panic("admin only") } } // assertRevocableBy checks both halves of the revocation authority: the // schedule must have been funded revocable, and the caller must be its // funder. func assertRevocableBy(s *schedule, caller address) { if !s.revocable { panic("schedule " + itoa(s.id) + " is irrevocable") } if caller != s.funder { panic("only the funder may revoke") } } func mustSchedule(id int64) *schedule { v := schedules.Get(padID(id)) if v == nil { panic("unknown schedule id") } return v.(*schedule) } func mustTranche(did uint64) *tranche { v := tranches.Get(padU(did)) if v == nil { panic("unknown tranche id") } return v.(*tranche) } // openOf reports how many tranches funder currently holds pending. A // missing key means zero, which is the same thing: addOpen removes the // entry when the count reaches zero. func openOf(funder address) int64 { v := openByFunder.Get(funder.String()) if v == nil { return 0 } return v.(int64) } // addOpen adjusts funder's pending-tranche count by delta, which may be // negative. Every increment happens in Fund and every decrement is paired // with a pending -> terminal transition, so the count tracks exactly the // tranches openOf claims. func addOpen(funder address, delta int64) { next := openOf(funder) + delta if next < 0 { // Unreachable: a tranche can only leave StatusPending once, and it // was counted when it was created. Kept as a consistency assertion // — if it fires, this counter has diverged from the tranche records // and the transaction must not proceed. panic("internal: funder open-tranche count went negative") } if next == 0 { openByFunder.Remove(funder.String()) return } openByFunder.Set(funder.String(), next) } // mustID converts an entrypoint's int64 tranche id to duebook's uint64, // rejecting the non-positive values duebook never issues. func mustID(id int64) uint64 { if id <= 0 { panic("tranche id must be positive") } return uint64(id) } // parseSpec parses "delay:amount,delay:amount,…" into parallel slices, // enforcing every bound before any state is touched. Delays must be // strictly increasing; amounts must be positive. Nothing here consults or // mutates realm state, so a malformed spec is rejected with the escrow // still untouched (the whole transaction aborts regardless). func parseSpec(spec string) (delays, amounts []int64) { if spec == "" { panic("spec must list at least one tranche as \"delay:amount\"") } if len(spec) > MaxSpecLen { panic("spec exceeds " + itoa(MaxSpecLen) + " bytes") } parts := strings.Split(spec, ",") if len(parts) > MaxTranches { panic("a schedule may have at most " + itoa(MaxTranches) + " tranches, got " + itoa(int64(len(parts)))) } prev := int64(-1) for i, part := range parts { at := strings.Index(part, ":") if at < 0 { panic("tranche " + itoa(int64(i+1)) + ": expected \"delay:amount\", got \"" + part + "\"") } delay, err := strconv.ParseInt(part[:at], 10, 64) if err != nil { panic("tranche " + itoa(int64(i+1)) + ": delay is not an integer") } amount, err := strconv.ParseInt(part[at+1:], 10, 64) if err != nil { panic("tranche " + itoa(int64(i+1)) + ": amount is not an integer") } if delay < MinDelayBlocks || delay > MaxDelayBlocks { panic("tranche " + itoa(int64(i+1)) + ": delay " + itoa(delay) + " outside [" + itoa(MinDelayBlocks) + ", " + itoa(MaxDelayBlocks) + "]") } if delay <= prev { panic("tranche " + itoa(int64(i+1)) + ": delays must be strictly increasing (" + itoa(delay) + " follows " + itoa(prev) + "); combine same-height payments") } if amount <= 0 { panic("tranche " + itoa(int64(i+1)) + ": amount must be positive") } prev = delay delays = append(delays, delay) amounts = append(amounts, amount) } return delays, amounts } // assertValidTitle bounds length and restricts the charset so titles are // list-safe in Render without escaping. func assertValidTitle(title string) { if len(title) == 0 || len(title) > MaxTitleLen { panic("title must be 1-" + itoa(MaxTitleLen) + " characters") } for i := 0; i < len(title); i++ { c := title[i] switch { case c >= 'a' && c <= 'z': case c >= 'A' && c <= 'Z': case c >= '0' && c <= '9': case c == ' ' || c == '_' || c == '-': default: panic("title may only contain letters, digits, space, _ and -") } } } // padID renders a schedule id as a fixed-width key so avl iteration order // is numeric order. func padID(id int64) string { s := strconv.FormatInt(id, 10) for len(s) < 12 { s = "0" + s } return s } // padU renders a deferral id as a fixed-width key. 20 digits covers the // whole uint64 range, so tranche iteration is numeric order too. func padU(id uint64) string { s := strconv.FormatUint(id, 10) for len(s) < 20 { s = "0" + s } return s } func itoa(n int64) string { return strconv.FormatInt(n, 10) } func utoa(n uint64) string { return strconv.FormatUint(n, 10) } func btoa(b bool) string { if b { return "true" } return "false" } func checkedAdd(a, b int64) (int64, bool) { sum := a + b if (b > 0 && sum < a) || (b < 0 && sum > a) { return 0, false } return sum, true }
  6. #6vesting_test.gno
  7. #7package vesting import ( "strings" "testing" "chain" "chain/banker" "chain/runtime" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( testAdmin = testutils.TestAddress("vestadmin") funder1 = testutils.TestAddress("vfunder1") funder2 = testutils.TestAddress("vfunder2") bene1 = testutils.TestAddress("vbene1") bene2 = testutils.TestAddress("vbene2") mallory = testutils.TestAddress("vmallory") stranger = testutils.TestAddress("vstranger") ) // HARNESS NOTES, carried from the duebook_demo and grants suites and // re-verified here: // // - testing.SetRealm records the override per FRAME INDEX. A helper that // stages a caller and RETURNS leaves the test frame untouched, so the // previous staging silently wins and every caller looks like the same // account — which hides exactly the authorization bugs these tests // exist to find. Every caller below is therefore staged INLINE in the // frame that makes the call. The staging helpers (fundAs, callAs) are // safe only because they make their call from inside their own frame. // // - Realm globals PERSIST across tests in the test VM, while banker // state resets per test. The monetary invariant is therefore asserted // in DELTA form after re-seeding carried liabilities (begin()). // // - A panic caught by uassert does NOT roll back realm globals here, // whereas on-chain it aborts and reverts the whole transaction. Every // abort asserted below happens strictly BEFORE this realm writes any // state, so the distinction never bites. The one path where it could — // a book.Schedule failure partway through Fund's loop — is unreachable // because Fund pre-checks capacity, and is not asserted here. // // - Tests must not assume a fixed starting height, schedule id, or // tranche id. They read all three and reason in deltas. func init() { admin = testAdmin feeRecipient = testAdmin self = chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/vesting") } // --- harness --- type baseline struct{ held, escrow, users, fees int64 } func begin() baseline { // Disarm any OriginSend a previous test left staged. The envelope is // process-global, not per-frame, and a leftover one makes the first // non-Fund entrypoint of this test abort in rejectStraySend — which // looks exactly like the guard working and is really the harness // leaking. Each test opens with an empty envelope, as a fresh tx does. testing.SetOriginSend(nil) if l := Liabilities(); l > 0 { testing.IssueCoins(Address(), ugnot(l)) } return baseline{held: Held(), escrow: EscrowTotal(), users: UsersTotal(), fees: FeesAccrued()} } // conserved asserts ΔH == ΔE + ΔU + ΔF + extraSurplus. func (b baseline) conserved(t *testing.T, extraSurplus int64) { t.Helper() dH := Held() - b.held dE := EscrowTotal() - b.escrow dU := UsersTotal() - b.users dF := FeesAccrued() - b.fees if dH != dE+dU+dF+extraSurplus { t.Fatalf("conservation violated: dH=%d dE=%d dU=%d dF=%d dS=%d", dH, dE, dU, dF, extraSurplus) } // NOT a test — an identity. Liabilities() is DEFINED as escrowTotal + // ledger.Liabilities(), and ledger.Liabilities() as usersTotal + // feesAccrued, so both sides are the same expression and this can only // fail on integer overflow. Kept deliberately, as an overflow tripwire // and as executable documentation of the definition; the real // conservation check is the dH comparison above, which is against the // banker's actual balance and can genuinely fail. if Liabilities() != EscrowTotal()+UsersTotal()+FeesAccrued() { t.Fatalf("Liabilities() = %d, want E+U+F", Liabilities()) } } // pendingSum recomputes the sum of amounts over pending tranches — the // application invariant E == Σ pending. func pendingSum() int64 { sum := int64(0) tranches.Iterate("", "", func(_ string, v any) bool { if t := v.(*tranche); t.status == StatusPending { sum += t.amount } return false }) return sum } func pendingCount() int { n := 0 tranches.Iterate("", "", func(_ string, v any) bool { if v.(*tranche).status == StatusPending { n++ } return false }) return n } // appInvariant asserts BOTH stores agree: escrowTotal tracks the pending // tranches, and duebook holds exactly one open deferral per pending // tranche. The second half is the cross-store check that matters most — // a tranche this realm thinks is pending but duebook has consumed would // be unreleasable escrow, and the reverse would be a double-spend. func appInvariant(t *testing.T) { t.Helper() if got := pendingSum(); got != EscrowTotal() { t.Fatalf("app invariant violated: escrowTotal=%d, sum pending=%d", EscrowTotal(), got) } if got := book.OpenCount(); got != pendingCount() { t.Fatalf("store divergence: duebook open=%d, pending tranches=%d", got, pendingCount()) } assertOpenCounts(t) // Per-schedule bookkeeping must add up to the schedule total. for id := int64(1); id <= NumSchedules(); id++ { s := mustSchedule(id) pend := int64(0) for _, did := range s.trancheIDs { if tranches.Get(padU(did)).(*tranche).status == StatusPending { pend += tranches.Get(padU(did)).(*tranche).amount } } if pend+s.releasedGross+s.revokedTotal != s.total { t.Fatalf("schedule %d: pending=%d + released=%d + revoked=%d != total=%d", id, pend, s.releasedGross, s.revokedTotal, s.total) } } } // assertOpenCounts recomputes every funder's pending-tranche count from the // tranche records and compares it to the openByFunder counter. This is the // THIRD cross-store check, and it earns its place: the counter gates Fund, // so a counter that drifts high locks an honest funder out of a realm that // has room, and one that drifts low silently defeats the per-funder cap. // It also asserts the zero-entry removal, because a tree that accumulates a // dead 0 per address that ever funded anything is an unbounded growth path. func assertOpenCounts(t *testing.T) { t.Helper() want := map[string]int64{} tranches.Iterate("", "", func(_ string, v any) bool { tr := v.(*tranche) if tr.status == StatusPending { want[mustSchedule(tr.scheduleID).funder.String()]++ } return false }) got := map[string]int64{} total := int64(0) openByFunder.Iterate("", "", func(k string, v any) bool { n := v.(int64) if n == 0 { t.Fatalf("openByFunder holds a zero entry for %s; it must be removed at zero", k) } got[k] = n total += n return false }) if len(got) != len(want) { t.Fatalf("openByFunder tracks %d funder(s), recomputed %d", len(got), len(want)) } for k, w := range want { if got[k] != w { t.Fatalf("funder %s: counter says %d, tranche records say %d", k, got[k], w) } } if total != int64(pendingCount()) { t.Fatalf("openByFunder totals %d, pending tranches %d", total, pendingCount()) } if got := FundersWithOpenTranches(); got != len(want) { t.Fatalf("FundersWithOpenTranches()=%d, recomputed %d", got, len(want)) } } func coinsOf(addr address) int64 { return banker.NewReadonlyBanker().GetCoin(addr, Denom) } func ugnot(n int64) chain.Coins { return chain.NewCoins(chain.NewCoin(Denom, n)) } // fundAs performs a chain-faithful funded Fund. Must be called from the // test function's own frame. // // The OriginSend envelope is NOT per-frame: whatever was last staged stays // visible to every later call in the process. Fund is the one entrypoint // that accepts coins, so leaving the envelope armed makes the very next // Release/Revoke abort in rejectStraySend instead of reaching the logic // under test. Clear it on the way out — a real transaction ends here. func fundAs(cur realm, from, beneficiary address, total int64, title, memo, spec string, revocable bool) int64 { testing.SetRealm(testing.NewUserRealm(from)) testing.SetOriginCaller(from) testing.SetOriginSend(ugnot(total)) testing.IssueCoins(Address(), ugnot(total)) id := Fund(cross(cur), beneficiary, title, memo, spec, revocable, MaxFeeBps) testing.SetOriginSend(nil) return id } // trancheIDsOf returns a schedule's tranche ids as int64, in order. func trancheIDsOf(id int64) []int64 { s := mustSchedule(id) out := []int64{} for _, did := range s.trancheIDs { out = append(out, int64(did)) } return out } // --- tests --- func TestInitialConfig(t *testing.T) { if Admin() != testAdmin || FeeRecipient() != testAdmin { t.Fatal("seeded roles mismatch") } var zero address if PendingAdmin() != zero { t.Fatal("pendingAdmin should start zero") } if Address() == zero { t.Fatal("realm address is zero") } // Reference duebook's own constant, NOT a hardcoded 10000. With the // literal, both operands were compile-time constants and the branch was // dead — it could not fail even if duebook were redeployed with a lower // limit, and duebook.MustNew would then panic at realm init instead, // turning a caught test failure into a deploy-time abort. if MaxOpenTranches > duebook.MaxOpenLimit { t.Fatalf("MaxOpenTranches %d exceeds duebook.MaxOpenLimit %d", MaxOpenTranches, duebook.MaxOpenLimit) } if MaxOpenPerFunder > MaxOpenTranches { t.Fatalf("MaxOpenPerFunder %d exceeds the whole-realm cap %d", MaxOpenPerFunder, MaxOpenTranches) } if book.MinDelay() != MinDelayBlocks || book.MaxDelay() != MaxDelayBlocks { t.Fatal("book bounds do not match the declared constants") } } func TestFundHappyPath(cur realm, t *testing.T) { b := begin() h := runtime.ChainHeight() id := fundAs(cur, funder1, bene1, 1000, "engineer grant", "a memo", "10:200,20:300,30:500", true) funder, beneficiary, title, total, snapBps, revocable, createdAt, numTranches, pending, releasedGross, revokedTotal := ScheduleInfo(id) if funder != funder1 || beneficiary != bene1 || title != "engineer grant" { t.Fatalf("identity fields wrong: %v %v %q", funder, beneficiary, title) } if total != 1000 || snapBps != 0 || !revocable { t.Fatalf("terms wrong: total=%d bps=%d revocable=%v", total, snapBps, revocable) } if createdAt != h || numTranches != 3 || pending != 3 || releasedGross != 0 || revokedTotal != 0 { t.Fatalf("bookkeeping wrong: createdAt=%d n=%d pending=%d rel=%d rev=%d", createdAt, numTranches, pending, releasedGross, revokedTotal) } if Memo(id) != "a memo" { t.Fatal("memo mismatch") } // Tranche records carry the right amounts and absolute due heights. ids := trancheIDsOf(id) wantAmounts := []int64{200, 300, 500} wantDue := []int64{h + 10, h + 20, h + 30} for i, tid := range ids { sID, seq, amount, dueAt, status, settledAt := TrancheInfo(tid) if sID != id || seq != int64(i+1) || amount != wantAmounts[i] || dueAt != wantDue[i] || status != StatusPending || settledAt != 0 { t.Fatalf("tranche %d wrong: sched=%d seq=%d amount=%d due=%d status=%s settled=%d", tid, sID, seq, amount, dueAt, status, settledAt) } } appInvariant(t) b.conserved(t, 0) } func TestFundRejectsEnvelope(cur realm, t *testing.T) { b := begin() // Not a direct EOA call (coinio.Receive guard). testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/attacker")) testing.SetOriginSend(ugnot(100)) uassert.AbortsContains(t, cur, "direct EOA call", func() { Fund(cross(cur), bene1, "evil", "", "10:100", false, MaxFeeBps) }) // No coins at all. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) testing.SetOriginSend(nil) uassert.AbortsContains(t, cur, "exactly one coin type", func() { Fund(cross(cur), bene1, "no coins", "", "10:100", false, MaxFeeBps) }) // Wrong denomination. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) testing.SetOriginSend(chain.NewCoins(chain.NewCoin("foocoin", 100))) uassert.AbortsContains(t, cur, "exactly one coin type", func() { Fund(cross(cur), bene1, "wrong denom", "", "10:100", false, MaxFeeBps) }) appInvariant(t) b.conserved(t, 0) } func TestFundRejectsTerms(cur realm, t *testing.T) { b := begin() var zero address // Each case stages inline, because SetRealm is per-frame. cases := []struct { name, want string beneficiary address title, memo string spec string sent int64 }{ {"zero beneficiary", "empty beneficiary", zero, "t", "", "10:100", 100}, // A balance credited to the realm's own address is claimable by // nobody and still counted in Liabilities(), so it is stranded // AND it shrinks SweepDenom's reach. Released by any third // party, so the funder cannot undo it. {"self beneficiary", "must not be this realm's own address", self, "t", "", "10:100", 100}, {"empty title", "title must be", bene1, "", "", "10:100", 100}, {"long title", "title must be", bene1, strings.Repeat("a", MaxTitleLen+1), "", "10:100", 100}, {"bad title charset", "letters, digits", bene1, "bad!title", "", "10:100", 100}, {"long memo", "memo exceeds", bene1, "t", strings.Repeat("m", MaxMemoLen+1), "10:100", 100}, {"empty spec", "at least one tranche", bene1, "t", "", "", 100}, {"long spec", "spec exceeds", bene1, "t", "", strings.Repeat("9", MaxSpecLen+1), 100}, {"missing colon", "expected", bene1, "t", "", "10-100", 100}, {"delay not integer", "delay is not an integer", bene1, "t", "", "ten:100", 100}, {"amount not integer", "amount is not an integer", bene1, "t", "", "10:lots", 100}, {"delay zero", "outside", bene1, "t", "", "0:100", 100}, {"delay too large", "outside", bene1, "t", "", "100000001:100", 100}, {"delays equal", "strictly increasing", bene1, "t", "", "10:50,10:50", 100}, {"delays decreasing", "strictly increasing", bene1, "t", "", "20:50,10:50", 100}, {"zero amount", "amount must be positive", bene1, "t", "", "10:0", 100}, {"negative amount", "amount must be positive", bene1, "t", "", "10:-5", 100}, {"underfunded", "they must be equal", bene1, "t", "", "10:200", 100}, {"overfunded", "they must be equal", bene1, "t", "", "10:50", 100}, } // A cross-call abort is NOT catchable with recover() in the calling // realm — that is what uassert.AbortsContains is for. Staging stays in // this frame, immediately before each call. for _, c := range cases { spec := c.spec ben := c.beneficiary title := c.title memo := c.memo testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) testing.SetOriginSend(ugnot(c.sent)) uassert.AbortsContains(t, cur, c.want, func() { Fund(cross(cur), ben, title, memo, spec, false, MaxFeeBps) }, c.name) } // Too many tranches: MaxTranches+1 entries with increasing delays. parts := []string{} for i := 1; i <= MaxTranches+1; i++ { parts = append(parts, itoa(int64(i))+":1") } testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) testing.SetOriginSend(ugnot(int64(MaxTranches + 1))) uassert.AbortsContains(t, cur, "at most "+itoa(MaxTranches)+" tranches", func() { Fund(cross(cur), bene1, "many", "", strings.Join(parts, ","), false, MaxFeeBps) }) appInvariant(t) b.conserved(t, 0) } func TestReleaseBeforeDueAborts(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 100, "not yet", "", "5:100", false) tid := trancheIDsOf(id)[0] testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) uassert.AbortsContains(t, cur, "not due yet", func() { Release(cross(cur), tid) }) // Nothing moved. if BalanceOf(bene1) != 0 { t.Fatal("beneficiary credited before the due height") } appInvariant(t) b.conserved(t, 0) } // TestReleaseIsPermissionlessAndPaysTheBeneficiary is the central // authorization property: a stranger may trigger the release, and doing so // credits the RECORDED beneficiary, never the caller. func TestReleaseIsPermissionlessAndPaysTheBeneficiary(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 400, "payable", "", "1:400", false) tid := trancheIDsOf(id)[0] beneBefore := BalanceOf(bene1) malloryBefore := BalanceOf(mallory) testing.SkipHeights(1) // Mallory, an unrelated account, triggers the release. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) Release(cross(cur), tid) if got := BalanceOf(bene1) - beneBefore; got != 400 { t.Fatalf("beneficiary credited %d, want 400", got) } if got := BalanceOf(mallory) - malloryBefore; got != 0 { t.Fatalf("the caller was credited %d; release must never pay the caller", got) } _, _, _, _, status, settledAt := TrancheInfo(tid) if status != StatusReleased || settledAt == 0 { t.Fatalf("tranche not marked released: status=%s settledAt=%d", status, settledAt) } appInvariant(t) b.conserved(t, 0) } // TestDoubleReleaseIsImpossible is the property duebook exists to provide. // The second attempt must fail because the deferral no longer exists — not // because this realm re-checked a status flag. func TestDoubleReleaseIsImpossible(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 700, "once only", "", "1:700", false) tid := trancheIDsOf(id)[0] testing.SkipHeights(1) testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) Release(cross(cur), tid) after := BalanceOf(bene1) // Second attempt, same transaction-equivalent frame and a different // caller: duebook has consumed the ID and can never return it again. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), tid) }) // And a third, from the beneficiary. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), tid) }) if BalanceOf(bene1) != after { t.Fatal("a repeat release changed the balance") } appInvariant(t) b.conserved(t, 0) } // TestVestedTrancheCannotBeRevoked is this realm's own check, not // duebook's: duebook.Cancel would consume a due-but-unclaimed deferral // happily. Without the refusal a funder could front-run the beneficiary. func TestVestedTrancheCannotBeRevoked(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 300, "vested now", "", "1:300", true) tid := trancheIDsOf(id)[0] testing.SkipHeights(1) // now == dueAt: vested testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) uassert.AbortsContains(t, cur, "can never be revoked", func() { Revoke(cross(cur), tid) }) // Still releasable to the beneficiary afterwards. before := BalanceOf(bene1) testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) Release(cross(cur), tid) if got := BalanceOf(bene1) - before; got != 300 { t.Fatalf("beneficiary credited %d after a refused revocation, want 300", got) } appInvariant(t) b.conserved(t, 0) } func TestRevokeAuthorization(cur realm, t *testing.T) { b := begin() rev := fundAs(cur, funder1, bene1, 500, "revocable one", "", "100:500", true) irr := fundAs(cur, funder2, bene2, 600, "irrevocable one", "", "100:600", false) revTID := trancheIDsOf(rev)[0] irrTID := trancheIDsOf(irr)[0] // A stranger may not revoke. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "only the funder may revoke", func() { Revoke(cross(cur), revTID) }) // Nor may the beneficiary. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) uassert.AbortsContains(t, cur, "only the funder may revoke", func() { Revoke(cross(cur), revTID) }) // Nor the OTHER schedule's funder. testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) uassert.AbortsContains(t, cur, "only the funder may revoke", func() { Revoke(cross(cur), revTID) }) // An irrevocable schedule refuses even its own funder. testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) uassert.AbortsContains(t, cur, "is irrevocable", func() { Revoke(cross(cur), irrTID) }) // RevokeAll carries the same two checks. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "only the funder may revoke", func() { RevokeAll(cross(cur), rev) }) testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) uassert.AbortsContains(t, cur, "is irrevocable", func() { RevokeAll(cross(cur), irr) }) appInvariant(t) b.conserved(t, 0) } func TestRevokeRefundsFunderFeeFree(cur realm, t *testing.T) { // A non-zero fee must NOT be charged on a revocation. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 500) b := begin() id := fundAs(cur, funder1, bene1, 800, "clawback", "", "1000:800", true) tid := trancheIDsOf(id)[0] funderBefore := BalanceOf(funder1) feesBefore := FeesAccrued() testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) Revoke(cross(cur), tid) if got := BalanceOf(funder1) - funderBefore; got != 800 { t.Fatalf("funder refunded %d, want the full 800 fee-free", got) } if FeesAccrued() != feesBefore { t.Fatalf("a revocation accrued a fee: %d -> %d", feesBefore, FeesAccrued()) } _, _, _, _, status, settledAt := TrancheInfo(tid) if status != StatusRevoked || settledAt == 0 { t.Fatalf("tranche not marked revoked: %s %d", status, settledAt) } // A revoked tranche can never be released: duebook consumed the ID. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), tid) }) appInvariant(t) b.conserved(t, 0) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 0) } func TestRevokeAllSkipsVestedAndSettled(cur realm, t *testing.T) { b := begin() // Tranche 1 due at +1, the rest far out. id := fundAs(cur, funder1, bene1, 1000, "mixed", "", "1:100,500:200,600:300,700:400", true) ids := trancheIDsOf(id) testing.SkipHeights(1) // tranche 1 vests // Release tranche 1 so it is settled, not merely vested. testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) Release(cross(cur), ids[0]) funderBefore := BalanceOf(funder1) beneBefore := BalanceOf(bene1) testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) RevokeAll(cross(cur), id) // The three unvested tranches came back; the released one did not. if got := BalanceOf(funder1) - funderBefore; got != 900 { t.Fatalf("funder refunded %d, want 900 (1000 less the released 100)", got) } // Assert the DELTA, not the absolute. BalanceOf(bene1) is cumulative // across the whole suite and is already far above 100 by this point, so // a `< 100` check could not fail however badly RevokeAll behaved. What // must hold is that RevokeAll moved nothing out of the beneficiary. if got := BalanceOf(bene1) - beneBefore; got != 0 { t.Fatalf("RevokeAll changed the beneficiary balance by %d, want 0", got) } _, _, _, _, st0, _ := TrancheInfo(ids[0]) if st0 != StatusReleased { t.Fatalf("released tranche was altered by RevokeAll: %s", st0) } for _, tid := range ids[1:] { _, _, _, _, st, _ := TrancheInfo(tid) if st != StatusRevoked { t.Fatalf("tranche %d status %s, want revoked", tid, st) } } // A second RevokeAll has nothing left to do and must say so rather // than report a silent success. testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) uassert.AbortsContains(t, cur, "no revocable tranches", func() { RevokeAll(cross(cur), id) }) appInvariant(t) b.conserved(t, 0) } func TestWithdrawPaysOutAndDebitsFirst(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 250, "payout test", "", "1:250", false) tid := trancheIDsOf(id)[0] testing.SkipHeights(1) testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) Release(cross(cur), tid) claimable := BalanceOf(bene1) if claimable < 250 { t.Fatalf("claimable %d, want at least 250", claimable) } coinsBefore := coinsOf(bene1) // Partial withdrawal. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) testing.SetOriginSend(nil) Withdraw(cross(cur), 100) if got := coinsOf(bene1) - coinsBefore; got != 100 { t.Fatalf("received %d coins, want 100", got) } if BalanceOf(bene1) != claimable-100 { t.Fatalf("ledger not debited: %d", BalanceOf(bene1)) } // Over-withdrawal is refused. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) uassert.AbortsContains(t, cur, "insufficient balance", func() { Withdraw(cross(cur), claimable) }) // The rest. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) WithdrawAll(cross(cur)) if BalanceOf(bene1) != 0 { t.Fatalf("balance after WithdrawAll = %d", BalanceOf(bene1)) } // Nothing left to withdraw: feeledger returns (0, nil) for an empty // account, so this realm's own guard is what turns that into an abort. testing.SetRealm(testing.NewUserRealm(bene1)) testing.SetOriginCaller(bene1) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { WithdrawAll(cross(cur)) }) // A stranger with no balance cannot withdraw. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "insufficient balance", func() { Withdraw(cross(cur), 1) }) // Every withdrawal moved H and U by the same amount, so the identity // holds with no surplus term: dH == dE + dU + dF. appInvariant(t) b.conserved(t, 0) } // TestFeeSnapshotAndCeiling covers both halves of the fee race: a fee // change never reaches an already-funded schedule, and a fee raise // sequenced ahead of a Fund is refused rather than silently applied. func TestFeeSnapshotAndCeiling(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 200) b := begin() id := fundAs(cur, funder1, bene1, 1000, "snapshotted", "", "1:1000", false) _, _, _, _, snapBps, _, _, _, _, _, _ := ScheduleInfo(id) if snapBps != 200 { t.Fatalf("snapshot bps = %d, want 200", snapBps) } // Admin raises the fee AFTER funding; the schedule keeps 200. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 1000) tid := trancheIDsOf(id)[0] testing.SkipHeights(1) beneBefore := BalanceOf(bene1) feesBefore := FeesAccrued() testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) Release(cross(cur), tid) // 200 bps of 1000 == 20, charged at the SNAPSHOT, not the live 1000 bps. if got := FeesAccrued() - feesBefore; got != 20 { t.Fatalf("fee charged = %d, want 20 (the snapshot)", got) } if got := BalanceOf(bene1) - beneBefore; got != 980 { t.Fatalf("beneficiary credited %d, want 980", got) } // The creation-time race: the live fee is now 1000, so a caller who // signs for at most 200 must be refused. testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) testing.SetOriginSend(ugnot(100)) uassert.AbortsContains(t, cur, "exceeds the caller's maximum", func() { Fund(cross(cur), bene1, "raced", "", "10:100", false, 200) }) testing.SetOriginSend(nil) // disarm; the envelope is process-global appInvariant(t) b.conserved(t, 0) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 0) } func TestFeeWithdrawalAuthorization(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 1000) b := begin() id := fundAs(cur, funder1, bene1, 1000, "fees", "", "1:1000", false) tid := trancheIDsOf(id)[0] testing.SkipHeights(1) testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) Release(cross(cur), tid) if FeesAccrued() == 0 { t.Fatal("no fee accrued at 1000 bps") } // A non-recipient cannot withdraw fees or sweep. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "only the fee recipient", func() { WithdrawFees(cross(cur)) }) testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "only the fee recipient", func() { SweepDenom(cross(cur), Denom) }) pot := FeesAccrued() coinsBefore := coinsOf(testAdmin) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) WithdrawFees(cross(cur)) if got := coinsOf(testAdmin) - coinsBefore; got != pot { t.Fatalf("fee recipient received %d, want %d", got, pot) } if FeesAccrued() != 0 { t.Fatalf("fee pot not drained: %d", FeesAccrued()) } // Draining twice is refused. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) uassert.AbortsContains(t, cur, "no fees accrued", func() { WithdrawFees(cross(cur)) }) appInvariant(t) // The withdrawal took 120 out of H *and* out of F, so both sides of the // identity moved together and there is no surplus term. dF is only -20 // because 20 of that 120 was accrued by an earlier test and is inside // the baseline: F went 20 -> 120 -> 0. Subtracting `pot` here would be // double-counting the very transfer dH already records. b.conserved(t, 0) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 0) } // TestSetFeeRecipientSettlesTheOutgoingRecipient is audit finding Y2: the // fee pot is a single scalar with no per-recipient accounting, so a bare // re-point would hand every basis point earned under the old recipient to // the new one at the admin's sole discretion. func TestSetFeeRecipientSettlesTheOutgoingRecipient(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 1000) b := begin() id := fundAs(cur, funder1, bene1, 1000, "settle", "", "1:1000", false) tid := trancheIDsOf(id)[0] testing.SkipHeights(1) testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) Release(cross(cur), tid) pot := FeesAccrued() if pot == 0 { t.Fatal("no fee accrued at 1000 bps") } oldRecipient := FeeRecipient() oldClaimBefore := BalanceOf(oldRecipient) newClaimBefore := BalanceOf(bene2) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeRecipient(cross(cur), bene2) if FeeRecipient() != bene2 { t.Fatalf("recipient is %s, want %s", FeeRecipient(), bene2) } // The pot moved to the OUTGOING recipient's claimable balance... if got := BalanceOf(oldRecipient) - oldClaimBefore; got != pot { t.Fatalf("outgoing recipient credited %d, want the whole pot %d", got, pot) } // ...and F is now empty, so the successor inherits the role, not the // earnings. This is the assertion the finding is about. if FeesAccrued() != 0 { t.Fatalf("fee pot still holds %d after re-pointing", FeesAccrued()) } if got := BalanceOf(bene2) - newClaimBefore; got != 0 { t.Fatalf("successor was credited %d of the predecessor's earnings, want 0", got) } // The successor has no pot to drain. testing.SetRealm(testing.NewUserRealm(bene2)) testing.SetOriginCaller(bene2) uassert.AbortsContains(t, cur, "no fees accrued", func() { WithdrawFees(cross(cur)) }) // No coins moved: F simply became U. Conservation is untouched. appInvariant(t) b.conserved(t, 0) // A re-point to the SAME address is a no-op and must not churn the pot. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeRecipient(cross(cur), bene2) if FeesAccrued() != 0 { t.Fatal("self re-point disturbed the pot") } // Restore the suite's baseline roles and fee. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeRecipient(cross(cur), testAdmin) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 0) } // TestPerFunderCapIsEnforcedAndFreed is audit finding Y1: MaxOpenTranches is // a whole-realm cap, and without a per-funder bound one account could fill // it for ~nothing and then reclaim every coin fee-free with RevokeAll. // // It also closes the coverage gap the audit named: before this, NO test // exercised a capacity check at all. The whole-realm cap itself stays // untested on purpose — reaching it needs 5000 tranches, and the per-funder // cap is now the binding one for any single account, which is the point. func TestPerFunderCapIsEnforcedAndFreed(cur realm, t *testing.T) { b := begin() // onesSpec builds n tranches of 1ugnot with strictly increasing delays. // n == MaxTranches is the largest schedule a single Fund can create. onesSpec := func(n int64) string { parts := []string{} for i := int64(1); i <= n; i++ { parts = append(parts, itoa(i)+":1") } return strings.Join(parts, ",") } // Realm globals persist across tests, so funder2 may already hold pending // tranches and the headroom need not divide evenly by MaxTranches. Fill // with full schedules and top off with the remainder rather than assert an // arithmetic coincidence: an earlier draft asserted room%MaxTranches==0 and // failed at room=255. start := OpenTranchesOfFunder(funder2) room := int64(MaxOpenPerFunder) - start if room <= 0 { t.Fatalf("funder2 starts at %d pending, already at the cap %d", start, MaxOpenPerFunder) } ids := []int64{} sizes := []int64{} for left := room; left > 0; { n := int64(MaxTranches) if left < n { n = left } ids = append(ids, fundAs(cur, funder2, bene2, n, "fill", "", onesSpec(n), true)) sizes = append(sizes, n) left -= n } if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder) { t.Fatalf("funder2 holds %d pending, want the cap %d", got, MaxOpenPerFunder) } // One more tranche — a single one — must be refused. testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) testing.SetOriginSend(ugnot(1)) testing.IssueCoins(Address(), ugnot(1)) uassert.AbortsContains(t, cur, "funder tranche capacity reached", func() { Fund(cross(cur), bene2, "one too many", "", "1:1", false, MaxFeeBps) }) testing.SetOriginSend(nil) // The 1ugnot issued for that rejected Fund never entered the escrow, so // it is surplus now. Account for it explicitly rather than let it hide // inside a tolerance. surplus := int64(1) // The cap is PER FUNDER, not global: a different funder is unaffected // even though funder2 is at its own ceiling. otherID := fundAs(cur, funder1, bene1, 5, "unaffected", "", "1:5", true) if OpenTranchesOfFunder(funder1) < 1 { t.Fatal("funder1 was blocked by funder2's cap") } // Releasing or revoking frees the slot again. Revoke one whole schedule // and confirm funder2 gets back exactly that schedule's worth of headroom. testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) RevokeAll(cross(cur), ids[0]) if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder)-sizes[0] { t.Fatalf("after RevokeAll funder2 holds %d, want %d", got, int64(MaxOpenPerFunder)-sizes[0]) } // ...and can fund again. ids[0] = fundAs(cur, funder2, bene2, sizes[0], "refill", "", onesSpec(sizes[0]), true) if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder) { t.Fatalf("after refunding funder2 holds %d, want the cap %d", got, MaxOpenPerFunder) } // Clean up: return funder2 to its starting count so later tests are not // funding against a full account, and drop the counter entries to prove // the zero-removal path runs. for _, id := range ids { testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) RevokeAll(cross(cur), id) } testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) RevokeAll(cross(cur), otherID) if got := OpenTranchesOfFunder(funder2); got != start { t.Fatalf("funder2 left with %d pending, want the starting %d", got, start) } appInvariant(t) b.conserved(t, surplus) } // TestSweepCannotTouchEscrow is the reserve property: surplus is // sweepable, escrowed tranches and claimable balances are not. func TestSweepCannotTouchEscrow(cur realm, t *testing.T) { b := begin() fundAs(cur, funder1, bene1, 900, "reserved", "", "1000:900", false) // Force-send 77 ugnot out of band, then sweep. Only the 77 may move. testing.IssueCoins(Address(), ugnot(77)) if got := Surplus(); got != 77 { t.Fatalf("surplus = %d, want 77", got) } liabBefore := Liabilities() coinsBefore := coinsOf(testAdmin) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SweepDenom(cross(cur), Denom) if got := coinsOf(testAdmin) - coinsBefore; got != 77 { t.Fatalf("swept %d, want exactly the 77 surplus", got) } if Liabilities() != liabBefore { t.Fatalf("sweep changed liabilities: %d -> %d", liabBefore, Liabilities()) } if Held() < Liabilities() { t.Fatalf("sweep broke solvency: held=%d liabilities=%d", Held(), Liabilities()) } // With no surplus left, sweeping aborts rather than touching reserves. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) uassert.AbortsContains(t, cur, "no surplus to sweep", func() { SweepDenom(cross(cur), Denom) }) appInvariant(t) b.conserved(t, 0) } func TestAdminHandoverIsTwoStep(cur realm, t *testing.T) { var zero address // Only the admin may propose. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "admin only", func() { ProposeAdmin(cross(cur), mallory) }) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) ProposeAdmin(cross(cur), funder2) if PendingAdmin() != funder2 { t.Fatal("proposal not recorded") } if Admin() != testAdmin { t.Fatal("a proposal must not transfer the role on its own") } // Only the proposed account may accept. testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) uassert.AbortsContains(t, cur, "only the proposed admin", func() { AcceptAdmin(cross(cur)) }) testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) AcceptAdmin(cross(cur)) if Admin() != funder2 || PendingAdmin() != zero { t.Fatalf("handover incomplete: admin=%v pending=%v", Admin(), PendingAdmin()) } // Accepting again is refused (the proposal was cleared). testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) uassert.AbortsContains(t, cur, "only the proposed admin", func() { AcceptAdmin(cross(cur)) }) // The old admin has lost the role. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) uassert.AbortsContains(t, cur, "admin only", func() { SetFeeBps(cross(cur), 1) }) // Hand it back so later tests keep their seeded admin. testing.SetRealm(testing.NewUserRealm(funder2)) testing.SetOriginCaller(funder2) ProposeAdmin(cross(cur), testAdmin) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) AcceptAdmin(cross(cur)) if Admin() != testAdmin { t.Fatal("failed to restore the seeded admin") } } func TestSetFeeBounds(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) uassert.AbortsContains(t, cur, "out of range", func() { SetFeeBps(cross(cur), MaxFeeBps+1) }) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) uassert.AbortsContains(t, cur, "out of range", func() { SetFeeBps(cross(cur), -1) }) testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) uassert.AbortsContains(t, cur, "empty fee recipient", func() { var zero address SetFeeRecipient(cross(cur), zero) }) } // TestStraySendRejected: only Fund accepts value. Every other payable- // looking entrypoint must refuse an attached envelope rather than absorb // it into surplus. func TestStraySendRejected(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 100, "stray", "", "1000:100", true) tid := trancheIDsOf(id)[0] const guard = "does not accept coins" testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) testing.SetOriginSend(ugnot(5)) uassert.AbortsContains(t, cur, guard, func() { Release(cross(cur), tid) }, "Release") testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) testing.SetOriginSend(ugnot(5)) uassert.AbortsContains(t, cur, guard, func() { Revoke(cross(cur), tid) }, "Revoke") testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) testing.SetOriginSend(ugnot(5)) uassert.AbortsContains(t, cur, guard, func() { RevokeAll(cross(cur), id) }, "RevokeAll") testing.SetRealm(testing.NewUserRealm(funder1)) testing.SetOriginCaller(funder1) testing.SetOriginSend(ugnot(5)) uassert.AbortsContains(t, cur, guard, func() { WithdrawAll(cross(cur)) }, "WithdrawAll") testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) testing.SetOriginSend(ugnot(5)) uassert.AbortsContains(t, cur, guard, func() { SetFeeBps(cross(cur), 0) }, "SetFeeBps") testing.SetOriginSend(nil) appInvariant(t) b.conserved(t, 0) } func TestUnknownIDsAbort(cur realm, t *testing.T) { // The views are non-crossing, and this test file is compiled INTO the // realm, so calling one is a same-realm call and its panic is an // ordinary panic — not an abort. PanicsContains is the matching // assertion; AbortsContains cannot see it and the panic escapes. // Off-chain a reader reaches these through vm/qeval, which does cross // the boundary and so surfaces the same text as an abort. uassert.PanicsContains(t, cur, "unknown schedule id", func() { ScheduleInfo(999999) }) uassert.PanicsContains(t, cur, "unknown tranche id", func() { TrancheInfo(999999) }) uassert.PanicsContains(t, cur, "must be positive", func() { TrancheInfo(0) }) uassert.PanicsContains(t, cur, "must be positive", func() { TrancheInfo(-1) }) testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginCaller(mallory) // Release consumes before it looks anything up, so an unknown id is // rejected by duebook and carries duebook's wording, not this realm's // "unknown tranche id". That ordering is the replay guard and is not // worth reversing for a nicer message: reading the tranches tree first // would put a lookup ahead of the consume, which is exactly the shape // the consumer contract forbids. uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), 999999) }) } func TestVestedOfTracksTheRevocationBoundary(cur realm, t *testing.T) { b := begin() id := fundAs(cur, funder1, bene1, 600, "boundary", "", "1:100,2:200,1000:300", false) vested, unvested := VestedOf(id) if vested != 0 || unvested != 600 { t.Fatalf("at funding: vested=%d unvested=%d, want 0/600", vested, unvested) } testing.SkipHeights(1) vested, unvested = VestedOf(id) if vested != 100 || unvested != 500 { t.Fatalf("after 1 block: vested=%d unvested=%d, want 100/500", vested, unvested) } testing.SkipHeights(1) vested, unvested = VestedOf(id) if vested != 300 || unvested != 300 { t.Fatalf("after 2 blocks: vested=%d unvested=%d, want 300/300", vested, unvested) } // Releasing does not change the vested figure: vested means "no longer // revocable", whether or not it has been paid out. ids := trancheIDsOf(id) testing.SetRealm(testing.NewUserRealm(stranger)) testing.SetOriginCaller(stranger) Release(cross(cur), ids[0]) vested, unvested = VestedOf(id) if vested != 300 || unvested != 300 { t.Fatalf("after a release: vested=%d unvested=%d, want 300/300", vested, unvested) } appInvariant(t) b.conserved(t, 0) } func TestViewsAndRenderDoNotPanic(cur realm, t *testing.T) { // feeBps is a realm global and persists across tests, so a test that // asserts a fee-free figure must set the fee it wants rather than // inherit whatever the last fee test left — including the case where // that test failed before its own reset line ran. testing.SetRealm(testing.NewUserRealm(testAdmin)) testing.SetOriginCaller(testAdmin) SetFeeBps(cross(cur), 0) b := begin() id := fundAs(cur, funder1, bene1, 300, "renderable", "a *memo* with _markdown_", "1:100,900:200", true) if out := TranchesOf(id); !strings.Contains(out, StatusPending) { t.Fatalf("TranchesOf missing status: %q", out) } testing.SkipHeights(1) if out := ReleasableNow(); out == "" { t.Fatal("ReleasableNow empty with a due tranche") } if fee, credited := FeeOn(1000); fee != 0 || credited != 1000 { t.Fatalf("FeeOn at 0 bps = %d/%d", fee, credited) } if OpenTrancheCount() < 1 { t.Fatal("OpenTrancheCount is zero with pending tranches") } if Height() == 0 { t.Fatal("Height() is zero") } // Assert the STATE-DEPENDENT line, not the heading. "E + U + F + S" is // emitted unconditionally as part of the section title, so matching it // proves only that Render ran — it would keep passing while the realm // rendered "conservation: VIOLATED" underneath. if out := Render(""); !strings.Contains(out, "Vesting") || !strings.Contains(out, "conservation: OK") { t.Fatalf("overview render wrong: %q", head(out, 400)) } if out := Render(""); !strings.Contains(out, "per funder") { t.Fatalf("overview render omits the per-funder cap: %q", head(out, 400)) } if out := Render(itoa(id)); !strings.Contains(out, "Schedule #"+itoa(id)) || !strings.Contains(out, "releasable now") { t.Fatalf("detail render wrong: %q", out) } if out := Render("not-a-number"); !strings.Contains(out, "invalid schedule id") { t.Fatalf("bad path render: %q", out) } if out := Render("999999"); !strings.Contains(out, "unknown schedule id") { t.Fatalf("unknown path render: %q", out) } appInvariant(t) b.conserved(t, 0) } func head(s string, n int) string { if len(s) <= n { return s } return s[:n] }
  8. #8/gno.MemPackageType
  9. #9 MPUserAll

Result log

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