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}