Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

vesting_test.gno

45.06 Kb · 1243 lines
   1package vesting
   2
   3import (
   4	"strings"
   5	"testing"
   6
   7	"chain"
   8	"chain/banker"
   9	"chain/runtime"
  10
  11	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook"
  12	"gno.land/p/nt/testutils/v0"
  13	"gno.land/p/nt/uassert/v0"
  14)
  15
  16var (
  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)
  25
  26// HARNESS NOTES, carried from the duebook_demo and grants suites and
  27// re-verified here:
  28//
  29//   - testing.SetRealm records the override per FRAME INDEX. A helper that
  30//     stages a caller and RETURNS leaves the test frame untouched, so the
  31//     previous staging silently wins and every caller looks like the same
  32//     account — which hides exactly the authorization bugs these tests
  33//     exist to find. Every caller below is therefore staged INLINE in the
  34//     frame that makes the call. The staging helpers (fundAs, callAs) are
  35//     safe only because they make their call from inside their own frame.
  36//
  37//   - Realm globals PERSIST across tests in the test VM, while banker
  38//     state resets per test. The monetary invariant is therefore asserted
  39//     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. Every
  43//     abort asserted below happens strictly BEFORE this realm writes any
  44//     state, so the distinction never bites. The one path where it could —
  45//     a book.Schedule failure partway through Fund's loop — is unreachable
  46//     because Fund pre-checks capacity, and is not asserted here.
  47//
  48//   - Tests must not assume a fixed starting height, schedule id, or
  49//     tranche id. They read all three and reason in deltas.
  50func init() {
  51	admin = testAdmin
  52	feeRecipient = testAdmin
  53	self = chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/vesting")
  54}
  55
  56// --- harness ---
  57
  58type baseline struct{ held, escrow, users, fees int64 }
  59
  60func begin() baseline {
  61	// Disarm any OriginSend a previous test left staged. The envelope is
  62	// process-global, not per-frame, and a leftover one makes the first
  63	// non-Fund entrypoint of this test abort in rejectStraySend — which
  64	// looks exactly like the guard working and is really the harness
  65	// 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}
  72
  73// conserved asserts ΔH == ΔE + ΔU + ΔF + extraSurplus.
  74func (b baseline) conserved(t *testing.T, extraSurplus int64) {
  75	t.Helper()
  76	dH := Held() - b.held
  77	dE := EscrowTotal() - b.escrow
  78	dU := UsersTotal() - b.users
  79	dF := FeesAccrued() - b.fees
  80	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 only
  86	// fail on integer overflow. Kept deliberately, as an overflow tripwire
  87	// and as executable documentation of the definition; the real
  88	// conservation check is the dH comparison above, which is against the
  89	// 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}
  94
  95// pendingSum recomputes the sum of amounts over pending tranches — the
  96// 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.amount
 102		}
 103		return false
 104	})
 105	return sum
 106}
 107
 108func pendingCount() int {
 109	n := 0
 110	tranches.Iterate("", "", func(_ string, v any) bool {
 111		if v.(*tranche).status == StatusPending {
 112			n++
 113		}
 114		return false
 115	})
 116	return n
 117}
 118
 119// appInvariant asserts BOTH stores agree: escrowTotal tracks the pending
 120// tranches, and duebook holds exactly one open deferral per pending
 121// tranche. The second half is the cross-store check that matters most —
 122// a tranche this realm thinks is pending but duebook has consumed would
 123// 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).amount
 140			}
 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}
 148
 149// assertOpenCounts recomputes every funder's pending-tranche count from the
 150// tranche records and compares it to the openByFunder counter. This is the
 151// 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 that
 153// 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 a
 155// dead 0 per address that ever funded anything is an unbounded growth path.
 156func assertOpenCounts(t *testing.T) {
 157	t.Helper()
 158
 159	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 false
 166	})
 167
 168	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] = n
 176		total += n
 177		return false
 178	})
 179
 180	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}
 195
 196func coinsOf(addr address) int64 {
 197	return banker.NewReadonlyBanker().GetCoin(addr, Denom)
 198}
 199
 200func ugnot(n int64) chain.Coins {
 201	return chain.NewCoins(chain.NewCoin(Denom, n))
 202}
 203
 204// fundAs performs a chain-faithful funded Fund. Must be called from the
 205// test function's own frame.
 206//
 207// The OriginSend envelope is NOT per-frame: whatever was last staged stays
 208// visible to every later call in the process. Fund is the one entrypoint
 209// that accepts coins, so leaving the envelope armed makes the very next
 210// Release/Revoke abort in rejectStraySend instead of reaching the logic
 211// 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 id
 220}
 221
 222// 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 out
 230}
 231
 232// --- tests ---
 233
 234func TestInitialConfig(t *testing.T) {
 235	if Admin() != testAdmin || FeeRecipient() != testAdmin {
 236		t.Fatal("seeded roles mismatch")
 237	}
 238	var zero address
 239	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 the
 246	// literal, both operands were compile-time constants and the branch was
 247	// dead — it could not fail even if duebook were redeployed with a lower
 248	// 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}
 262
 263func TestFundHappyPath(cur realm, t *testing.T) {
 264	b := begin()
 265	h := runtime.ChainHeight()
 266
 267	id := fundAs(cur, funder1, bene1, 1000, "engineer grant", "a memo", "10:200,20:300,30:500", true)
 268
 269	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	}
 284
 285	// 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	}
 297
 298	appInvariant(t)
 299	b.conserved(t, 0)
 300}
 301
 302func TestFundRejectsEnvelope(cur realm, t *testing.T) {
 303	b := begin()
 304
 305	// 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	})
 311
 312	// 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	})
 319
 320	// 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	})
 327
 328	appInvariant(t)
 329	b.conserved(t, 0)
 330}
 331
 332func TestFundRejectsTerms(cur realm, t *testing.T) {
 333	b := begin()
 334	var zero address
 335
 336	// Each case stages inline, because SetRealm is per-frame.
 337	cases := []struct {
 338		name, want  string
 339		beneficiary address
 340		title, memo string
 341		spec        string
 342		sent        int64
 343	}{
 344		{"zero beneficiary", "empty beneficiary", zero, "t", "", "10:100", 100},
 345		// A balance credited to the realm's own address is claimable by
 346		// nobody and still counted in Liabilities(), so it is stranded
 347		// AND it shrinks SweepDenom's reach. Released by any third
 348		// 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	}
 368
 369	// A cross-call abort is NOT catchable with recover() in the calling
 370	// realm — that is what uassert.AbortsContains is for. Staging stays in
 371	// this frame, immediately before each call.
 372	for _, c := range cases {
 373		spec := c.spec
 374		ben := c.beneficiary
 375		title := c.title
 376		memo := c.memo
 377
 378		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	}
 385
 386	// 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	})
 397
 398	appInvariant(t)
 399	b.conserved(t, 0)
 400}
 401
 402func 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]
 406
 407	testing.SetRealm(testing.NewUserRealm(stranger))
 408	testing.SetOriginCaller(stranger)
 409	uassert.AbortsContains(t, cur, "not due yet", func() { Release(cross(cur), tid) })
 410
 411	// 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}
 418
 419// TestReleaseIsPermissionlessAndPaysTheBeneficiary is the central
 420// authorization property: a stranger may trigger the release, and doing so
 421// 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]
 426
 427	beneBefore := BalanceOf(bene1)
 428	malloryBefore := BalanceOf(mallory)
 429	testing.SkipHeights(1)
 430
 431	// Mallory, an unrelated account, triggers the release.
 432	testing.SetRealm(testing.NewUserRealm(mallory))
 433	testing.SetOriginCaller(mallory)
 434	Release(cross(cur), tid)
 435
 436	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}
 449
 450// TestDoubleReleaseIsImpossible is the property duebook exists to provide.
 451// The second attempt must fail because the deferral no longer exists — not
 452// 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)
 458
 459	testing.SetRealm(testing.NewUserRealm(bene1))
 460	testing.SetOriginCaller(bene1)
 461	Release(cross(cur), tid)
 462	after := BalanceOf(bene1)
 463
 464	// Second attempt, same transaction-equivalent frame and a different
 465	// 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) })
 469
 470	// 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) })
 474
 475	if BalanceOf(bene1) != after {
 476		t.Fatal("a repeat release changed the balance")
 477	}
 478	appInvariant(t)
 479	b.conserved(t, 0)
 480}
 481
 482// TestVestedTrancheCannotBeRevoked is this realm's own check, not
 483// duebook's: duebook.Cancel would consume a due-but-unclaimed deferral
 484// 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]
 489
 490	testing.SkipHeights(1) // now == dueAt: vested
 491
 492	testing.SetRealm(testing.NewUserRealm(funder1))
 493	testing.SetOriginCaller(funder1)
 494	uassert.AbortsContains(t, cur, "can never be revoked", func() { Revoke(cross(cur), tid) })
 495
 496	// 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}
 507
 508func 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]
 514
 515	// 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) })
 519
 520	// 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) })
 524
 525	// 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) })
 529
 530	// 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) })
 534
 535	// 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) })
 542
 543	appInvariant(t)
 544	b.conserved(t, 0)
 545}
 546
 547func 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)
 552
 553	b := begin()
 554	id := fundAs(cur, funder1, bene1, 800, "clawback", "", "1000:800", true)
 555	tid := trancheIDsOf(id)[0]
 556
 557	funderBefore := BalanceOf(funder1)
 558	feesBefore := FeesAccrued()
 559
 560	testing.SetRealm(testing.NewUserRealm(funder1))
 561	testing.SetOriginCaller(funder1)
 562	Revoke(cross(cur), tid)
 563
 564	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	}
 574
 575	// 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) })
 579
 580	appInvariant(t)
 581	b.conserved(t, 0)
 582
 583	testing.SetRealm(testing.NewUserRealm(testAdmin))
 584	testing.SetOriginCaller(testAdmin)
 585	SetFeeBps(cross(cur), 0)
 586}
 587
 588func 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)
 593
 594	testing.SkipHeights(1) // tranche 1 vests
 595
 596	// 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])
 600
 601	funderBefore := BalanceOf(funder1)
 602	beneBefore := BalanceOf(bene1)
 603	testing.SetRealm(testing.NewUserRealm(funder1))
 604	testing.SetOriginCaller(funder1)
 605	RevokeAll(cross(cur), id)
 606
 607	// 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 cumulative
 612	// across the whole suite and is already far above 100 by this point, so
 613	// a `< 100` check could not fail however badly RevokeAll behaved. What
 614	// 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	}
 628
 629	// A second RevokeAll has nothing left to do and must say so rather
 630	// 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) })
 634
 635	appInvariant(t)
 636	b.conserved(t, 0)
 637}
 638
 639func 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)
 644
 645	testing.SetRealm(testing.NewUserRealm(bene1))
 646	testing.SetOriginCaller(bene1)
 647	Release(cross(cur), tid)
 648
 649	claimable := BalanceOf(bene1)
 650	if claimable < 250 {
 651		t.Fatalf("claimable %d, want at least 250", claimable)
 652	}
 653	coinsBefore := coinsOf(bene1)
 654
 655	// 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	}
 666
 667	// 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) })
 671
 672	// 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	}
 679
 680	// Nothing left to withdraw: feeledger returns (0, nil) for an empty
 681	// 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)) })
 685
 686	// 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) })
 690
 691	// Every withdrawal moved H and U by the same amount, so the identity
 692	// holds with no surplus term: dH == dE + dU + dF.
 693	appInvariant(t)
 694	b.conserved(t, 0)
 695}
 696
 697// TestFeeSnapshotAndCeiling covers both halves of the fee race: a fee
 698// change never reaches an already-funded schedule, and a fee raise
 699// 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)
 704
 705	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	}
 711
 712	// 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)
 716
 717	tid := trancheIDsOf(id)[0]
 718	testing.SkipHeights(1)
 719	beneBefore := BalanceOf(bene1)
 720	feesBefore := FeesAccrued()
 721
 722	testing.SetRealm(testing.NewUserRealm(stranger))
 723	testing.SetOriginCaller(stranger)
 724	Release(cross(cur), tid)
 725
 726	// 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	}
 733
 734	// The creation-time race: the live fee is now 1000, so a caller who
 735	// 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-global
 743
 744	appInvariant(t)
 745	b.conserved(t, 0)
 746
 747	testing.SetRealm(testing.NewUserRealm(testAdmin))
 748	testing.SetOriginCaller(testAdmin)
 749	SetFeeBps(cross(cur), 0)
 750}
 751
 752func TestFeeWithdrawalAuthorization(cur realm, t *testing.T) {
 753	testing.SetRealm(testing.NewUserRealm(testAdmin))
 754	testing.SetOriginCaller(testAdmin)
 755	SetFeeBps(cross(cur), 1000)
 756
 757	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)
 764
 765	if FeesAccrued() == 0 {
 766		t.Fatal("no fee accrued at 1000 bps")
 767	}
 768
 769	// 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) })
 776
 777	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	}
 788
 789	// 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)) })
 793
 794	appInvariant(t)
 795	// The withdrawal took 120 out of H *and* out of F, so both sides of the
 796	// identity moved together and there is no surplus term. dF is only -20
 797	// because 20 of that 120 was accrued by an earlier test and is inside
 798	// the baseline: F went 20 -> 120 -> 0. Subtracting `pot` here would be
 799	// double-counting the very transfer dH already records.
 800	b.conserved(t, 0)
 801
 802	testing.SetRealm(testing.NewUserRealm(testAdmin))
 803	testing.SetOriginCaller(testAdmin)
 804	SetFeeBps(cross(cur), 0)
 805}
 806
 807// TestSetFeeRecipientSettlesTheOutgoingRecipient is audit finding Y2: the
 808// fee pot is a single scalar with no per-recipient accounting, so a bare
 809// re-point would hand every basis point earned under the old recipient to
 810// 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)
 815
 816	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)
 823
 824	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)
 831
 832	testing.SetRealm(testing.NewUserRealm(testAdmin))
 833	testing.SetOriginCaller(testAdmin)
 834	SetFeeRecipient(cross(cur), bene2)
 835
 836	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 the
 844	// 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)) })
 855
 856	// No coins moved: F simply became U. Conservation is untouched.
 857	appInvariant(t)
 858	b.conserved(t, 0)
 859
 860	// 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	}
 867
 868	// 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}
 876
 877// TestPerFunderCapIsEnforcedAndFreed is audit finding Y1: MaxOpenTranches is
 878// a whole-realm cap, and without a per-funder bound one account could fill
 879// 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 test
 882// exercised a capacity check at all. The whole-realm cap itself stays
 883// untested on purpose — reaching it needs 5000 tranches, and the per-funder
 884// cap is now the binding one for any single account, which is the point.
 885func TestPerFunderCapIsEnforcedAndFreed(cur realm, t *testing.T) {
 886	b := begin()
 887
 888	// 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	}
 897
 898	// Realm globals persist across tests, so funder2 may already hold pending
 899	// tranches and the headroom need not divide evenly by MaxTranches. Fill
 900	// with full schedules and top off with the remainder rather than assert an
 901	// arithmetic coincidence: an earlier draft asserted room%MaxTranches==0 and
 902	// failed at room=255.
 903	start := OpenTranchesOfFunder(funder2)
 904	room := int64(MaxOpenPerFunder) - start
 905	if room <= 0 {
 906		t.Fatalf("funder2 starts at %d pending, already at the cap %d", start, MaxOpenPerFunder)
 907	}
 908
 909	ids := []int64{}
 910	sizes := []int64{}
 911	for left := room; left > 0; {
 912		n := int64(MaxTranches)
 913		if left < n {
 914			n = left
 915		}
 916		ids = append(ids, fundAs(cur, funder2, bene2, n, "fill", "", onesSpec(n), true))
 917		sizes = append(sizes, n)
 918		left -= n
 919	}
 920	if got := OpenTranchesOfFunder(funder2); got != int64(MaxOpenPerFunder) {
 921		t.Fatalf("funder2 holds %d pending, want the cap %d", got, MaxOpenPerFunder)
 922	}
 923
 924	// 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)
 933
 934	// The 1ugnot issued for that rejected Fund never entered the escrow, so
 935	// it is surplus now. Account for it explicitly rather than let it hide
 936	// inside a tolerance.
 937	surplus := int64(1)
 938
 939	// The cap is PER FUNDER, not global: a different funder is unaffected
 940	// 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	}
 945
 946	// Releasing or revoking frees the slot again. Revoke one whole schedule
 947	// 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	}
 960
 961	// Clean up: return funder2 to its starting count so later tests are not
 962	// funding against a full account, and drop the counter entries to prove
 963	// 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	}
 975
 976	appInvariant(t)
 977	b.conserved(t, surplus)
 978}
 979
 980// TestSweepCannotTouchEscrow is the reserve property: surplus is
 981// 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)
 985
 986	// 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)
 993
 994	testing.SetRealm(testing.NewUserRealm(testAdmin))
 995	testing.SetOriginCaller(testAdmin)
 996	SweepDenom(cross(cur), Denom)
 997
 998	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	}
