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

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}