1package vesting23import (4 "strings"5 "testing"67 "chain"8 "chain/banker"9 "chain/runtime"1011 "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook"12 "gno.land/p/nt/testutils/v0"13 "gno.land/p/nt/uassert/v0"14)1516var (17 testAdmin = testutils.TestAddress("vestadmin")18 funder1 = testutils.TestAddress("vfunder1")19 funder2 = testutils.TestAddress("vfunder2")20 bene1 = testutils.TestAddress("vbene1")21 bene2 = testutils.TestAddress("vbene2")22 mallory = testutils.TestAddress("vmallory")23 stranger = testutils.TestAddress("vstranger")24)2526// HARNESS NOTES, carried from the duebook_demo and grants suites and27// re-verified here:28//29// - testing.SetRealm records the override per FRAME INDEX. A helper that30// stages a caller and RETURNS leaves the test frame untouched, so the31// previous staging silently wins and every caller looks like the same32// account — which hides exactly the authorization bugs these tests33// exist to find. Every caller below is therefore staged INLINE in the34// frame that makes the call. The staging helpers (fundAs, callAs) are35// safe only because they make their call from inside their own frame.36//37// - Realm globals PERSIST across tests in the test VM, while banker38// state resets per test. The monetary invariant is therefore asserted39// in DELTA form after re-seeding carried liabilities (begin()).40//41// - A panic caught by uassert does NOT roll back realm globals here,42// whereas on-chain it aborts and reverts the whole transaction. Every43// abort asserted below happens strictly BEFORE this realm writes any44// state, so the distinction never bites. The one path where it could —45// a book.Schedule failure partway through Fund's loop — is unreachable46// because Fund pre-checks capacity, and is not asserted here.47//48// - Tests must not assume a fixed starting height, schedule id, or49// tranche id. They read all three and reason in deltas.50func init() {51 admin = testAdmin52 feeRecipient = testAdmin53 self = chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/vesting")54}5556// --- harness ---5758type baseline struct{ held, escrow, users, fees int64 }5960func begin() baseline {61 // Disarm any OriginSend a previous test left staged. The envelope is62 // process-global, not per-frame, and a leftover one makes the first63 // non-Fund entrypoint of this test abort in rejectStraySend — which64 // looks exactly like the guard working and is really the harness65 // leaking. Each test opens with an empty envelope, as a fresh tx does.66 testing.SetOriginSend(nil)67 if l := Liabilities(); l > 0 {68 testing.IssueCoins(Address(), ugnot(l))69 }70 return baseline{held: Held(), escrow: EscrowTotal(), users: UsersTotal(), fees: FeesAccrued()}71}7273// conserved asserts ΔH == ΔE + ΔU + ΔF + extraSurplus.74func (b baseline) conserved(t *testing.T, extraSurplus int64) {75 t.Helper()76 dH := Held() - b.held77 dE := EscrowTotal() - b.escrow78 dU := UsersTotal() - b.users79 dF := FeesAccrued() - b.fees80 if dH != dE+dU+dF+extraSurplus {81 t.Fatalf("conservation violated: dH=%d dE=%d dU=%d dF=%d dS=%d", dH, dE, dU, dF, extraSurplus)82 }83 // NOT a test — an identity. Liabilities() is DEFINED as escrowTotal +84 // ledger.Liabilities(), and ledger.Liabilities() as usersTotal +85 // feesAccrued, so both sides are the same expression and this can only86 // fail on integer overflow. Kept deliberately, as an overflow tripwire87 // and as executable documentation of the definition; the real88 // conservation check is the dH comparison above, which is against the89 // banker's actual balance and can genuinely fail.90 if Liabilities() != EscrowTotal()+UsersTotal()+FeesAccrued() {91 t.Fatalf("Liabilities() = %d, want E+U+F", Liabilities())92 }93}9495// pendingSum recomputes the sum of amounts over pending tranches — the96// application invariant E == Σ pending.97func pendingSum() int64 {98 sum := int64(0)99 tranches.Iterate("", "", func(_ string, v any) bool {100 if t := v.(*tranche); t.status == StatusPending {101 sum += t.amount102 }103 return false104 })105 return sum106}107108func pendingCount() int {109 n := 0110 tranches.Iterate("", "", func(_ string, v any) bool {111 if v.(*tranche).status == StatusPending {112 n++113 }114 return false115 })116 return n117}118119// appInvariant asserts BOTH stores agree: escrowTotal tracks the pending120// tranches, and duebook holds exactly one open deferral per pending121// tranche. The second half is the cross-store check that matters most —122// a tranche this realm thinks is pending but duebook has consumed would123// be unreleasable escrow, and the reverse would be a double-spend.124func appInvariant(t *testing.T) {125 t.Helper()126 if got := pendingSum(); got != EscrowTotal() {127 t.Fatalf("app invariant violated: escrowTotal=%d, sum pending=%d", EscrowTotal(), got)128 }129 if got := book.OpenCount(); got != pendingCount() {130 t.Fatalf("store divergence: duebook open=%d, pending tranches=%d", got, pendingCount())131 }132 assertOpenCounts(t)133 // Per-schedule bookkeeping must add up to the schedule total.134 for id := int64(1); id <= NumSchedules(); id++ {135 s := mustSchedule(id)136 pend := int64(0)137 for _, did := range s.trancheIDs {138 if tranches.Get(padU(did)).(*tranche).status == StatusPending {139 pend += tranches.Get(padU(did)).(*tranche).amount140 }141 }142 if pend+s.releasedGross+s.revokedTotal != s.total {143 t.Fatalf("schedule %d: pending=%d + released=%d + revoked=%d != total=%d",144 id, pend, s.releasedGross, s.revokedTotal, s.total)145 }146 }147}148149// assertOpenCounts recomputes every funder's pending-tranche count from the150// tranche records and compares it to the openByFunder counter. This is the151// THIRD cross-store check, and it earns its place: the counter gates Fund,152// so a counter that drifts high locks an honest funder out of a realm that153// has room, and one that drifts low silently defeats the per-funder cap.154// It also asserts the zero-entry removal, because a tree that accumulates a155// dead 0 per address that ever funded anything is an unbounded growth path.156func assertOpenCounts(t *testing.T) {157 t.Helper()158159 want := map[string]int64{}160 tranches.Iterate("", "", func(_ string, v any) bool {161 tr := v.(*tranche)162 if tr.status == StatusPending {163 want[mustSchedule(tr.scheduleID).funder.String()]++164 }165 return false166 })167168 got := map[string]int64{}169 total := int64(0)170 openByFunder.Iterate("", "", func(k string, v any) bool {171 n := v.(int64)172 if n == 0 {173 t.Fatalf("openByFunder holds a zero entry for %s; it must be removed at zero", k)174 }175 got[k] = n176 total += n177 return false178 })179180 if len(got) != len(want) {181 t.Fatalf("openByFunder tracks %d funder(s), recomputed %d", len(got), len(want))182 }183 for k, w := range want {184 if got[k] != w {185 t.Fatalf("funder %s: counter says %d, tranche records say %d", k, got[k], w)186 }187 }188 if total != int64(pendingCount()) {189 t.Fatalf("openByFunder totals %d, pending tranches %d", total, pendingCount())190 }191 if got := FundersWithOpenTranches(); got != len(want) {192 t.Fatalf("FundersWithOpenTranches()=%d, recomputed %d", got, len(want))193 }194}195196func coinsOf(addr address) int64 {197 return banker.NewReadonlyBanker().GetCoin(addr, Denom)198}199200func ugnot(n int64) chain.Coins {201 return chain.NewCoins(chain.NewCoin(Denom, n))202}203204// fundAs performs a chain-faithful funded Fund. Must be called from the205// test function's own frame.206//207// The OriginSend envelope is NOT per-frame: whatever was last staged stays208// visible to every later call in the process. Fund is the one entrypoint209// that accepts coins, so leaving the envelope armed makes the very next210// Release/Revoke abort in rejectStraySend instead of reaching the logic211// under test. Clear it on the way out — a real transaction ends here.212func fundAs(cur realm, from, beneficiary address, total int64, title, memo, spec string, revocable bool) int64 {213 testing.SetRealm(testing.NewUserRealm(from))214 testing.SetOriginCaller(from)215 testing.SetOriginSend(ugnot(total))216 testing.IssueCoins(Address(), ugnot(total))217 id := Fund(cross(cur), beneficiary, title, memo, spec, revocable, MaxFeeBps)218 testing.SetOriginSend(nil)219 return id220}221222// trancheIDsOf returns a schedule's tranche ids as int64, in order.223func trancheIDsOf(id int64) []int64 {224 s := mustSchedule(id)225 out := []int64{}226 for _, did := range s.trancheIDs {227 out = append(out, int64(did))228 }229 return out230}231232// --- tests ---233234func TestInitialConfig(t *testing.T) {235 if Admin() != testAdmin || FeeRecipient() != testAdmin {236 t.Fatal("seeded roles mismatch")237 }238 var zero address239 if PendingAdmin() != zero {240 t.Fatal("pendingAdmin should start zero")241 }242 if Address() == zero {243 t.Fatal("realm address is zero")244 }245 // Reference duebook's own constant, NOT a hardcoded 10000. With the246 // literal, both operands were compile-time constants and the branch was247 // dead — it could not fail even if duebook were redeployed with a lower248 // limit, and duebook.MustNew would then panic at realm init instead,249 // turning a caught test failure into a deploy-time abort.250 if MaxOpenTranches > duebook.MaxOpenLimit {251 t.Fatalf("MaxOpenTranches %d exceeds duebook.MaxOpenLimit %d",252 MaxOpenTranches, duebook.MaxOpenLimit)253 }254 if MaxOpenPerFunder > MaxOpenTranches {255 t.Fatalf("MaxOpenPerFunder %d exceeds the whole-realm cap %d",256 MaxOpenPerFunder, MaxOpenTranches)257 }258 if book.MinDelay() != MinDelayBlocks || book.MaxDelay() != MaxDelayBlocks {259 t.Fatal("book bounds do not match the declared constants")260 }261}262263func TestFundHappyPath(cur realm, t *testing.T) {264 b := begin()265 h := runtime.ChainHeight()266267 id := fundAs(cur, funder1, bene1, 1000, "engineer grant", "a memo", "10:200,20:300,30:500", true)268269 funder, beneficiary, title, total, snapBps, revocable,270 createdAt, numTranches, pending, releasedGross, revokedTotal := ScheduleInfo(id)271 if funder != funder1 || beneficiary != bene1 || title != "engineer grant" {272 t.Fatalf("identity fields wrong: %v %v %q", funder, beneficiary, title)273 }274 if total != 1000 || snapBps != 0 || !revocable {275 t.Fatalf("terms wrong: total=%d bps=%d revocable=%v", total, snapBps, revocable)276 }277 if createdAt != h || numTranches != 3 || pending != 3 || releasedGross != 0 || revokedTotal != 0 {278 t.Fatalf("bookkeeping wrong: createdAt=%d n=%d pending=%d rel=%d rev=%d",279 createdAt, numTranches, pending, releasedGross, revokedTotal)280 }281 if Memo(id) != "a memo" {282 t.Fatal("memo mismatch")283 }284285 // Tranche records carry the right amounts and absolute due heights.286 ids := trancheIDsOf(id)287 wantAmounts := []int64{200, 300, 500}288 wantDue := []int64{h + 10, h + 20, h + 30}289 for i, tid := range ids {290 sID, seq, amount, dueAt, status, settledAt := TrancheInfo(tid)291 if sID != id || seq != int64(i+1) || amount != wantAmounts[i] ||292 dueAt != wantDue[i] || status != StatusPending || settledAt != 0 {293 t.Fatalf("tranche %d wrong: sched=%d seq=%d amount=%d due=%d status=%s settled=%d",294 tid, sID, seq, amount, dueAt, status, settledAt)295 }296 }297298 appInvariant(t)299 b.conserved(t, 0)300}301302func TestFundRejectsEnvelope(cur realm, t *testing.T) {303 b := begin()304305 // Not a direct EOA call (coinio.Receive guard).306 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/attacker"))307 testing.SetOriginSend(ugnot(100))308 uassert.AbortsContains(t, cur, "direct EOA call", func() {309 Fund(cross(cur), bene1, "evil", "", "10:100", false, MaxFeeBps)310 })311312 // No coins at all.313 testing.SetRealm(testing.NewUserRealm(mallory))314 testing.SetOriginCaller(mallory)315 testing.SetOriginSend(nil)316 uassert.AbortsContains(t, cur, "exactly one coin type", func() {317 Fund(cross(cur), bene1, "no coins", "", "10:100", false, MaxFeeBps)318 })319320 // Wrong denomination.321 testing.SetRealm(testing.NewUserRealm(mallory))322 testing.SetOriginCaller(mallory)323 testing.SetOriginSend(chain.NewCoins(chain.NewCoin("foocoin", 100)))324 uassert.AbortsContains(t, cur, "exactly one coin type", func() {325 Fund(cross(cur), bene1, "wrong denom", "", "10:100", false, MaxFeeBps)326 })327328 appInvariant(t)329 b.conserved(t, 0)330}331332func TestFundRejectsTerms(cur realm, t *testing.T) {333 b := begin()334 var zero address335336 // Each case stages inline, because SetRealm is per-frame.337 cases := []struct {338 name, want string339 beneficiary address340 title, memo string341 spec string342 sent int64343 }{344 {"zero beneficiary", "empty beneficiary", zero, "t", "", "10:100", 100},345 // A balance credited to the realm's own address is claimable by346 // nobody and still counted in Liabilities(), so it is stranded347 // AND it shrinks SweepDenom's reach. Released by any third348 // party, so the funder cannot undo it.349 {"self beneficiary", "must not be this realm's own address", self, "t", "", "10:100", 100},350 {"empty title", "title must be", bene1, "", "", "10:100", 100},351 {"long title", "title must be", bene1, strings.Repeat("a", MaxTitleLen+1), "", "10:100", 100},352 {"bad title charset", "letters, digits", bene1, "bad!title", "", "10:100", 100},353 {"long memo", "memo exceeds", bene1, "t", strings.Repeat("m", MaxMemoLen+1), "10:100", 100},354 {"empty spec", "at least one tranche", bene1, "t", "", "", 100},355 {"long spec", "spec exceeds", bene1, "t", "", strings.Repeat("9", MaxSpecLen+1), 100},356 {"missing colon", "expected", bene1, "t", "", "10-100", 100},357 {"delay not integer", "delay is not an integer", bene1, "t", "", "ten:100", 100},358 {"amount not integer", "amount is not an integer", bene1, "t", "", "10:lots", 100},359 {"delay zero", "outside", bene1, "t", "", "0:100", 100},360 {"delay too large", "outside", bene1, "t", "", "100000001:100", 100},361 {"delays equal", "strictly increasing", bene1, "t", "", "10:50,10:50", 100},362 {"delays decreasing", "strictly increasing", bene1, "t", "", "20:50,10:50", 100},363 {"zero amount", "amount must be positive", bene1, "t", "", "10:0", 100},364 {"negative amount", "amount must be positive", bene1, "t", "", "10:-5", 100},365 {"underfunded", "they must be equal", bene1, "t", "", "10:200", 100},366 {"overfunded", "they must be equal", bene1, "t", "", "10:50", 100},367 }368369 // A cross-call abort is NOT catchable with recover() in the calling370 // realm — that is what uassert.AbortsContains is for. Staging stays in371 // this frame, immediately before each call.372 for _, c := range cases {373 spec := c.spec374 ben := c.beneficiary375 title := c.title376 memo := c.memo377378 testing.SetRealm(testing.NewUserRealm(mallory))379 testing.SetOriginCaller(mallory)380 testing.SetOriginSend(ugnot(c.sent))381 uassert.AbortsContains(t, cur, c.want, func() {382 Fund(cross(cur), ben, title, memo, spec, false, MaxFeeBps)383 }, c.name)384 }385386 // Too many tranches: MaxTranches+1 entries with increasing delays.387 parts := []string{}388 for i := 1; i <= MaxTranches+1; i++ {389 parts = append(parts, itoa(int64(i))+":1")390 }391 testing.SetRealm(testing.NewUserRealm(mallory))392 testing.SetOriginCaller(mallory)393 testing.SetOriginSend(ugnot(int64(MaxTranches + 1)))394 uassert.AbortsContains(t, cur, "at most "+itoa(MaxTranches)+" tranches", func() {395 Fund(cross(cur), bene1, "many", "", strings.Join(parts, ","), false, MaxFeeBps)396 })397398 appInvariant(t)399 b.conserved(t, 0)400}401402func TestReleaseBeforeDueAborts(cur realm, t *testing.T) {403 b := begin()404 id := fundAs(cur, funder1, bene1, 100, "not yet", "", "5:100", false)405 tid := trancheIDsOf(id)[0]406407 testing.SetRealm(testing.NewUserRealm(stranger))408 testing.SetOriginCaller(stranger)409 uassert.AbortsContains(t, cur, "not due yet", func() { Release(cross(cur), tid) })410411 // Nothing moved.412 if BalanceOf(bene1) != 0 {413 t.Fatal("beneficiary credited before the due height")414 }415 appInvariant(t)416 b.conserved(t, 0)417}418419// TestReleaseIsPermissionlessAndPaysTheBeneficiary is the central420// authorization property: a stranger may trigger the release, and doing so421// credits the RECORDED beneficiary, never the caller.422func TestReleaseIsPermissionlessAndPaysTheBeneficiary(cur realm, t *testing.T) {423 b := begin()424 id := fundAs(cur, funder1, bene1, 400, "payable", "", "1:400", false)425 tid := trancheIDsOf(id)[0]426427 beneBefore := BalanceOf(bene1)428 malloryBefore := BalanceOf(mallory)429 testing.SkipHeights(1)430431 // Mallory, an unrelated account, triggers the release.432 testing.SetRealm(testing.NewUserRealm(mallory))433 testing.SetOriginCaller(mallory)434 Release(cross(cur), tid)435436 if got := BalanceOf(bene1) - beneBefore; got != 400 {437 t.Fatalf("beneficiary credited %d, want 400", got)438 }439 if got := BalanceOf(mallory) - malloryBefore; got != 0 {440 t.Fatalf("the caller was credited %d; release must never pay the caller", got)441 }442 _, _, _, _, status, settledAt := TrancheInfo(tid)443 if status != StatusReleased || settledAt == 0 {444 t.Fatalf("tranche not marked released: status=%s settledAt=%d", status, settledAt)445 }446 appInvariant(t)447 b.conserved(t, 0)448}449450// TestDoubleReleaseIsImpossible is the property duebook exists to provide.451// The second attempt must fail because the deferral no longer exists — not452// because this realm re-checked a status flag.453func TestDoubleReleaseIsImpossible(cur realm, t *testing.T) {454 b := begin()455 id := fundAs(cur, funder1, bene1, 700, "once only", "", "1:700", false)456 tid := trancheIDsOf(id)[0]457 testing.SkipHeights(1)458459 testing.SetRealm(testing.NewUserRealm(bene1))460 testing.SetOriginCaller(bene1)461 Release(cross(cur), tid)462 after := BalanceOf(bene1)463464 // Second attempt, same transaction-equivalent frame and a different465 // caller: duebook has consumed the ID and can never return it again.466 testing.SetRealm(testing.NewUserRealm(mallory))467 testing.SetOriginCaller(mallory)468 uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), tid) })469470 // And a third, from the beneficiary.471 testing.SetRealm(testing.NewUserRealm(bene1))472 testing.SetOriginCaller(bene1)473 uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), tid) })474475 if BalanceOf(bene1) != after {476 t.Fatal("a repeat release changed the balance")477 }478 appInvariant(t)479 b.conserved(t, 0)480}481482// TestVestedTrancheCannotBeRevoked is this realm's own check, not483// duebook's: duebook.Cancel would consume a due-but-unclaimed deferral484// happily. Without the refusal a funder could front-run the beneficiary.485func TestVestedTrancheCannotBeRevoked(cur realm, t *testing.T) {486 b := begin()487 id := fundAs(cur, funder1, bene1, 300, "vested now", "", "1:300", true)488 tid := trancheIDsOf(id)[0]489490 testing.SkipHeights(1) // now == dueAt: vested491492 testing.SetRealm(testing.NewUserRealm(funder1))493 testing.SetOriginCaller(funder1)494 uassert.AbortsContains(t, cur, "can never be revoked", func() { Revoke(cross(cur), tid) })495496 // Still releasable to the beneficiary afterwards.497 before := BalanceOf(bene1)498 testing.SetRealm(testing.NewUserRealm(stranger))499 testing.SetOriginCaller(stranger)500 Release(cross(cur), tid)501 if got := BalanceOf(bene1) - before; got != 300 {502 t.Fatalf("beneficiary credited %d after a refused revocation, want 300", got)503 }504 appInvariant(t)505 b.conserved(t, 0)506}507508func TestRevokeAuthorization(cur realm, t *testing.T) {509 b := begin()510 rev := fundAs(cur, funder1, bene1, 500, "revocable one", "", "100:500", true)511 irr := fundAs(cur, funder2, bene2, 600, "irrevocable one", "", "100:600", false)512 revTID := trancheIDsOf(rev)[0]513 irrTID := trancheIDsOf(irr)[0]514515 // A stranger may not revoke.516 testing.SetRealm(testing.NewUserRealm(mallory))517 testing.SetOriginCaller(mallory)518 uassert.AbortsContains(t, cur, "only the funder may revoke", func() { Revoke(cross(cur), revTID) })519520 // Nor may the beneficiary.521 testing.SetRealm(testing.NewUserRealm(bene1))522 testing.SetOriginCaller(bene1)523 uassert.AbortsContains(t, cur, "only the funder may revoke", func() { Revoke(cross(cur), revTID) })524525 // Nor the OTHER schedule's funder.526 testing.SetRealm(testing.NewUserRealm(funder2))527 testing.SetOriginCaller(funder2)528 uassert.AbortsContains(t, cur, "only the funder may revoke", func() { Revoke(cross(cur), revTID) })529530 // An irrevocable schedule refuses even its own funder.531 testing.SetRealm(testing.NewUserRealm(funder2))532 testing.SetOriginCaller(funder2)533 uassert.AbortsContains(t, cur, "is irrevocable", func() { Revoke(cross(cur), irrTID) })534535 // RevokeAll carries the same two checks.536 testing.SetRealm(testing.NewUserRealm(mallory))537 testing.SetOriginCaller(mallory)538 uassert.AbortsContains(t, cur, "only the funder may revoke", func() { RevokeAll(cross(cur), rev) })539 testing.SetRealm(testing.NewUserRealm(funder2))540 testing.SetOriginCaller(funder2)541 uassert.AbortsContains(t, cur, "is irrevocable", func() { RevokeAll(cross(cur), irr) })542543 appInvariant(t)544 b.conserved(t, 0)545}546547func TestRevokeRefundsFunderFeeFree(cur realm, t *testing.T) {548 // A non-zero fee must NOT be charged on a revocation.549 testing.SetRealm(testing.NewUserRealm(testAdmin))550 testing.SetOriginCaller(testAdmin)551 SetFeeBps(cross(cur), 500)552553 b := begin()554 id := fundAs(cur, funder1, bene1, 800, "clawback", "", "1000:800", true)555 tid := trancheIDsOf(id)[0]556557 funderBefore := BalanceOf(funder1)558 feesBefore := FeesAccrued()559560 testing.SetRealm(testing.NewUserRealm(funder1))561 testing.SetOriginCaller(funder1)562 Revoke(cross(cur), tid)563564 if got := BalanceOf(funder1) - funderBefore; got != 800 {565 t.Fatalf("funder refunded %d, want the full 800 fee-free", got)566 }567 if FeesAccrued() != feesBefore {568 t.Fatalf("a revocation accrued a fee: %d -> %d", feesBefore, FeesAccrued())569 }570 _, _, _, _, status, settledAt := TrancheInfo(tid)571 if status != StatusRevoked || settledAt == 0 {572 t.Fatalf("tranche not marked revoked: %s %d", status, settledAt)573 }574575 // A revoked tranche can never be released: duebook consumed the ID.576 testing.SetRealm(testing.NewUserRealm(bene1))577 testing.SetOriginCaller(bene1)578 uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), tid) })579580 appInvariant(t)581 b.conserved(t, 0)582583 testing.SetRealm(testing.NewUserRealm(testAdmin))584 testing.SetOriginCaller(testAdmin)585 SetFeeBps(cross(cur), 0)586}587588func TestRevokeAllSkipsVestedAndSettled(cur realm, t *testing.T) {589 b := begin()590 // Tranche 1 due at +1, the rest far out.591 id := fundAs(cur, funder1, bene1, 1000, "mixed", "", "1:100,500:200,600:300,700:400", true)592 ids := trancheIDsOf(id)593594 testing.SkipHeights(1) // tranche 1 vests595596 // Release tranche 1 so it is settled, not merely vested.597 testing.SetRealm(testing.NewUserRealm(stranger))598 testing.SetOriginCaller(stranger)599 Release(cross(cur), ids[0])600601 funderBefore := BalanceOf(funder1)602 beneBefore := BalanceOf(bene1)603 testing.SetRealm(testing.NewUserRealm(funder1))604 testing.SetOriginCaller(funder1)605 RevokeAll(cross(cur), id)606607 // The three unvested tranches came back; the released one did not.608 if got := BalanceOf(funder1) - funderBefore; got != 900 {609 t.Fatalf("funder refunded %d, want 900 (1000 less the released 100)", got)610 }611 // Assert the DELTA, not the absolute. BalanceOf(bene1) is cumulative612 // across the whole suite and is already far above 100 by this point, so613 // a `< 100` check could not fail however badly RevokeAll behaved. What614 // must hold is that RevokeAll moved nothing out of the beneficiary.615 if got := BalanceOf(bene1) - beneBefore; got != 0 {616 t.Fatalf("RevokeAll changed the beneficiary balance by %d, want 0", got)617 }618 _, _, _, _, st0, _ := TrancheInfo(ids[0])619 if st0 != StatusReleased {620 t.Fatalf("released tranche was altered by RevokeAll: %s", st0)621 }622 for _, tid := range ids[1:] {623 _, _, _, _, st, _ := TrancheInfo(tid)624 if st != StatusRevoked {625 t.Fatalf("tranche %d status %s, want revoked", tid, st)626 }627 }628629 // A second RevokeAll has nothing left to do and must say so rather630 // than report a silent success.631 testing.SetRealm(testing.NewUserRealm(funder1))632 testing.SetOriginCaller(funder1)633 uassert.AbortsContains(t, cur, "no revocable tranches", func() { RevokeAll(cross(cur), id) })634635 appInvariant(t)636 b.conserved(t, 0)637}638639func TestWithdrawPaysOutAndDebitsFirst(cur realm, t *testing.T) {640 b := begin()641 id := fundAs(cur, funder1, bene1, 250, "payout test", "", "1:250", false)642 tid := trancheIDsOf(id)[0]643 testing.SkipHeights(1)644645 testing.SetRealm(testing.NewUserRealm(bene1))646 testing.SetOriginCaller(bene1)647 Release(cross(cur), tid)648649 claimable := BalanceOf(bene1)650 if claimable < 250 {651 t.Fatalf("claimable %d, want at least 250", claimable)652 }653 coinsBefore := coinsOf(bene1)654655 // Partial withdrawal.656 testing.SetRealm(testing.NewUserRealm(bene1))657 testing.SetOriginCaller(bene1)658 testing.SetOriginSend(nil)659 Withdraw(cross(cur), 100)660 if got := coinsOf(bene1) - coinsBefore; got != 100 {661 t.Fatalf("received %d coins, want 100", got)662 }663 if BalanceOf(bene1) != claimable-100 {664 t.Fatalf("ledger not debited: %d", BalanceOf(bene1))665 }666667 // Over-withdrawal is refused.668 testing.SetRealm(testing.NewUserRealm(bene1))669 testing.SetOriginCaller(bene1)670 uassert.AbortsContains(t, cur, "insufficient balance", func() { Withdraw(cross(cur), claimable) })671672 // The rest.673 testing.SetRealm(testing.NewUserRealm(bene1))674 testing.SetOriginCaller(bene1)675 WithdrawAll(cross(cur))676 if BalanceOf(bene1) != 0 {677 t.Fatalf("balance after WithdrawAll = %d", BalanceOf(bene1))678 }679680 // Nothing left to withdraw: feeledger returns (0, nil) for an empty681 // account, so this realm's own guard is what turns that into an abort.682 testing.SetRealm(testing.NewUserRealm(bene1))683 testing.SetOriginCaller(bene1)684 uassert.AbortsContains(t, cur, "nothing to withdraw", func() { WithdrawAll(cross(cur)) })685686 // A stranger with no balance cannot withdraw.687 testing.SetRealm(testing.NewUserRealm(mallory))688 testing.SetOriginCaller(mallory)689 uassert.AbortsContains(t, cur, "insufficient balance", func() { Withdraw(cross(cur), 1) })690691 // Every withdrawal moved H and U by the same amount, so the identity692 // holds with no surplus term: dH == dE + dU + dF.693 appInvariant(t)694 b.conserved(t, 0)695}696697// TestFeeSnapshotAndCeiling covers both halves of the fee race: a fee698// change never reaches an already-funded schedule, and a fee raise699// sequenced ahead of a Fund is refused rather than silently applied.700func TestFeeSnapshotAndCeiling(cur realm, t *testing.T) {701 testing.SetRealm(testing.NewUserRealm(testAdmin))702 testing.SetOriginCaller(testAdmin)703 SetFeeBps(cross(cur), 200)704705 b := begin()706 id := fundAs(cur, funder1, bene1, 1000, "snapshotted", "", "1:1000", false)707 _, _, _, _, snapBps, _, _, _, _, _, _ := ScheduleInfo(id)708 if snapBps != 200 {709 t.Fatalf("snapshot bps = %d, want 200", snapBps)710 }711712 // Admin raises the fee AFTER funding; the schedule keeps 200.713 testing.SetRealm(testing.NewUserRealm(testAdmin))714 testing.SetOriginCaller(testAdmin)715 SetFeeBps(cross(cur), 1000)716717 tid := trancheIDsOf(id)[0]718 testing.SkipHeights(1)719 beneBefore := BalanceOf(bene1)720 feesBefore := FeesAccrued()721722 testing.SetRealm(testing.NewUserRealm(stranger))723 testing.SetOriginCaller(stranger)724 Release(cross(cur), tid)725726 // 200 bps of 1000 == 20, charged at the SNAPSHOT, not the live 1000 bps.727 if got := FeesAccrued() - feesBefore; got != 20 {728 t.Fatalf("fee charged = %d, want 20 (the snapshot)", got)729 }730 if got := BalanceOf(bene1) - beneBefore; got != 980 {731 t.Fatalf("beneficiary credited %d, want 980", got)732 }733734 // The creation-time race: the live fee is now 1000, so a caller who735 // signs for at most 200 must be refused.736 testing.SetRealm(testing.NewUserRealm(funder1))737 testing.SetOriginCaller(funder1)738 testing.SetOriginSend(ugnot(100))739 uassert.AbortsContains(t, cur, "exceeds the caller's maximum", func() {740 Fund(cross(cur), bene1, "raced", "", "10:100", false, 200)741 })742 testing.SetOriginSend(nil) // disarm; the envelope is process-global743744 appInvariant(t)745 b.conserved(t, 0)746747 testing.SetRealm(testing.NewUserRealm(testAdmin))748 testing.SetOriginCaller(testAdmin)749 SetFeeBps(cross(cur), 0)750}751752func TestFeeWithdrawalAuthorization(cur realm, t *testing.T) {753 testing.SetRealm(testing.NewUserRealm(testAdmin))754 testing.SetOriginCaller(testAdmin)755 SetFeeBps(cross(cur), 1000)756757 b := begin()758 id := fundAs(cur, funder1, bene1, 1000, "fees", "", "1:1000", false)759 tid := trancheIDsOf(id)[0]760 testing.SkipHeights(1)761 testing.SetRealm(testing.NewUserRealm(stranger))762 testing.SetOriginCaller(stranger)763 Release(cross(cur), tid)764765 if FeesAccrued() == 0 {766 t.Fatal("no fee accrued at 1000 bps")767 }768769 // A non-recipient cannot withdraw fees or sweep.770 testing.SetRealm(testing.NewUserRealm(mallory))771 testing.SetOriginCaller(mallory)772 uassert.AbortsContains(t, cur, "only the fee recipient", func() { WithdrawFees(cross(cur)) })773 testing.SetRealm(testing.NewUserRealm(mallory))774 testing.SetOriginCaller(mallory)775 uassert.AbortsContains(t, cur, "only the fee recipient", func() { SweepDenom(cross(cur), Denom) })776777 pot := FeesAccrued()778 coinsBefore := coinsOf(testAdmin)779 testing.SetRealm(testing.NewUserRealm(testAdmin))780 testing.SetOriginCaller(testAdmin)781 WithdrawFees(cross(cur))782 if got := coinsOf(testAdmin) - coinsBefore; got != pot {783 t.Fatalf("fee recipient received %d, want %d", got, pot)784 }785 if FeesAccrued() != 0 {786 t.Fatalf("fee pot not drained: %d", FeesAccrued())787 }788789 // Draining twice is refused.790 testing.SetRealm(testing.NewUserRealm(testAdmin))791 testing.SetOriginCaller(testAdmin)792 uassert.AbortsContains(t, cur, "no fees accrued", func() { WithdrawFees(cross(cur)) })793794 appInvariant(t)795 // The withdrawal took 120 out of H *and* out of F, so both sides of the796 // identity moved together and there is no surplus term. dF is only -20797 // because 20 of that 120 was accrued by an earlier test and is inside798 // the baseline: F went 20 -> 120 -> 0. Subtracting `pot` here would be799 // double-counting the very transfer dH already records.800 b.conserved(t, 0)801802 testing.SetRealm(testing.NewUserRealm(testAdmin))803 testing.SetOriginCaller(testAdmin)804 SetFeeBps(cross(cur), 0)805}806807// TestSetFeeRecipientSettlesTheOutgoingRecipient is audit finding Y2: the808// fee pot is a single scalar with no per-recipient accounting, so a bare809// re-point would hand every basis point earned under the old recipient to810// the new one at the admin's sole discretion.811func TestSetFeeRecipientSettlesTheOutgoingRecipient(cur realm, t *testing.T) {812 testing.SetRealm(testing.NewUserRealm(testAdmin))813 testing.SetOriginCaller(testAdmin)814 SetFeeBps(cross(cur), 1000)815816 b := begin()817 id := fundAs(cur, funder1, bene1, 1000, "settle", "", "1:1000", false)818 tid := trancheIDsOf(id)[0]819 testing.SkipHeights(1)820 testing.SetRealm(testing.NewUserRealm(stranger))821 testing.SetOriginCaller(stranger)822 Release(cross(cur), tid)823824 pot := FeesAccrued()825 if pot == 0 {826 t.Fatal("no fee accrued at 1000 bps")827 }828 oldRecipient := FeeRecipient()829 oldClaimBefore := BalanceOf(oldRecipient)830 newClaimBefore := BalanceOf(bene2)831832 testing.SetRealm(testing.NewUserRealm(testAdmin))833 testing.SetOriginCaller(testAdmin)834 SetFeeRecipient(cross(cur), bene2)835836 if FeeRecipient() != bene2 {837 t.Fatalf("recipient is %s, want %s", FeeRecipient(), bene2)838 }839 // The pot moved to the OUTGOING recipient's claimable balance...840 if got := BalanceOf(oldRecipient) - oldClaimBefore; got != pot {841 t.Fatalf("outgoing recipient credited %d, want the whole pot %d", got, pot)842 }843 // ...and F is now empty, so the successor inherits the role, not the844 // earnings. This is the assertion the finding is about.845 if FeesAccrued() != 0 {846 t.Fatalf("fee pot still holds %d after re-pointing", FeesAccrued())847 }848 if got := BalanceOf(bene2) - newClaimBefore; got != 0 {849 t.Fatalf("successor was credited %d of the predecessor's earnings, want 0", got)850 }851 // The successor has no pot to drain.852 testing.SetRealm(testing.NewUserRealm(bene2))853 testing.SetOriginCaller(bene2)854 uassert.AbortsContains(t, cur, "no fees accrued", func() { WithdrawFees(cross(cur)) })855856 // No coins moved: F simply became U. Conservation is untouched.857 appInvariant(t)858 b.conserved(t, 0)859860 // A re-point to the SAME address is a no-op and must not churn the pot.861 testing.SetRealm(testing.NewUserRealm(testAdmin))862 testing.SetOriginCaller(testAdmin)863 SetFeeRecipient(cross(cur), bene2)864 if FeesAccrued() != 0 {865 t.Fatal("self re-point disturbed the pot")866 }867868 // Restore the suite's baseline roles and fee.869 testing.SetRealm(testing.NewUserRealm(testAdmin))870 testing.SetOriginCaller(testAdmin)871 SetFeeRecipient(cross(cur), testAdmin)872 testing.SetRealm(testing.NewUserRealm(testAdmin))873 testing.SetOriginCaller(testAdmin)874 SetFeeBps(cross(cur), 0)875}876877// TestPerFunderCapIsEnforcedAndFreed is audit finding Y1: MaxOpenTranches is878// a whole-realm cap, and without a per-funder bound one account could fill879// it for ~nothing and then reclaim every coin fee-free with RevokeAll.880//881// It also closes the coverage gap the audit named: before this, NO test882// exercised a capacity check at all. The whole-realm cap itself stays883// untested on purpose — reaching it needs 5000 tranches, and the per-funder884// cap is now the binding one for any single account, which is the point.885func TestPerFunderCapIsEnforcedAndFreed(cur realm, t *testing.T) {886 b := begin()887888 // onesSpec builds n tranches of 1ugnot with strictly increasing delays.889 // n == MaxTranches is the largest schedule a single Fund can create.890 onesSpec := func(n int64) string {891 parts := []string{}892 for i := int64(1); i <= n; i++ {893 parts = append(parts, itoa(i)+":1")894 }895 return strings.Join(parts, ",")896 }897898 // Realm globals persist across tests, so funder2 may already hold pending899 // tranches and the headroom need not divide evenly by MaxTranches. Fill900 // with full schedules and top off with the remainder rather than assert an901 // arithmetic coincidence: an earlier draft asserted room%MaxTranches==0 and902 // failed at room=255.903 start := OpenTranchesOfFunder(funder2)904 room := int64(MaxOpenPerFunder) - start905 if room <= 0 {906 t.Fatalf("funder2 starts at %d pending, already at the cap %d", start, MaxOpenPerFunder)907 }908909 ids := []int64{}910 sizes := []int64{}911 for left := room; left > 0; {912 n := int64(MaxTranches)913 if left < n {914 n = left915 }916 ids = append(ids, fundAs(cur, funder2, bene2, n, "fill", "", onesSpec(n), true))917 sizes = append(sizes, n)918 left -= n919 }920 if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder) {921 t.Fatalf("funder2 holds %d pending, want the cap %d", got, MaxOpenPerFunder)922 }923924 // One more tranche — a single one — must be refused.925 testing.SetRealm(testing.NewUserRealm(funder2))926 testing.SetOriginCaller(funder2)927 testing.SetOriginSend(ugnot(1))928 testing.IssueCoins(Address(), ugnot(1))929 uassert.AbortsContains(t, cur, "funder tranche capacity reached", func() {930 Fund(cross(cur), bene2, "one too many", "", "1:1", false, MaxFeeBps)931 })932 testing.SetOriginSend(nil)933934 // The 1ugnot issued for that rejected Fund never entered the escrow, so935 // it is surplus now. Account for it explicitly rather than let it hide936 // inside a tolerance.937 surplus := int64(1)938939 // The cap is PER FUNDER, not global: a different funder is unaffected940 // even though funder2 is at its own ceiling.941 otherID := fundAs(cur, funder1, bene1, 5, "unaffected", "", "1:5", true)942 if OpenTranchesOfFunder(funder1) < 1 {943 t.Fatal("funder1 was blocked by funder2's cap")944 }945946 // Releasing or revoking frees the slot again. Revoke one whole schedule947 // and confirm funder2 gets back exactly that schedule's worth of headroom.948 testing.SetRealm(testing.NewUserRealm(funder2))949 testing.SetOriginCaller(funder2)950 RevokeAll(cross(cur), ids[0])951 if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder)-sizes[0] {952 t.Fatalf("after RevokeAll funder2 holds %d, want %d",953 got, int64(MaxOpenPerFunder)-sizes[0])954 }955 // ...and can fund again.956 ids[0] = fundAs(cur, funder2, bene2, sizes[0], "refill", "", onesSpec(sizes[0]), true)957 if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder) {958 t.Fatalf("after refunding funder2 holds %d, want the cap %d", got, MaxOpenPerFunder)959 }960961 // Clean up: return funder2 to its starting count so later tests are not962 // funding against a full account, and drop the counter entries to prove963 // the zero-removal path runs.964 for _, id := range ids {965 testing.SetRealm(testing.NewUserRealm(funder2))966 testing.SetOriginCaller(funder2)967 RevokeAll(cross(cur), id)968 }969 testing.SetRealm(testing.NewUserRealm(funder1))970 testing.SetOriginCaller(funder1)971 RevokeAll(cross(cur), otherID)972 if got := OpenTranchesOfFunder(funder2); got != start {973 t.Fatalf("funder2 left with %d pending, want the starting %d", got, start)974 }975976 appInvariant(t)977 b.conserved(t, surplus)978}979980// TestSweepCannotTouchEscrow is the reserve property: surplus is981// sweepable, escrowed tranches and claimable balances are not.982func TestSweepCannotTouchEscrow(cur realm, t *testing.T) {983 b := begin()984 fundAs(cur, funder1, bene1, 900, "reserved", "", "1000:900", false)985986 // Force-send 77 ugnot out of band, then sweep. Only the 77 may move.987 testing.IssueCoins(Address(), ugnot(77))988 if got := Surplus(); got != 77 {989 t.Fatalf("surplus = %d, want 77", got)990 }991 liabBefore := Liabilities()992 coinsBefore := coinsOf(testAdmin)993994 testing.SetRealm(testing.NewUserRealm(testAdmin))995 testing.SetOriginCaller(testAdmin)996 SweepDenom(cross(cur), Denom)997998 if got := coinsOf(testAdmin) - coinsBefore; got != 77 {999 t.Fatalf("swept %d, want exactly the 77 surplus", got)1000 }1001 if Liabilities() != liabBefore {1002 t.Fatalf("sweep changed liabilities: %d -> %d", liabBefore, Liabilities())1003 }1004 if Held() < Liabilities() {1005 t.Fatalf("sweep broke solvency: held=%d liabilities=%d", Held(), Liabilities())1006 }10071008 // With no surplus left, sweeping aborts rather than touching reserves.1009 testing.SetRealm(testing.NewUserRealm(testAdmin))1010 testing.SetOriginCaller(testAdmin)1011 uassert.AbortsContains(t, cur, "no surplus to sweep", func() { SweepDenom(cross(cur), Denom) })10121013 appInvariant(t)1014 b.conserved(t, 0)1015}10161017func TestAdminHandoverIsTwoStep(cur realm, t *testing.T) {1018 var zero address10191020 // Only the admin may propose.1021 testing.SetRealm(testing.NewUserRealm(mallory))1022 testing.SetOriginCaller(mallory)1023 uassert.AbortsContains(t, cur, "admin only", func() { ProposeAdmin(cross(cur), mallory) })10241025 testing.SetRealm(testing.NewUserRealm(testAdmin))1026 testing.SetOriginCaller(testAdmin)1027 ProposeAdmin(cross(cur), funder2)1028 if PendingAdmin() != funder2 {1029 t.Fatal("proposal not recorded")1030 }1031 if Admin() != testAdmin {1032 t.Fatal("a proposal must not transfer the role on its own")1033 }10341035 // Only the proposed account may accept.1036 testing.SetRealm(testing.NewUserRealm(mallory))1037 testing.SetOriginCaller(mallory)1038 uassert.AbortsContains(t, cur, "only the proposed admin", func() { AcceptAdmin(cross(cur)) })10391040 testing.SetRealm(testing.NewUserRealm(funder2))1041 testing.SetOriginCaller(funder2)1042 AcceptAdmin(cross(cur))1043 if Admin() != funder2 || PendingAdmin() != zero {1044 t.Fatalf("handover incomplete: admin=%v pending=%v", Admin(), PendingAdmin())1045 }10461047 // Accepting again is refused (the proposal was cleared).1048 testing.SetRealm(testing.NewUserRealm(funder2))1049 testing.SetOriginCaller(funder2)1050 uassert.AbortsContains(t, cur, "only the proposed admin", func() { AcceptAdmin(cross(cur)) })10511052 // The old admin has lost the role.1053 testing.SetRealm(testing.NewUserRealm(testAdmin))1054 testing.SetOriginCaller(testAdmin)1055 uassert.AbortsContains(t, cur, "admin only", func() { SetFeeBps(cross(cur), 1) })10561057 // Hand it back so later tests keep their seeded admin.1058 testing.SetRealm(testing.NewUserRealm(funder2))1059 testing.SetOriginCaller(funder2)1060 ProposeAdmin(cross(cur), testAdmin)1061 testing.SetRealm(testing.NewUserRealm(testAdmin))1062 testing.SetOriginCaller(testAdmin)1063 AcceptAdmin(cross(cur))1064 if Admin() != testAdmin {1065 t.Fatal("failed to restore the seeded admin")1066 }1067}10681069func TestSetFeeBounds(cur realm, t *testing.T) {1070 testing.SetRealm(testing.NewUserRealm(testAdmin))1071 testing.SetOriginCaller(testAdmin)1072 uassert.AbortsContains(t, cur, "out of range", func() { SetFeeBps(cross(cur), MaxFeeBps+1) })1073 testing.SetRealm(testing.NewUserRealm(testAdmin))1074 testing.SetOriginCaller(testAdmin)1075 uassert.AbortsContains(t, cur, "out of range", func() { SetFeeBps(cross(cur), -1) })1076 testing.SetRealm(testing.NewUserRealm(testAdmin))1077 testing.SetOriginCaller(testAdmin)1078 uassert.AbortsContains(t, cur, "empty fee recipient", func() {1079 var zero address1080 SetFeeRecipient(cross(cur), zero)1081 })1082}10831084// TestStraySendRejected: only Fund accepts value. Every other payable-1085// looking entrypoint must refuse an attached envelope rather than absorb1086// it into surplus.1087func TestStraySendRejected(cur realm, t *testing.T) {1088 b := begin()1089 id := fundAs(cur, funder1, bene1, 100, "stray", "", "1000:100", true)1090 tid := trancheIDsOf(id)[0]10911092 const guard = "does not accept coins"10931094 testing.SetRealm(testing.NewUserRealm(funder1))1095 testing.SetOriginCaller(funder1)1096 testing.SetOriginSend(ugnot(5))1097 uassert.AbortsContains(t, cur, guard, func() { Release(cross(cur), tid) }, "Release")10981099 testing.SetRealm(testing.NewUserRealm(funder1))1100 testing.SetOriginCaller(funder1)1101 testing.SetOriginSend(ugnot(5))1102 uassert.AbortsContains(t, cur, guard, func() { Revoke(cross(cur), tid) }, "Revoke")11031104 testing.SetRealm(testing.NewUserRealm(funder1))1105 testing.SetOriginCaller(funder1)1106 testing.SetOriginSend(ugnot(5))1107 uassert.AbortsContains(t, cur, guard, func() { RevokeAll(cross(cur), id) }, "RevokeAll")11081109 testing.SetRealm(testing.NewUserRealm(funder1))1110 testing.SetOriginCaller(funder1)1111 testing.SetOriginSend(ugnot(5))1112 uassert.AbortsContains(t, cur, guard, func() { WithdrawAll(cross(cur)) }, "WithdrawAll")11131114 testing.SetRealm(testing.NewUserRealm(testAdmin))1115 testing.SetOriginCaller(testAdmin)1116 testing.SetOriginSend(ugnot(5))1117 uassert.AbortsContains(t, cur, guard, func() { SetFeeBps(cross(cur), 0) }, "SetFeeBps")11181119 testing.SetOriginSend(nil)1120 appInvariant(t)1121 b.conserved(t, 0)1122}11231124func TestUnknownIDsAbort(cur realm, t *testing.T) {1125 // The views are non-crossing, and this test file is compiled INTO the1126 // realm, so calling one is a same-realm call and its panic is an1127 // ordinary panic — not an abort. PanicsContains is the matching1128 // assertion; AbortsContains cannot see it and the panic escapes.1129 // Off-chain a reader reaches these through vm/qeval, which does cross1130 // the boundary and so surfaces the same text as an abort.1131 uassert.PanicsContains(t, cur, "unknown schedule id", func() { ScheduleInfo(999999) })1132 uassert.PanicsContains(t, cur, "unknown tranche id", func() { TrancheInfo(999999) })1133 uassert.PanicsContains(t, cur, "must be positive", func() { TrancheInfo(0) })1134 uassert.PanicsContains(t, cur, "must be positive", func() { TrancheInfo(-1) })11351136 testing.SetRealm(testing.NewUserRealm(mallory))1137 testing.SetOriginCaller(mallory)1138 // Release consumes before it looks anything up, so an unknown id is1139 // rejected by duebook and carries duebook's wording, not this realm's1140 // "unknown tranche id". That ordering is the replay guard and is not1141 // worth reversing for a nicer message: reading the tranches tree first1142 // would put a lookup ahead of the consume, which is exactly the shape1143 // the consumer contract forbids.1144 uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), 999999) })1145}11461147func TestVestedOfTracksTheRevocationBoundary(cur realm, t *testing.T) {1148 b := begin()1149 id := fundAs(cur, funder1, bene1, 600, "boundary", "", "1:100,2:200,1000:300", false)11501151 vested, unvested := VestedOf(id)1152 if vested != 0 || unvested != 600 {1153 t.Fatalf("at funding: vested=%d unvested=%d, want 0/600", vested, unvested)1154 }11551156 testing.SkipHeights(1)1157 vested, unvested = VestedOf(id)1158 if vested != 100 || unvested != 500 {1159 t.Fatalf("after 1 block: vested=%d unvested=%d, want 100/500", vested, unvested)1160 }11611162 testing.SkipHeights(1)1163 vested, unvested = VestedOf(id)1164 if vested != 300 || unvested != 300 {1165 t.Fatalf("after 2 blocks: vested=%d unvested=%d, want 300/300", vested, unvested)1166 }11671168 // Releasing does not change the vested figure: vested means "no longer1169 // revocable", whether or not it has been paid out.1170 ids := trancheIDsOf(id)1171 testing.SetRealm(testing.NewUserRealm(stranger))1172 testing.SetOriginCaller(stranger)1173 Release(cross(cur), ids[0])1174 vested, unvested = VestedOf(id)1175 if vested != 300 || unvested != 300 {1176 t.Fatalf("after a release: vested=%d unvested=%d, want 300/300", vested, unvested)1177 }11781179 appInvariant(t)1180 b.conserved(t, 0)1181}11821183func TestViewsAndRenderDoNotPanic(cur realm, t *testing.T) {1184 // feeBps is a realm global and persists across tests, so a test that1185 // asserts a fee-free figure must set the fee it wants rather than1186 // inherit whatever the last fee test left — including the case where1187 // that test failed before its own reset line ran.1188 testing.SetRealm(testing.NewUserRealm(testAdmin))1189 testing.SetOriginCaller(testAdmin)1190 SetFeeBps(cross(cur), 0)11911192 b := begin()1193 id := fundAs(cur, funder1, bene1, 300, "renderable", "a *memo* with _markdown_", "1:100,900:200", true)11941195 if out := TranchesOf(id); !strings.Contains(out, StatusPending) {1196 t.Fatalf("TranchesOf missing status: %q", out)1197 }1198 testing.SkipHeights(1)1199 if out := ReleasableNow(); out == "" {1200 t.Fatal("ReleasableNow empty with a due tranche")1201 }1202 if fee, credited := FeeOn(1000); fee != 0 || credited != 1000 {1203 t.Fatalf("FeeOn at 0 bps = %d/%d", fee, credited)1204 }1205 if OpenTrancheCount() < 1 {1206 t.Fatal("OpenTrancheCount is zero with pending tranches")1207 }1208 if Height() == 0 {1209 t.Fatal("Height() is zero")1210 }12111212 // Assert the STATE-DEPENDENT line, not the heading. "E + U + F + S" is1213 // emitted unconditionally as part of the section title, so matching it1214 // proves only that Render ran — it would keep passing while the realm1215 // rendered "conservation: VIOLATED" underneath.1216 if out := Render(""); !strings.Contains(out, "Vesting") ||1217 !strings.Contains(out, "conservation: OK") {1218 t.Fatalf("overview render wrong: %q", head(out, 400))1219 }1220 if out := Render(""); !strings.Contains(out, "per funder") {1221 t.Fatalf("overview render omits the per-funder cap: %q", head(out, 400))1222 }1223 if out := Render(itoa(id)); !strings.Contains(out, "Schedule #"+itoa(id)) ||1224 !strings.Contains(out, "releasable now") {1225 t.Fatalf("detail render wrong: %q", out)1226 }1227 if out := Render("not-a-number"); !strings.Contains(out, "invalid schedule id") {1228 t.Fatalf("bad path render: %q", out)1229 }1230 if out := Render("999999"); !strings.Contains(out, "unknown schedule id") {1231 t.Fatalf("unknown path render: %q", out)1232 }12331234 appInvariant(t)1235 b.conserved(t, 0)1236}12371238func head(s string, n int) string {1239 if len(s) <= n {1240 return s1241 }1242 return s[:n]1243}1244AcceptAdmin(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Address() string
Admin() string
BalanceOf(addr string) int64
EscrowTotal() int64
FeeBps() int64
FeeOn(amount int64) (fee int64, credited int64)
FeeRecipient() string
FeesAccrued() int64
Fund(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, beneficiary string, title string, memo string, spec string, revocable bool, maxFeeBps int64) int64
FundersWithOpenTranches() int
Height() int64
Held() int64
Liabilities() int64
Memo(id int64) string
NumSchedules() int64
OpenTrancheCount() int
OpenTranchesOfFunder(funder string) int64
PendingAdmin() string
ProposeAdmin(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, next string)
ReleasableNow() string
Release(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, trancheID int64) string
Render(path string) string
Revoke(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, trancheID int64) string
RevokeAll(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, scheduleID int64) string
ScheduleInfo(id int64) (funder string, beneficiary string, title string, total int64, feeBps int64, revocable bool, createdAt int64, numTranches int64, pending int64, releasedGross int64, revokedTotal int64)
SetFeeBps(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, bps int64)
SetFeeRecipient(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, next string)
Surplus() int64
SweepDenom(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, denom string) string
TrancheInfo(trancheID int64) (scheduleID int64, seq int64, amount int64, dueAt int64, status string, settledAt int64)
TranchesOf(id int64) string
UsersTotal() int64
VestedOf(id int64) (vested int64, unvested int64)
Withdraw(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64) string
WithdrawAll(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) string
WithdrawFees(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) string
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.