1007
1008	// 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) })
1012
1013	appInvariant(t)
1014	b.conserved(t, 0)
1015}
1016
1017func TestAdminHandoverIsTwoStep(cur realm, t *testing.T) {
1018	var zero address
1019
1020	// 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) })
1024
1025	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	}
1034
1035	// 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)) })
1039
1040	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	}
1046
1047	// 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)) })
1051
1052	// 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) })
1056
1057	// 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}
1068
1069func 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 address
1080		SetFeeRecipient(cross(cur), zero)
1081	})
1082}
1083
1084// TestStraySendRejected: only Fund accepts value. Every other payable-
1085// looking entrypoint must refuse an attached envelope rather than absorb
1086// 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]
1091
1092	const guard = "does not accept coins"
1093
1094	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")
1098
1099	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")
1103
1104	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")
1108
1109	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")
1113
1114	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")
1118
1119	testing.SetOriginSend(nil)
1120	appInvariant(t)
1121	b.conserved(t, 0)
1122}
1123
1124func TestUnknownIDsAbort(cur realm, t *testing.T) {
1125	// The views are non-crossing, and this test file is compiled INTO the
1126	// realm, so calling one is a same-realm call and its panic is an
1127	// ordinary panic — not an abort. PanicsContains is the matching
1128	// assertion; AbortsContains cannot see it and the panic escapes.
1129	// Off-chain a reader reaches these through vm/qeval, which does cross
1130	// 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) })
1135
1136	testing.SetRealm(testing.NewUserRealm(mallory))
1137	testing.SetOriginCaller(mallory)
1138	// Release consumes before it looks anything up, so an unknown id is
1139	// rejected by duebook and carries duebook's wording, not this realm's
1140	// "unknown tranche id". That ordering is the replay guard and is not
1141	// worth reversing for a nicer message: reading the tranches tree first
1142	// would put a lookup ahead of the consume, which is exactly the shape
1143	// the consumer contract forbids.
1144	uassert.AbortsContains(t, cur, "no such open deferral", func() { Release(cross(cur), 999999) })
1145}
1146
1147func TestVestedOfTracksTheRevocationBoundary(cur realm, t *testing.T) {
1148	b := begin()
1149	id := fundAs(cur, funder1, bene1, 600, "boundary", "", "1:100,2:200,1000:300", false)
1150
1151	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	}
1155
1156	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	}
1161
1162	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	}
1167
1168	// Releasing does not change the vested figure: vested means "no longer
1169	// 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	}
1178
1179	appInvariant(t)
1180	b.conserved(t, 0)
1181}
1182
1183func TestViewsAndRenderDoNotPanic(cur realm, t *testing.T) {
1184	// feeBps is a realm global and persists across tests, so a test that
1185	// asserts a fee-free figure must set the fee it wants rather than
1186	// inherit whatever the last fee test left — including the case where
1187	// that test failed before its own reset line ran.
1188	testing.SetRealm(testing.NewUserRealm(testAdmin))
1189	testing.SetOriginCaller(testAdmin)
1190	SetFeeBps(cross(cur), 0)
1191
1192	b := begin()
1193	id := fundAs(cur, funder1, bene1, 300, "renderable", "a *memo* with _markdown_", "1:100,900:200", true)
1194
1195	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	}
1211
1212	// Assert the STATE-DEPENDENT line, not the heading. "E + U + F + S" is
1213	// emitted unconditionally as part of the section title, so matching it
1214	// proves only that Render ran — it would keep passing while the realm
1215	// 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	}
1233
1234	appInvariant(t)
1235	b.conserved(t, 0)
1236}
1237
1238func head(s string, n int) string {
1239	if len(s) <= n {
1240		return s
1241	}
1242	return s[:n]
1243}