budgets_test.gno
47.50 Kb · 1275 lines
1package budgets
2
3import (
4 "strings"
5 "testing"
6
7 "chain"
8
9 "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
10 "gno.land/p/nt/testutils/v0"
11 "gno.land/p/nt/uassert/v0"
12)
13
14var (
15 owner1 = testutils.TestAddress("bgowner1")
16 owner2 = testutils.TestAddress("bgowner2")
17 delegate1 = testutils.TestAddress("bgdeleg1")
18 delegate2 = testutils.TestAddress("bgdeleg2")
19 mallory = testutils.TestAddress("bgmallory")
20)
21
22// HARNESS NOTES, carried from the crowdfund, treasury_board and vesting
23// suites (measured there, relied on here):
24//
25// - testing.SetRealm records the override per FRAME INDEX: stage the
26// caller INLINE in this test function's own frame before a uassert
27// call, or inside a helper that stages and calls in one frame of its
28// own. Never inside a uassert closure — that staging does not take.
29//
30// - testing.SkipHeights REPLACES the whole context (OriginCaller,
31// CurrentRealm, OriginSend included). Re-stage after every skip.
32//
33// - Realm globals PERSIST across tests while banker state resets per
34// test. begin() disarms any leftover envelope and re-seeds the
35// realm's bank to its carried liabilities, so solvency is asserted in
36// DELTA form and every test tidies up after itself.
37//
38// - A panic caught by uassert does NOT roll back realm globals here,
39// whereas on-chain the whole transaction reverts. Every abort
40// asserted below happens strictly BEFORE this realm writes state, and
41// the one case where that is the POINT of the test
42// (TestDrawSolvencyRefusalConsumesNothing) asserts the untouched
43// state directly rather than relying on a rollback that does not
44// happen in this harness.
45//
46// - The SEND envelope is process-global: a staged envelope must be
47// cleared (SetOriginSend(nil)) before the next non-payable call, or
48// rejectStraySend fires and the test asserts the harness leak instead
49// of the guard.
50//
51// unsafe.CurrentRealm() is not available to a unit test, so `self` is
52// seeded from the package path the same way the chain derives it.
53func init() {
54 self = chain.PackageAddress(
55 "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/budgets")
56}
57
58type baseline struct {
59 held int64
60 pooled int64
61 credit int64
62 fees int64
63}
64
65func begin() baseline {
66 testing.SetOriginSend(nil)
67 if l := Liabilities(); l > 0 {
68 testing.IssueCoins(Address(), ugnot(l))
69 }
70 return baseline{
71 held: Held(),
72 pooled: TotalPooled(),
73 credit: UnclaimedCredit(),
74 fees: FeesAccrued(),
75 }
76}
77
78func ugnot(n int64) chain.Coins {
79 return chain.NewCoins(chain.NewCoin(Denom, n))
80}
81
82// assertSolvent asserts the two halves of H == P + L + S that can actually
83// FAIL. The identity itself is definitional here, because Surplus() is
84// defined as Held() - Liabilities(), so asserting it would be a tautology
85// dressed as a check. What can fail is that the realm owes more than it
86// holds, and that L decomposes exactly into delegate credit plus the fee
87// pot — neither of which follows from any definition in this file.
88// It also re-derives the P term the slow way. totalPooled is maintained
89// O(1) for Render's sake, so it is a CACHE of the pools tree, and nothing
90// in the realm ever recomputes it — exactly the shape that drifts silently.
91// Sabotage found this gap rather than review: disabling the per-owner debit
92// in Draw while leaving totalPooled correct was detected only by a later
93// native-Go banker panic, never by an assertion.
94func assertSolvent(t *testing.T) {
95 uassert.True(t, Held() >= Liabilities())
96 uassert.Equal(t, TotalPooled()+UnclaimedCredit()+FeesAccrued(), Liabilities())
97
98 sum := int64(0)
99 pools.Iterate("", "", func(_ string, v any) bool {
100 n := v.(int64)
101 // setPool removes a zeroed entry rather than storing it, so every
102 // surviving entry must be strictly positive.
103 uassert.True(t, n > 0)
104 sum += n
105 return false
106 })
107 uassert.Equal(t, sum, TotalPooled())
108}
109
110// fundAs stages a direct EOA call with -send and mirrors the envelope into
111// the realm's bank, as the chain would.
112func fundAs(cur realm, from address, amount int64) int64 {
113 testing.SetRealm(testing.NewUserRealm(from))
114 testing.SetOriginSend(ugnot(amount))
115 testing.IssueCoins(Address(), ugnot(amount))
116 n := Fund(cross(cur))
117 testing.SetOriginSend(nil)
118 return n
119}
120
121func grantAs(cur realm, from, spender address,
122 perPeriod, periodLength, total, expiresAt int64) int64 {
123 testing.SetRealm(testing.NewUserRealm(from))
124 return Grant(cross(cur), spender, "", perPeriod, periodLength, total, expiresAt)
125}
126
127func drawAs(cur realm, from address, id, amount int64) int64 {
128 testing.SetRealm(testing.NewUserRealm(from))
129 return Draw(cross(cur), id, amount)
130}
131
132func claimAs(cur realm, from address) int64 {
133 testing.SetRealm(testing.NewUserRealm(from))
134 return Claim(cross(cur))
135}
136
137func revokeAs(cur realm, from address, id int64) {
138 testing.SetRealm(testing.NewUserRealm(from))
139 Revoke(cross(cur), id)
140}
141
142func withdrawAllAs(cur realm, from address) int64 {
143 testing.SetRealm(testing.NewUserRealm(from))
144 return WithdrawPoolAll(cross(cur))
145}
146
147func balanceOf(addr address) int64 {
148 return coinio.HeldAt(addr, Denom)
149}
150
151// --- the pool ---
152
153func TestFundMakesAnOwner(cur realm, t *testing.T) {
154 b := begin()
155 uassert.Equal(t, int64(0), PoolOf(owner1))
156
157 pool := fundAs(cur, owner1, MinFunding)
158 uassert.Equal(t, MinFunding, pool)
159 uassert.Equal(t, MinFunding, PoolOf(owner1))
160 uassert.Equal(t, b.pooled+MinFunding, TotalPooled())
161 uassert.Equal(t, b.held+MinFunding, Held())
162 uassert.Equal(t, b.credit, UnclaimedCredit())
163 uassert.Equal(t, b.fees, FeesAccrued())
164 assertSolvent(t)
165
166 // Funding again accumulates into the same pool.
167 pool = fundAs(cur, owner1, 2*MinFunding)
168 uassert.Equal(t, 3*MinFunding, pool)
169 uassert.Equal(t, b.pooled+3*MinFunding, TotalPooled())
170
171 // A second owner's pool is independent.
172 fundAs(cur, owner2, MinFunding)
173 uassert.Equal(t, 3*MinFunding, PoolOf(owner1))
174 uassert.Equal(t, MinFunding, PoolOf(owner2))
175 assertSolvent(t)
176
177 uassert.Equal(t, 3*MinFunding, withdrawAllAs(cur, owner1))
178 uassert.Equal(t, MinFunding, withdrawAllAs(cur, owner2))
179 uassert.Equal(t, b.pooled, TotalPooled())
180 // setPool removes a zeroed entry rather than storing 0.
181 uassert.Equal(t, int64(0), PoolOf(owner1))
182}
183
184func TestFundGuards(cur realm, t *testing.T) {
185 begin()
186
187 // coinio's receipt shape: a realm-routed payment is refused by the
188 // primitive, not by anything this file could forget to write.
189 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/attacker"))
190 testing.SetOriginSend(ugnot(MinFunding))
191 uassert.AbortsWithMessage(t, cur,
192 "coinio: payment must be a direct EOA call with -send (realms and maketx-run are rejected)",
193 func() { Fund(cross(cur)) })
194 testing.SetOriginSend(nil)
195
196 // Wrong denomination.
197 testing.SetRealm(testing.NewUserRealm(owner1))
198 testing.SetOriginSend(chain.NewCoins(chain.NewCoin("foocoin", MinFunding)))
199 uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot",
200 func() { Fund(cross(cur)) })
201 testing.SetOriginSend(nil)
202
203 // No envelope at all.
204 testing.SetRealm(testing.NewUserRealm(owner1))
205 uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot",
206 func() { Fund(cross(cur)) })
207
208 // Below the minimum. Not issued into the bank, so the refused call
209 // leaves no surplus behind.
210 testing.SetRealm(testing.NewUserRealm(owner1))
211 testing.SetOriginSend(ugnot(MinFunding - 1))
212 uassert.AbortsWithMessage(t, cur, "fund at least 1000000ugnot",
213 func() { Fund(cross(cur)) })
214 testing.SetOriginSend(nil)
215
216 uassert.Equal(t, int64(0), PoolOf(owner1))
217 assertSolvent(t)
218}
219
220func TestWithdrawPoolIsUnconditional(cur realm, t *testing.T) {
221 b := begin()
222 fundAs(cur, owner1, 3*MinFunding)
223 before := balanceOf(owner1)
224
225 testing.SetRealm(testing.NewUserRealm(owner1))
226 left := WithdrawPool(cross(cur), MinFunding)
227 uassert.Equal(t, 2*MinFunding, left)
228 uassert.Equal(t, before+MinFunding, balanceOf(owner1))
229 uassert.Equal(t, b.pooled+2*MinFunding, TotalPooled())
230 assertSolvent(t)
231
232 testing.SetRealm(testing.NewUserRealm(owner1))
233 uassert.AbortsWithMessage(t, cur, "amount exceeds pool",
234 func() { WithdrawPool(cross(cur), 2*MinFunding+1) })
235
236 testing.SetRealm(testing.NewUserRealm(owner1))
237 uassert.AbortsWithMessage(t, cur, "amount must be positive",
238 func() { WithdrawPool(cross(cur), 0) })
239
240 // A non-owner has no pool to take, and mallory cannot reach owner1's.
241 testing.SetRealm(testing.NewUserRealm(mallory))
242 uassert.AbortsWithMessage(t, cur, "no pool to withdraw",
243 func() { WithdrawPoolAll(cross(cur)) })
244 uassert.Equal(t, 2*MinFunding, PoolOf(owner1))
245
246 uassert.Equal(t, 2*MinFunding, withdrawAllAs(cur, owner1))
247 uassert.Equal(t, b.pooled, TotalPooled())
248 assertSolvent(t)
249}
250
251// TestOwnerCanAlwaysLeave pins the documented consequence of that design:
252// an owner may empty the pool under a LIVE grant, and the delegate's next
253// draw then fails on solvency rather than being trapped half-way. A realm
254// that locked the capital behind the grant would be the worse realm, and
255// this test is here so the behaviour cannot drift silently either way.
256func TestOwnerCanAlwaysLeave(cur realm, t *testing.T) {
257 begin()
258 fundAs(cur, owner1, MinFunding)
259 id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0)
260
261 uassert.Equal(t, int64(500_000), Drawable(id))
262 withdrawAllAs(cur, owner1)
263 uassert.Equal(t, int64(0), PoolOf(owner1))
264
265 // The AUTHORITY is untouched; only the honest figure moved.
266 uassert.Equal(t, int64(500_000), Remaining(id))
267 uassert.Equal(t, int64(0), Drawable(id))
268
269 testing.SetRealm(testing.NewUserRealm(delegate1))
270 uassert.AbortsWithMessage(t, cur, "owner's pool cannot cover this draw",
271 func() { Draw(cross(cur), id, 1_000) })
272
273 revokeAs(cur, owner1, id)
274 assertSolvent(t)
275}
276
277// TestSelfGrantRecoversThePoolForTheFeeAlone pins the premise that the
278// MaxLiveGrants comment rests on, and it exists because the claim that
279// comment USED to make rotted undetected until an adversarial audit read
280// it (finding Y1). The lesson was not "that number was wrong" but "a
281// mitigation asserted nowhere is prose", so the replacement reasoning is
282// asserted here.
283//
284// The reasoning is that no capital bound on grant-slot occupation can be
285// built out of the pool, because an owner may be its OWN delegate. Make
286// an owner retain MinFunding while grants are live and this is the way
287// around it: grant to yourself, draw the pool to zero through your own
288// grant, Claim the credit back. You recover everything but the draw fee,
289// the slot is still held, and a retention rule reads as satisfied the
290// whole way through. The realm permits this deliberately — refusing a
291// self-grant would not close it either, since a second address the same
292// operator controls is free.
293func TestSelfGrantRecoversThePoolForTheFeeAlone(cur realm, t *testing.T) {
294 begin()
295 feesBefore := FeesAccrued()
296
297 fundAs(cur, owner1, MinFunding)
298 // owner1 is both parties. Nothing refuses it.
299 id := grantAs(cur, owner1, owner1, MinFunding, MinPeriodBlocks, 0, 0)
300
301 // Drain the whole pool through the owner's own grant.
302 credited := drawAs(cur, owner1, id, MinFunding)
303 fee := MinFunding * DrawFeeBps / 10_000
304 uassert.Equal(t, MinFunding-fee, credited)
305 uassert.Equal(t, int64(0), PoolOf(owner1))
306
307 // ... and take it back out. Net cost of holding the slot: the fee.
308 uassert.Equal(t, MinFunding-fee, claimAs(cur, owner1))
309 uassert.Equal(t, feesBefore+fee, FeesAccrued())
310
311 // The slot is still occupied, which is the whole point: the grant
312 // survived the pool reaching zero, and no third party can remove it
313 // because it carries no expiry.
314 uassert.Equal(t, int64(1), ownerOpen(owner1.String()))
315 uassert.False(t, IsExpired(id))
316 testing.SetRealm(testing.NewUserRealm(delegate1))
317 uassert.AbortsWithMessage(t, cur, "spendbook: allowance has not expired",
318 func() { Reap(cross(cur), id) })
319
320 revokeAs(cur, owner1, id)
321 uassert.Equal(t, int64(0), ownerOpen(owner1.String()))
322 assertSolvent(t)
323}
324
325// --- grants ---
326
327func TestGrantRecordsTermsAndParties(cur realm, t *testing.T) {
328 begin()
329 fundAs(cur, owner1, MinFunding)
330 h := Height()
331 liveBefore := LiveGrants()
332
333 testing.SetRealm(testing.NewUserRealm(owner1))
334 id := Grant(cross(cur), delegate1, "monthly ops", 50_000, MinPeriodBlocks,
335 200_000, h+500)
336
337 uassert.Equal(t, id, NumGrants())
338 uassert.Equal(t, liveBefore+1, LiveGrants())
339 uassert.Equal(t, int64(1), GrantsOf(owner1))
340 uassert.Equal(t, "monthly ops", GrantLabel(id))
341
342 o, s, perPeriod, periodLength, total, expiresAt, anchor, createdAt, feeBps :=
343 GrantInfo(id)
344 uassert.Equal(t, owner1, o)
345 uassert.Equal(t, delegate1, s)
346 uassert.Equal(t, int64(50_000), perPeriod)
347 uassert.Equal(t, MinPeriodBlocks, periodLength)
348 uassert.Equal(t, int64(200_000), total)
349 uassert.Equal(t, h+500, expiresAt)
350 // The anchor is the grant height: windows are anchored, not sliding,
351 // so nobody resets a cap by choosing when to call.
352 uassert.Equal(t, h, anchor)
353 uassert.Equal(t, h, createdAt)
354 uassert.Equal(t, DrawFeeBps, feeBps)
355
356 spentWindow, spentTotal, windowIndex, draws := GrantUsage(id)
357 uassert.Equal(t, int64(0), spentWindow)
358 uassert.Equal(t, int64(0), spentTotal)
359 uassert.Equal(t, int64(0), windowIndex)
360 uassert.Equal(t, int64(0), draws)
361
362 uassert.False(t, IsExpired(id))
363
364 revokeAs(cur, owner1, id)
365 uassert.Equal(t, int64(0), GrantsOf(owner1))
366 uassert.Equal(t, liveBefore, LiveGrants())
367 withdrawAllAs(cur, owner1)
368}
369
370func TestGrantValidation(cur realm, t *testing.T) {
371 begin()
372
373 // No pool: a grant from an address with nothing behind it is refused
374 // before any allowance is opened.
375 testing.SetRealm(testing.NewUserRealm(owner1))
376 uassert.AbortsWithMessage(t, cur, "fund a pool before granting",
377 func() { Grant(cross(cur), delegate1, "", 50_000, MinPeriodBlocks, 0, 0) })
378
379 fundAs(cur, owner1, MinFunding)
380
381 testing.SetRealm(testing.NewUserRealm(owner1))
382 uassert.AbortsWithMessage(t, cur,
383 "spender is not a valid address in canonical (lowercase) form",
384 func() { Grant(cross(cur), address("not-an-address"), "", 50_000, MinPeriodBlocks, 0, 0) })
385
386 testing.SetRealm(testing.NewUserRealm(owner1))
387 uassert.AbortsWithMessage(t, cur, "label exceeds 64 bytes",
388 func() {
389 Grant(cross(cur), delegate1, strings.Repeat("x", MaxLabelLen+1),
390 50_000, MinPeriodBlocks, 0, 0)
391 })
392
393 testing.SetRealm(testing.NewUserRealm(owner1))
394 uassert.AbortsWithMessage(t, cur, "perPeriod must be at least 1000ugnot",
395 func() { Grant(cross(cur), delegate1, "", MinPerPeriod-1, MinPeriodBlocks, 0, 0) })
396
397 testing.SetRealm(testing.NewUserRealm(owner1))
398 uassert.AbortsWithMessage(t, cur,
399 "periodLength must be 0 (lifetime) or at least 100 blocks",
400 func() { Grant(cross(cur), delegate1, "", 50_000, MinPeriodBlocks-1, 0, 0) })
401
402 // An expiry already in the past is spendbook's own refusal: an
403 // allowance that can authorize nothing must not become a live entry.
404 testing.SetRealm(testing.NewUserRealm(owner1))
405 uassert.AbortsWithMessage(t, cur, "spendbook: invalid terms",
406 func() { Grant(cross(cur), delegate1, "", 50_000, MinPeriodBlocks, 0, 1) })
407
408 uassert.Equal(t, int64(0), GrantsOf(owner1))
409 withdrawAllAs(cur, owner1)
410}
411
412// TestGrantRefusesUppercaseSpender measures the premise of Grant's
413// lowercase clause rather than asserting it from the bech32 spec. If
414// IsValid() ever stops accepting the uppercase spelling, the first
415// assertion here fails and the clause's comment must be corrected — which
416// is the point of measuring it.
417func TestGrantRefusesUppercaseSpender(cur realm, t *testing.T) {
418 begin()
419 fundAs(cur, owner1, MinFunding)
420
421 upper := address(strings.ToUpper(string(delegate1)))
422 uassert.True(t, upper != delegate1)
423 uassert.True(t, upper.IsValid()) // the hazard, measured on this toolchain
424
425 testing.SetRealm(testing.NewUserRealm(owner1))
426 uassert.AbortsWithMessage(t, cur,
427 "spender is not a valid address in canonical (lowercase) form",
428 func() { Grant(cross(cur), upper, "", 50_000, MinPeriodBlocks, 0, 0) })
429
430 uassert.Equal(t, int64(0), GrantsOf(owner1))
431 withdrawAllAs(cur, owner1)
432}
433
434// TestGrantPerOwnerCap covers the gap spendbook's own documentation names:
435// maxLive is a single global figure, so without a per-owner cap one owner
436// could exhaust it and lock everyone else out.
437func TestGrantPerOwnerCap(cur realm, t *testing.T) {
438 begin()
439 fundAs(cur, owner1, MinFunding)
440
441 ids := []int64{}
442 for i := int64(0); i < MaxGrantsPerOwner; i++ {
443 ids = append(ids, grantAs(cur, owner1, delegate1, MinPerPeriod,
444 MinPeriodBlocks, 0, 0))
445 }
446 uassert.Equal(t, MaxGrantsPerOwner, GrantsOf(owner1))
447
448 testing.SetRealm(testing.NewUserRealm(owner1))
449 uassert.AbortsWithMessage(t, cur, "owner already has 16 live grants",
450 func() { Grant(cross(cur), delegate1, "", MinPerPeriod, MinPeriodBlocks, 0, 0) })
451
452 // A DIFFERENT owner is unaffected — the cap is per-owner, not global.
453 fundAs(cur, owner2, MinFunding)
454 other := grantAs(cur, owner2, delegate2, MinPerPeriod, MinPeriodBlocks, 0, 0)
455 uassert.Equal(t, int64(1), GrantsOf(owner2))
456
457 // Revoking frees a slot, and the id is not reused.
458 revokeAs(cur, owner1, ids[0])
459 uassert.Equal(t, MaxGrantsPerOwner-1, GrantsOf(owner1))
460 fresh := grantAs(cur, owner1, delegate1, MinPerPeriod, MinPeriodBlocks, 0, 0)
461 uassert.True(t, fresh > ids[len(ids)-1])
462
463 for _, id := range ids[1:] {
464 revokeAs(cur, owner1, id)
465 }
466 revokeAs(cur, owner1, fresh)
467 revokeAs(cur, owner2, other)
468 uassert.Equal(t, int64(0), GrantsOf(owner1))
469 uassert.Equal(t, int64(0), GrantsOf(owner2))
470 withdrawAllAs(cur, owner1)
471 withdrawAllAs(cur, owner2)
472 assertSolvent(t)
473}
474
475func TestGrantAndAmendAreOwnerOnly(cur realm, t *testing.T) {
476 begin()
477 fundAs(cur, owner1, MinFunding)
478 id := grantAs(cur, owner1, delegate1, 50_000, MinPeriodBlocks, 0, 0)
479
480 testing.SetRealm(testing.NewUserRealm(mallory))
481 uassert.AbortsWithMessage(t, cur, "owner only",
482 func() { Amend(cross(cur), id, 10_000, 0, 0) })
483
484 testing.SetRealm(testing.NewUserRealm(mallory))
485 uassert.AbortsWithMessage(t, cur, "owner only",
486 func() { Revoke(cross(cur), id) })
487
488 // Not even the delegate may amend or revoke their own grant.
489 testing.SetRealm(testing.NewUserRealm(delegate1))
490 uassert.AbortsWithMessage(t, cur, "owner only",
491 func() { Amend(cross(cur), id, 10_000, 0, 0) })
492
493 testing.SetRealm(testing.NewUserRealm(owner1))
494 uassert.AbortsWithMessage(t, cur, "unknown grant id",
495 func() { Revoke(cross(cur), 999_999) })
496
497 revokeAs(cur, owner1, id)
498 withdrawAllAs(cur, owner1)
499}
500
501// --- drawing ---
502
503func TestDrawIsDelegateOnlyAndCreditsNetOfFee(cur realm, t *testing.T) {
504 b := begin()
505 fundAs(cur, owner1, MinFunding)
506 id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0)
507
508 // Nobody but the named delegate — not mallory, and not the owner.
509 testing.SetRealm(testing.NewUserRealm(mallory))
510 uassert.AbortsWithMessage(t, cur, "delegate only",
511 func() { Draw(cross(cur), id, 1_000) })
512 testing.SetRealm(testing.NewUserRealm(owner1))
513 uassert.AbortsWithMessage(t, cur, "delegate only",
514 func() { Draw(cross(cur), id, 1_000) })
515
516 testing.SetRealm(testing.NewUserRealm(delegate1))
517 uassert.AbortsWithMessage(t, cur, "amount must be positive",
518 func() { Draw(cross(cur), id, 0) })
519
520 heldBefore := Held()
521 credited := drawAs(cur, delegate1, id, 100_000)
522
523 // 1% of the gross, floor-rounded in the delegate's favour.
524 uassert.Equal(t, int64(99_000), credited)
525 uassert.Equal(t, int64(99_000), CreditOf(delegate1))
526 uassert.Equal(t, b.fees+1_000, FeesAccrued())
527
528 // The owner's pool is debited the GROSS: the fee is paid out of the
529 // budget, not out of the delegate's authority.
530 uassert.Equal(t, MinFunding-100_000, PoolOf(owner1))
531 uassert.Equal(t, b.pooled+MinFunding-100_000, TotalPooled())
532
533 // Draw moves value between P and L only. H does not move at all, which
534 // is why this transition has no payout that could fail.
535 uassert.Equal(t, heldBefore, Held())
536 assertSolvent(t)
537
538 spentWindow, spentTotal, _, draws := GrantUsage(id)
539 uassert.Equal(t, int64(100_000), spentWindow)
540 uassert.Equal(t, int64(100_000), spentTotal)
541 uassert.Equal(t, int64(1), draws)
542 uassert.Equal(t, int64(400_000), Remaining(id))
543
544 // Claim is the separate transaction that moves coins.
545 before := balanceOf(delegate1)
546 paid := claimAs(cur, delegate1)
547 uassert.Equal(t, int64(99_000), paid)
548 uassert.Equal(t, before+99_000, balanceOf(delegate1))
549 uassert.Equal(t, int64(0), CreditOf(delegate1))
550 uassert.Equal(t, b.credit, UnclaimedCredit())
551 assertSolvent(t)
552
553 testing.SetRealm(testing.NewUserRealm(delegate1))
554 uassert.AbortsWithMessage(t, cur, "no credit to claim",
555 func() { Claim(cross(cur)) })
556
557 revokeAs(cur, owner1, id)
558 withdrawAllAs(cur, owner1)
559 tidyFees(cur, t)
560}
561
562// TestDrawSolvencyRefusalConsumesNothing is this realm's central
563// invariant, and the ordering inside Draw is what it proves: the solvency
564// check runs BEFORE spendbook.Spend, so a draw refused for insolvency
565// leaves the window capacity intact rather than burning it on a draw that
566// never happened.
567//
568// This is also the one abort in this file where the post-state is the
569// assertion rather than an afterthought, because uassert does not roll
570// back realm globals the way a reverted transaction would.
571func TestDrawSolvencyRefusalConsumesNothing(cur realm, t *testing.T) {
572 begin()
573 fundAs(cur, owner1, MinFunding)
574 // Authority deliberately EXCEEDS the pool: a budget is authority, not
575 // a reservation, and the realm's promise is that it never credits what
576 // it cannot pay.
577 id := grantAs(cur, owner1, delegate1, 5*MinFunding, MinPeriodBlocks, 0, 0)
578 uassert.Equal(t, 5*MinFunding, Remaining(id))
579 uassert.Equal(t, MinFunding, Drawable(id)) // the honest figure
580
581 testing.SetRealm(testing.NewUserRealm(delegate1))
582 uassert.AbortsWithMessage(t, cur, "owner's pool cannot cover this draw",
583 func() { Draw(cross(cur), id, MinFunding+1) })
584
585 // Nothing moved, and — the part that matters — nothing was CONSUMED.
586 spentWindow, spentTotal, _, draws := GrantUsage(id)
587 uassert.Equal(t, int64(0), spentWindow)
588 uassert.Equal(t, int64(0), spentTotal)
589 uassert.Equal(t, int64(0), draws)
590 uassert.Equal(t, 5*MinFunding, Remaining(id))
591 uassert.Equal(t, MinFunding, PoolOf(owner1))
592 uassert.Equal(t, int64(0), CreditOf(delegate1))
593 assertSolvent(t)
594
595 // Exactly the drawable figure succeeds, which is what makes the
596 // refusal above a boundary rather than a blanket.
597 uassert.Equal(t, int64(990_000), drawAs(cur, delegate1, id, MinFunding))
598 uassert.Equal(t, int64(0), PoolOf(owner1))
599
600 claimAs(cur, delegate1)
601 revokeAs(cur, owner1, id)
602 tidyFees(cur, t)
603 assertSolvent(t)
604}
605
606func TestDrawFeeRoundingFavorsTheDelegate(cur realm, t *testing.T) {
607 b := begin()
608 fundAs(cur, owner1, MinFunding)
609 id := grantAs(cur, owner1, delegate1, 50_000, MinPeriodBlocks, 0, 0)
610
611 // 1% of 99 floors to 0, so the delegate keeps all of it. There is
612 // deliberately no minimum draw: a floor above a grant's PerPeriod
613 // would make the grant silently undrawable.
614 uassert.Equal(t, int64(99), drawAs(cur, delegate1, id, 99))
615 uassert.Equal(t, b.fees, FeesAccrued())
616 uassert.Equal(t, int64(99), CreditOf(delegate1))
617
618 // 100 is the first amount that pays a fee at all.
619 uassert.Equal(t, int64(99), drawAs(cur, delegate1, id, 100))
620 uassert.Equal(t, b.fees+1, FeesAccrued())
621 uassert.Equal(t, int64(198), CreditOf(delegate1))
622 assertSolvent(t)
623
624 claimAs(cur, delegate1)
625 revokeAs(cur, owner1, id)
626 withdrawAllAs(cur, owner1)
627 tidyFees(cur, t)
628}
629
630// TestWindowRefillsAtAnchoredBoundary is spendbook consumer obligation 1
631// observed from the consuming side: the clock is runtime.ChainHeight() and
632// no caller supplies it, so the window cannot be rolled on demand. The
633// refill is a RESET, not a carry.
634func TestWindowRefillsAtAnchoredBoundary(cur realm, t *testing.T) {
635 begin()
636 fundAs(cur, owner1, 10*MinFunding)
637 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0)
638
639 drawAs(cur, delegate1, id, 100_000)
640 uassert.Equal(t, int64(0), Remaining(id))
641
642 testing.SetRealm(testing.NewUserRealm(delegate1))
643 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap",
644 func() { Draw(cross(cur), id, 1) })
645
646 // One block short of the boundary: still refused.
647 testing.SkipHeights(MinPeriodBlocks - 1)
648 uassert.Equal(t, int64(0), Remaining(id))
649 testing.SetRealm(testing.NewUserRealm(delegate1))
650 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap",
651 func() { Draw(cross(cur), id, 1) })
652
653 // The boundary itself. The window index advances and the cap is full
654 // again — not 100_000 plus a carried remainder.
655 testing.SkipHeights(1)
656 uassert.Equal(t, int64(100_000), Remaining(id))
657 _, _, windowIndex, _ := GrantUsage(id)
658 uassert.Equal(t, int64(1), windowIndex)
659
660 // THE STALE-PAIR CASE, and it is asserted here rather than left to
661 // inference because this is the only moment it is observable: the
662 // chain has crossed a boundary and NOTHING has been written since, so
663 // Allowance.WindowIndex still names window 0 and Allowance.SpentWindow
664 // still holds window 0's 100_000. Reading either field straight off
665 // the record would publish "spent this window: 100000" directly above
666 // "authorized by the terms: 100000" — a page contradicting itself.
667 // spentInWindow is what makes the first figure 0, and this assertion
668 // is what keeps it that way.
669 staleWindow, _, _, _ := GrantUsage(id)
670 uassert.Equal(t, int64(0), staleWindow)
671 page := Render(itoa(id))
672 uassert.True(t, strings.Contains(page, "spent this window: 0ugnot"))
673 uassert.True(t, strings.Contains(page, "now in window 1"))
674
675 drawAs(cur, delegate1, id, 100_000)
676 _, spentTotal, _, draws := GrantUsage(id)
677 uassert.Equal(t, int64(200_000), spentTotal) // lifetime accumulates
678 uassert.Equal(t, int64(2), draws)
679 spentWindow, _, _, _ := GrantUsage(id)
680 uassert.Equal(t, int64(100_000), spentWindow) // the window reset
681 assertSolvent(t)
682
683 claimAs(cur, delegate1)
684 revokeAs(cur, owner1, id)
685 withdrawAllAs(cur, owner1)
686 tidyFees(cur, t)
687}
688
689func TestLifetimeTotalBindsAcrossARollover(cur realm, t *testing.T) {
690 begin()
691 fundAs(cur, owner1, 10*MinFunding)
692 // Lifetime cap at 1.5 windows: the refill cannot outrun it.
693 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 150_000, 0)
694
695 drawAs(cur, delegate1, id, 100_000)
696 testing.SkipHeights(MinPeriodBlocks)
697
698 // The window refilled to 100_000 but the lifetime cap leaves 50_000,
699 // and Remaining reports the LESSER of the two.
700 uassert.Equal(t, int64(50_000), Remaining(id))
701 testing.SetRealm(testing.NewUserRealm(delegate1))
702 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the lifetime cap",
703 func() { Draw(cross(cur), id, 50_001) })
704
705 drawAs(cur, delegate1, id, 50_000)
706 uassert.Equal(t, int64(0), Remaining(id))
707
708 // A further window brings no further authority: the lifetime cap is
709 // absolute.
710 testing.SkipHeights(MinPeriodBlocks)
711 uassert.Equal(t, int64(0), Remaining(id))
712 testing.SetRealm(testing.NewUserRealm(delegate1))
713 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the lifetime cap",
714 func() { Draw(cross(cur), id, 1) })
715 assertSolvent(t)
716
717 claimAs(cur, delegate1)
718 revokeAs(cur, owner1, id)
719 withdrawAllAs(cur, owner1)
720 tidyFees(cur, t)
721}
722
723// TestLifetimeWindowNeverRefills covers periodLength == 0: ONE window for
724// the grant's whole life. It is a distinct code path in spendbook
725// (windowIndex is pinned to 0) and a distinct product shape — a one-off
726// authority rather than a recurring budget.
727func TestLifetimeWindowNeverRefills(cur realm, t *testing.T) {
728 begin()
729 fundAs(cur, owner1, MinFunding)
730 id := grantAs(cur, owner1, delegate1, 100_000, 0, 0, 0)
731
732 // GrantInfo's fourth result is periodLength; the third is perPeriod.
733 _, _, _, periodLength, _, _, _, _, _ := GrantInfo(id)
734 uassert.Equal(t, int64(0), periodLength)
735
736 drawAs(cur, delegate1, id, 100_000)
737 uassert.Equal(t, int64(0), Remaining(id))
738
739 testing.SkipHeights(10 * MinPeriodBlocks)
740 uassert.Equal(t, int64(0), Remaining(id))
741 _, _, windowIndex, _ := GrantUsage(id)
742 uassert.Equal(t, int64(0), windowIndex)
743
744 testing.SetRealm(testing.NewUserRealm(delegate1))
745 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap",
746 func() { Draw(cross(cur), id, 1) })
747
748 // And a LOOSENING cannot be queued against a grant with no boundary to
749 // wait for — spendbook refuses rather than accepting an amendment that
750 // would never take effect.
751 testing.SetRealm(testing.NewUserRealm(owner1))
752 uassert.AbortsWithMessage(t, cur,
753 "spendbook: no reachable next window boundary",
754 func() { Amend(cross(cur), id, 200_000, 0, 0) })
755 assertSolvent(t)
756
757 claimAs(cur, delegate1)
758 revokeAs(cur, owner1, id)
759 withdrawAllAs(cur, owner1)
760 tidyFees(cur, t)
761}
762
763// --- amendment ---
764
765func TestAmendIsOneSided(cur realm, t *testing.T) {
766 begin()
767 fundAs(cur, owner1, 10*MinFunding)
768 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0)
769
770 // A TIGHTENING takes effect immediately, mid-window.
771 testing.SetRealm(testing.NewUserRealm(owner1))
772 uassert.True(t, Amend(cross(cur), id, 20_000, 0, 0))
773 uassert.Equal(t, int64(20_000), Remaining(id))
774 _, _, pending := pendingOf(id)
775 uassert.False(t, pending)
776
777 testing.SetRealm(testing.NewUserRealm(delegate1))
778 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap",
779 func() { Draw(cross(cur), id, 20_001) })
780
781 // A LOOSENING is queued for the next boundary, not applied now. That
782 // asymmetry is the whole guarantee: the delegate's exposure in the
783 // current window can only ever shrink.
784 testing.SetRealm(testing.NewUserRealm(owner1))
785 uassert.False(t, Amend(cross(cur), id, 300_000, 0, 0))
786 uassert.Equal(t, int64(20_000), Remaining(id))
787 perPeriod, _, pending := pendingOf(id)
788 uassert.True(t, pending)
789 uassert.Equal(t, int64(300_000), perPeriod)
790
791 testing.SetRealm(testing.NewUserRealm(delegate1))
792 uassert.AbortsWithMessage(t, cur, "spendbook: amount exceeds the window cap",
793 func() { Draw(cross(cur), id, 20_001) })
794
795 // At the boundary the pending terms become the live ones.
796 testing.SkipHeights(MinPeriodBlocks)
797 uassert.Equal(t, int64(300_000), Remaining(id))
798 drawAs(cur, delegate1, id, 300_000)
799 assertSolvent(t)
800
801 // The realm's own floor still applies to an amendment.
802 testing.SetRealm(testing.NewUserRealm(owner1))
803 uassert.AbortsWithMessage(t, cur, "perPeriod must be at least 1000ugnot",
804 func() { Amend(cross(cur), id, MinPerPeriod-1, 0, 0) })
805
806 claimAs(cur, delegate1)
807 revokeAs(cur, owner1, id)
808 withdrawAllAs(cur, owner1)
809 tidyFees(cur, t)
810}
811
812// TestAmendCannotMovePeriodLength asserts a STRUCTURAL property, not a
813// refusal: Amend has no periodLength parameter at all, so moving every
814// future boundary is not merely rejected, it is unexpressible. The realm
815// reads the grant's own periodLength and passes it back unchanged.
816func TestAmendCannotMovePeriodLength(cur realm, t *testing.T) {
817 begin()
818 fundAs(cur, owner1, MinFunding)
819 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0)
820
821 _, _, _, before, _, _, anchorBefore, _, _ := GrantInfo(id)
822 testing.SetRealm(testing.NewUserRealm(owner1))
823 uassert.True(t, Amend(cross(cur), id, 10_000, 0, 0))
824 _, _, _, after, _, _, anchorAfter, _, _ := GrantInfo(id)
825
826 uassert.Equal(t, before, after)
827 uassert.Equal(t, MinPeriodBlocks, after)
828 // The anchor is immovable too, so the boundaries stay where they were.
829 uassert.Equal(t, anchorBefore, anchorAfter)
830
831 revokeAs(cur, owner1, id)
832 withdrawAllAs(cur, owner1)
833}
834
835// --- ending a grant ---
836
837func TestRevokeEndsAuthorityButNotEarnedCredit(cur realm, t *testing.T) {
838 begin()
839 fundAs(cur, owner1, MinFunding)
840 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0)
841 drawAs(cur, delegate1, id, 100_000)
842 uassert.Equal(t, int64(99_000), CreditOf(delegate1))
843
844 revokeAs(cur, owner1, id)
845
846 // The grant record is gone and the id is never reused, so a later draw
847 // fails as unknown rather than as unauthorized.
848 testing.SetRealm(testing.NewUserRealm(delegate1))
849 uassert.AbortsWithMessage(t, cur, "unknown grant id",
850 func() { Draw(cross(cur), id, 1) })
851 testing.SetRealm(testing.NewUserRealm(owner1))
852 uassert.AbortsWithMessage(t, cur, "unknown grant id",
853 func() { Revoke(cross(cur), id) })
854
855 // Credit already drawn is the delegate's. Revoke ends FUTURE authority
856 // and claws back nothing.
857 uassert.Equal(t, int64(99_000), CreditOf(delegate1))
858 before := balanceOf(delegate1)
859 uassert.Equal(t, int64(99_000), claimAs(cur, delegate1))
860 uassert.Equal(t, before+99_000, balanceOf(delegate1))
861 assertSolvent(t)
862
863 withdrawAllAs(cur, owner1)
864 tidyFees(cur, t)
865}
866
867func TestReapIsPermissionlessButOnlyForExpired(cur realm, t *testing.T) {
868 begin()
869 fundAs(cur, owner1, MinFunding)
870 h := Height()
871 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, h+200)
872 noExpiry := grantAs(cur, owner1, delegate2, 100_000, MinPeriodBlocks, 0, 0)
873
874 // Live: refused, for anyone.
875 testing.SetRealm(testing.NewUserRealm(mallory))
876 uassert.AbortsWithMessage(t, cur, "spendbook: allowance has not expired",
877 func() { Reap(cross(cur), id) })
878 uassert.False(t, IsExpired(id))
879
880 testing.SkipHeights(200)
881 uassert.True(t, IsExpired(id))
882
883 // An expired allowance can authorize nothing, so its own delegate is
884 // refused too — and a stranger may clear it.
885 testing.SetRealm(testing.NewUserRealm(delegate1))
886 uassert.AbortsWithMessage(t, cur, "spendbook: allowance has expired",
887 func() { Draw(cross(cur), id, 1) })
888
889 liveBefore := LiveGrants()
890 testing.SetRealm(testing.NewUserRealm(mallory))
891 Reap(cross(cur), id)
892 uassert.Equal(t, liveBefore-1, LiveGrants())
893 uassert.Equal(t, int64(1), GrantsOf(owner1))
894
895 // A grant with NO expiry can never be reaped: only its owner ends it.
896 uassert.False(t, IsExpired(noExpiry))
897 testing.SetRealm(testing.NewUserRealm(mallory))
898 uassert.AbortsWithMessage(t, cur, "spendbook: allowance has not expired",
899 func() { Reap(cross(cur), noExpiry) })
900
901 revokeAs(cur, owner1, noExpiry)
902 withdrawAllAs(cur, owner1)
903 assertSolvent(t)
904}
905
906// --- fees, surplus, and the absence of an admin ---
907
908func TestWithdrawFeesGoesOnlyToTheFixedCollector(cur realm, t *testing.T) {
909 begin()
910 fundAs(cur, owner1, MinFunding)
911 id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0)
912 drawAs(cur, delegate1, id, 500_000)
913 uassert.Equal(t, int64(5_000), FeesAccrued())
914
915 // Permissionless, because the destination is a compile-time constant:
916 // there is nothing for a caller to steer.
917 before := balanceOf(FeeCollector)
918 testing.SetRealm(testing.NewUserRealm(mallory))
919 uassert.Equal(t, int64(5_000), WithdrawFees(cross(cur)))
920 uassert.Equal(t, before+5_000, balanceOf(FeeCollector))
921 uassert.Equal(t, int64(0), FeesAccrued())
922
923 testing.SetRealm(testing.NewUserRealm(mallory))
924 uassert.AbortsWithMessage(t, cur, "no fees accrued",
925 func() { WithdrawFees(cross(cur)) })
926 assertSolvent(t)
927
928 claimAs(cur, delegate1)
929 revokeAs(cur, owner1, id)
930 withdrawAllAs(cur, owner1)
931}
932
933func TestSweepSurplusCannotReachTrackedLiabilities(cur realm, t *testing.T) {
934 begin()
935 fundAs(cur, owner1, MinFunding)
936 id := grantAs(cur, owner1, delegate1, 500_000, MinPeriodBlocks, 0, 0)
937 drawAs(cur, delegate1, id, 200_000)
938
939 // Nothing out of band yet: the reserve is everything the realm holds,
940 // and the primitive ABORTS rather than reporting a transfer of zero.
941 uassert.Equal(t, int64(0), Surplus())
942 testing.SetRealm(testing.NewUserRealm(mallory))
943 uassert.AbortsWithMessage(t, cur, "coinio: no surplus to sweep for ugnot",
944 func() { SweepSurplus(cross(cur), Denom) })
945 uassert.Equal(t, MinFunding-200_000, PoolOf(owner1))
946 uassert.Equal(t, int64(198_000), CreditOf(delegate1))
947 uassert.Equal(t, int64(2_000), FeesAccrued())
948
949 // A foreign denom the realm has never held is the same answer: there
950 // is nothing to move, so nothing claims to have moved.
951 testing.SetRealm(testing.NewUserRealm(mallory))
952 uassert.AbortsWithMessage(t, cur, "coinio: no surplus to sweep for foocoin",
953 func() { SweepSurplus(cross(cur), "foocoin") })
954
955 // Coins arriving outside any entrypoint ARE sweepable, and only those.
956 testing.IssueCoins(Address(), ugnot(777))
957 uassert.Equal(t, int64(777), Surplus())
958 before := balanceOf(FeeCollector)
959 testing.SetRealm(testing.NewUserRealm(mallory))
960 uassert.Equal(t, int64(777), SweepSurplus(cross(cur), Denom))
961 uassert.Equal(t, before+777, balanceOf(FeeCollector))
962 uassert.Equal(t, int64(0), Surplus())
963
964 // Every tracked term survived the sweep untouched.
965 uassert.Equal(t, MinFunding-200_000, PoolOf(owner1))
966 uassert.Equal(t, int64(198_000), CreditOf(delegate1))
967 uassert.Equal(t, int64(2_000), FeesAccrued())
968 assertSolvent(t)
969
970 claimAs(cur, delegate1)
971 revokeAs(cur, owner1, id)
972 withdrawAllAs(cur, owner1)
973 tidyFees(cur, t)
974}
975
976// TestStraySendIsRejectedOnEveryNonPayableEntrypoint sweeps the whole
977// non-payable surface. A coin attached to any of these would otherwise
978// become sweepable surplus — silently converting a user's coins into
979// operator funds — so aborting and reverting the transfer is the only safe
980// answer. The count assertion is deliberate: a table that silently lost
981// its rows would otherwise report green.
982func TestStraySendIsRejectedOnEveryNonPayableEntrypoint(cur realm, t *testing.T) {
983 begin()
984 fundAs(cur, owner1, MinFunding)
985 id := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0)
986
987 // No IssueCoins: the envelope is staged but never mirrored into the
988 // bank, so a refused call cannot leave surplus behind.
989 calls := []func(){
990 func() { WithdrawPool(cross(cur), 1) },
991 func() { WithdrawPoolAll(cross(cur)) },
992 func() { Grant(cross(cur), delegate2, "", 100_000, MinPeriodBlocks, 0, 0) },
993 func() { Amend(cross(cur), id, 100_000, 0, 0) },
994 func() { Revoke(cross(cur), id) },
995 func() { Reap(cross(cur), id) },
996 func() { Draw(cross(cur), id, 1_000) },
997 func() { Claim(cross(cur)) },
998 func() { WithdrawFees(cross(cur)) },
999 func() { SweepSurplus(cross(cur), Denom) },
1000 }
1001 uassert.Equal(t, 10, len(calls))
1002
1003 for _, call := range calls {
1004 testing.SetRealm(testing.NewUserRealm(owner1))
1005 testing.SetOriginSend(ugnot(1))
1006 uassert.AbortsWithMessage(t, cur,
1007 "this entrypoint does not accept coins", call)
1008 testing.SetOriginSend(nil)
1009 }
1010
1011 // The guard runs FIRST in every one of them, which is why even calls
1012 // that would have failed on their own arguments abort with this
1013 // message — and why nothing above changed any state.
1014 uassert.Equal(t, MinFunding, PoolOf(owner1))
1015 uassert.Equal(t, int64(1), GrantsOf(owner1))
1016 uassert.Equal(t, int64(100_000), Remaining(id))
1017 assertSolvent(t)
1018
1019 revokeAs(cur, owner1, id)
1020 withdrawAllAs(cur, owner1)
1021}
1022
1023// TestDrawableIsTheLesserOfAuthorityAndPool pins the realm's own
1024// contribution to the view surface. spendbook bounds PERMISSION and says
1025// so; it never speaks about solvency. Remaining is the authority figure
1026// and Drawable is the honest one, and conflating them is what would make
1027// the realm promise what it does not hold.
1028func TestDrawableIsTheLesserOfAuthorityAndPool(cur realm, t *testing.T) {
1029 begin()
1030 fundAs(cur, owner1, MinFunding)
1031
1032 // Authority below the pool: authority binds.
1033 small := grantAs(cur, owner1, delegate1, 100_000, MinPeriodBlocks, 0, 0)
1034 uassert.Equal(t, int64(100_000), Remaining(small))
1035 uassert.Equal(t, int64(100_000), Drawable(small))
1036
1037 // Authority above the pool: the pool binds, and Remaining does NOT
1038 // move — the two views answer different questions.
1039 big := grantAs(cur, owner1, delegate2, 5*MinFunding, MinPeriodBlocks, 0, 0)
1040 uassert.Equal(t, 5*MinFunding, Remaining(big))
1041 uassert.Equal(t, MinFunding, Drawable(big))
1042
1043 // Two grants, one pool: three grants of 100 against a pool of 100 are
1044 // all three valid and the first delegate to draw gets the money. The
1045 // sum of what is drawable deliberately EXCEEDS the pool, because a
1046 // budget is authority and not a reservation.
1047 uassert.True(t, Drawable(small)+Drawable(big) > PoolOf(owner1))
1048
1049 drawAs(cur, delegate1, small, 100_000)
1050 uassert.Equal(t, MinFunding-100_000, Drawable(big)) // the other shrank
1051
1052 claimAs(cur, delegate1)
1053 revokeAs(cur, owner1, small)
1054 revokeAs(cur, owner1, big)
1055 withdrawAllAs(cur, owner1)
1056 tidyFees(cur, t)
1057}
1058
1059func TestViewsRefuseUnknownIds(cur realm, t *testing.T) {
1060 begin()
1061 // PanicsWithMessage, not AbortsWithMessage, and the distinction is the
1062 // view/entrypoint one rather than a style choice. revive() catches an
1063 // ABORT, which is what a panic becomes when it crosses a realm
1064 // boundary; these six are non-crossing view functions called straight
1065 // from the test's own frame, so their panic never crosses anything and
1066 // stays a plain panic. Every other refusal asserted in this file goes
1067 // through a crossing helper (drawAs, grantAs, …) and is correctly an
1068 // abort. Getting this backwards does not weaken the assertion — it
1069 // fails loudly, which is how it was found.
1070 uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { GrantInfo(999_999) })
1071 uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { GrantUsage(999_999) })
1072 uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { GrantLabel(999_999) })
1073 uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { Remaining(999_999) })
1074 uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { Drawable(999_999) })
1075 uassert.PanicsWithMessage(t, cur, "unknown grant id", func() { IsExpired(999_999) })
1076
1077 // The zero values of the non-panicking views are "nothing", not an
1078 // error, which is correct for an address that simply has no state.
1079 uassert.Equal(t, int64(0), PoolOf(mallory))
1080 uassert.Equal(t, int64(0), CreditOf(mallory))
1081 uassert.Equal(t, int64(0), GrantsOf(mallory))
1082}
1083
1084// --- render ---
1085
1086func TestRenderPages(cur realm, t *testing.T) {
1087 begin()
1088 fundAs(cur, owner1, MinFunding)
1089 h := Height()
1090 testing.SetRealm(testing.NewUserRealm(owner1))
1091 id := Grant(cross(cur), delegate1, "ops <script>alert(1)</script>",
1092 100_000, MinPeriodBlocks, 500_000, h+1_000)
1093 drawAs(cur, delegate1, id, 40_000)
1094
1095 index := Render("")
1096 uassert.True(t, strings.Contains(index, "# Budgets"))
1097 uassert.True(t, strings.Contains(index, "conservation: OK"))
1098 uassert.True(t, strings.Contains(index, "[live]"))
1099 uassert.True(t, strings.Contains(index, "#"+itoa(id)))
1100 // The free-text label reaches markdown only through the ecosystem
1101 // sanitizer, so the raw tag must not survive into the page.
1102 uassert.False(t, strings.Contains(index, "<script>"))
1103
1104 page := Render(itoa(id))
1105 uassert.True(t, strings.Contains(page, "# Grant #"+itoa(id)))
1106 uassert.True(t, strings.Contains(page, owner1.String()))
1107 uassert.True(t, strings.Contains(page, delegate1.String()))
1108 uassert.True(t, strings.Contains(page, "spent this window: 40000ugnot"))
1109 uassert.True(t, strings.Contains(page, "authorized by the terms: 60000ugnot"))
1110 uassert.True(t, strings.Contains(page, "drawable: 60000ugnot"))
1111 uassert.True(t, strings.Contains(page, "draw fee: 1%"))
1112 uassert.False(t, strings.Contains(page, "<script>"))
1113
1114 // The not-funded case is called by its name rather than shown as a
1115 // bare zero, because "authorized but unfunded" and "nothing
1116 // authorized" are different situations for a delegate.
1117 withdrawAllAs(cur, owner1)
1118 page = Render(itoa(id))
1119 uassert.True(t, strings.Contains(page, "authorized by the terms: 60000ugnot"))
1120 uassert.True(t, strings.Contains(page, "drawable: 0ugnot"))
1121 uassert.True(t, strings.Contains(page, "Authorized but **not funded**"))
1122
1123 // A queued loosening is disclosed on the page, with the terms that
1124 // actually govern until the boundary.
1125 testing.SetRealm(testing.NewUserRealm(owner1))
1126 uassert.False(t, Amend(cross(cur), id, 900_000, 0, 0))
1127 page = Render(itoa(id))
1128 uassert.True(t, strings.Contains(page, "A **looser** amendment is queued"))
1129
1130 // Expiry, and the keeper prompt that comes with it.
1131 testing.SkipHeights(1_000)
1132 page = Render(itoa(id))
1133 uassert.True(t, strings.Contains(page, "has **expired**"))
1134 uassert.True(t, strings.Contains(page, "Reap("+itoa(id)+")"))
1135 index = Render("")
1136 uassert.True(t, strings.Contains(index, "expired (reapable)"))
1137 uassert.True(t, strings.Contains(index, "## Keeper work"))
1138
1139 // Bad paths render a warning rather than panicking a page.
1140 uassert.True(t, strings.Contains(Render("not-a-number"), "invalid grant id"))
1141 uassert.True(t, strings.Contains(Render("999999"), "unknown grant id"))
1142
1143 testing.SetRealm(testing.NewUserRealm(mallory))
1144 Reap(cross(cur), id)
1145 claimAs(cur, delegate1)
1146 tidyFees(cur, t)
1147 uassert.True(t, strings.Contains(Render(""), "None yet"))
1148}
1149
1150func TestRenderIndexIsBounded(cur realm, t *testing.T) {
1151 begin()
1152 fundAs(cur, owner1, MinFunding)
1153 fundAs(cur, owner2, MinFunding)
1154
1155 ids := []int64{}
1156 for i := int64(0); i < int64(MaxRenderRows+2); i++ {
1157 from, to := owner1, delegate1
1158 if i >= MaxGrantsPerOwner {
1159 from, to = owner2, delegate2
1160 }
1161 ids = append(ids, grantAs(cur, from, to, MinPerPeriod, MinPeriodBlocks, 0, 0))
1162 }
1163 uassert.Equal(t, MaxRenderRows+2, len(ids))
1164
1165 index := Render("")
1166 uassert.True(t, strings.Contains(index, "Showing the 20 most recent of 22"))
1167 // Newest first, and the two oldest are off the page.
1168 uassert.True(t, strings.Contains(index, "#"+itoa(ids[len(ids)-1])))
1169 uassert.False(t, strings.Contains(index, "#"+itoa(ids[0])+"]"))
1170
1171 for i, id := range ids {
1172 from := owner1
1173 if int64(i) >= MaxGrantsPerOwner {
1174 from = owner2
1175 }
1176 revokeAs(cur, from, id)
1177 }
1178 withdrawAllAs(cur, owner1)
1179 withdrawAllAs(cur, owner2)
1180 assertSolvent(t)
1181}
1182
1183// --- the realm's own arithmetic ---
1184
1185func TestBpsRendersExactly(t *testing.T) {
1186 uassert.Equal(t, "1%", bps(100))
1187 uassert.Equal(t, "5%", bps(500))
1188 uassert.Equal(t, "0%", bps(0))
1189 uassert.Equal(t, "0.5%", bps(50))
1190 uassert.Equal(t, "0.05%", bps(5))
1191 uassert.Equal(t, "2.25%", bps(225))
1192 uassert.Equal(t, "100%", bps(10_000))
1193}
1194
1195func TestPadIDOrdersNumerically(t *testing.T) {
1196 uassert.True(t, padID(2) > padID(1))
1197 uassert.True(t, padID(10) > padID(9))
1198 uassert.True(t, padID(100) > padID(99))
1199 uassert.Equal(t, 20, len(padID(1)))
1200 uassert.Equal(t, 20, len(padID(1_000_000)))
1201}
1202
1203func TestCheckedAddRefusesOverflow(t *testing.T) {
1204 const max = int64(1<<63 - 1)
1205 sum, ok := checkedAdd(2, 3)
1206 uassert.True(t, ok)
1207 uassert.Equal(t, int64(5), sum)
1208
1209 _, ok = checkedAdd(max, 1)
1210 uassert.False(t, ok)
1211 _, ok = checkedAdd(max-1, 2)
1212 uassert.False(t, ok)
1213 sum, ok = checkedAdd(max-1, 1)
1214 uassert.True(t, ok)
1215 uassert.Equal(t, max, sum)
1216}
1217
1218// --- helpers used by the cases above ---
1219
1220// pendingOf unpacks PendingTerms without the four-value call site noise.
1221func pendingOf(id int64) (perPeriod, total int64, pending bool) {
1222 p, tot, _, has := PendingTerms(id)
1223 return p, tot, has
1224}
1225
1226// tidyFees drains the fee pot between cases so each test's fee
1227// assertions are absolute rather than cumulative. It is a test fixture,
1228// not a privileged path: WithdrawFees is permissionless and its
1229// destination is a compile-time constant, so there is nothing here a real
1230// caller could not also do.
1231func tidyFees(cur realm, t *testing.T) {
1232 if FeesAccrued() == 0 {
1233 return
1234 }
1235 testing.SetRealm(testing.NewUserRealm(mallory))
1236 WithdrawFees(cross(cur))
1237 uassert.Equal(t, int64(0), FeesAccrued())
1238}
1239
1240// TestRenderShowsGoverningTermsPastAnUnobservedBoundary pins the audit
1241// 2026-11-04 N1 repair. A queued loosening GOVERNS enforcement from the
1242// first boundary after it was queued -- spendbook's at() applies Pending
1243// whenever the window has rolled -- but the stored record still holds
1244// the old Terms until the next write. The grant page used to print the
1245// old per-window cap with a note claiming "the terms above are what
1246// govern": risk-understatement to the owner, on the same page whose
1247// "authorized by the terms" figure already used the pending cap. The
1248// page must derive its terms exactly as the gate does.
1249func TestRenderShowsGoverningTermsPastAnUnobservedBoundary(cur realm, t *testing.T) {
1250 ownr := testutils.TestAddress("n1-owner")
1251 spndr := testutils.TestAddress("n1-spender")
1252 fundAs(cur, ownr, 2_000_000)
1253 id := grantAs(cur, ownr, spndr, 1_000, MinPeriodBlocks, 0, 0)
1254
1255 // Queue a loosening: raised per-window cap. It must NOT govern yet.
1256 testing.SetRealm(testing.NewUserRealm(ownr))
1257 applied := Amend(cross(cur), id, 2_000, 0, 0)
1258 uassert.False(t, applied) // queued, not applied
1259 page := Render(itoa(id))
1260 if !strings.Contains(page, "per-window cap: 1000ugnot") ||
1261 !strings.Contains(page, "Until then the terms above are what govern") {
1262 t.Fatalf("pre-boundary page wrong:\n%s", page)
1263 }
1264
1265 // Cross the boundary with NO intervening write. The pending terms
1266 // now govern enforcement (at() applies them); the page must say so.
1267 testing.SkipHeights(MinPeriodBlocks)
1268 page = Render(itoa(id))
1269 if !strings.Contains(page, "per-window cap: 2000ugnot") {
1270 t.Fatalf("page still prints the superseded cap past the boundary:\n%s", page)
1271 }
1272 if strings.Contains(page, "Until then the terms above are what govern") {
1273 t.Fatalf("page still claims the stored terms govern past the boundary")
1274 }
1275}