upkeep_test.gno
14.30 Kb · 452 lines
1package upkeep
2
3import (
4 "chain"
5 "chain/banker"
6 "strings"
7 "testing"
8
9 "gno.land/p/nt/testutils/v0"
10 "gno.land/p/nt/uassert/v0"
11
12 subs "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/subscriptions"
13 guardian "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/timelock_guardian"
14)
15
16var (
17 testAdmin = testutils.TestAddress("ukadmin")
18 funder = testutils.TestAddress("ukfunder")
19 poker = testutils.TestAddress("ukpoker")
20 mallory = testutils.TestAddress("ukmallory")
21 provider = testutils.TestAddress("ukprov")
22 subber = testutils.TestAddress("uksubber")
23 towner = testutils.TestAddress("uktowner")
24)
25
26// Harness facts, carried from the sibling suites: init runs with an
27// empty origin (real deploys capture the signer); realm globals persist
28// across tests while the banker resets; SetRealm binds to the CALLING
29// frame only, so arming is inlined at every site; SkipHeights advances
30// the clock 5s per height AND wipes the origin context.
31func init() {
32 admin = testAdmin
33 sweeper = testAdmin
34 self = chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/upkeep")
35}
36
37func ugnot(n int64) chain.Coins { return chain.NewCoins(chain.NewCoin(Denom, n)) }
38
39func coinsOf(a address) int64 { return banker.NewReadonlyBanker().GetCoin(a, Denom) }
40
41// conserved asserts the realm's monetary invariant: everything the realm
42// holds is either unspent pot or earned-but-unclaimed balance.
43func conserved(t *testing.T) {
44 t.Helper()
45 if Held() != Pot()+UsersTotal() {
46 t.Fatalf("conservation violated: Held=%d pot=%d users=%d",
47 Held(), Pot(), UsersTotal())
48 }
49}
50
51// syncHeld re-issues the realm's expected holdings. The harness banker
52// resets between tests while realm globals (pot, ledger) persist, so
53// each money-touching test starts by making the banker match the books.
54func syncHeld() {
55 want := Pot() + UsersTotal()
56 have := coinsOf(Address())
57 if want > have {
58 testing.IssueCoins(Address(), ugnot(want-have))
59 }
60}
61
62func fundAs(cur realm, from address, amount int64) {
63 testing.SetRealm(testing.NewUserRealm(from))
64 testing.SetOriginCaller(from)
65 testing.SetOriginSend(ugnot(amount))
66 testing.IssueCoins(Address(), ugnot(amount))
67 Fund(cross(cur))
68}
69
70func adminSet(cur realm, task string, amount int64) {
71 testing.SetRealm(testing.NewUserRealm(testAdmin))
72 testing.SetOriginCaller(testAdmin)
73 testing.SetOriginSend(nil)
74 SetReward(cross(cur), task, amount)
75}
76
77// expirableSub drives the REAL subscriptions realm to a state where one
78// subscription is past its grace window, and returns its id. This is
79// the downstream fixture for the realm-to-realm tests: nothing is
80// mocked.
81func expirableSub(cur realm, t *testing.T) int64 {
82 t.Helper()
83 testing.SetRealm(testing.NewUserRealm(provider))
84 testing.SetOriginCaller(provider)
85 testing.SetOriginSend(nil)
86 planID := subs.CreatePlan(cross(cur), "upkeep-fixture", "d", 1, 10, 1000)
87
88 testing.SetRealm(testing.NewUserRealm(subber))
89 testing.SetOriginCaller(subber)
90 testing.SetOriginSend(ugnot(1))
91 testing.IssueCoins(subs.Address(), ugnot(1))
92 subID := subs.Subscribe(cross(cur), planID)
93
94 // paidThrough = h+10; expirable at paidThrough+10. Skip well past.
95 testing.SkipHeights(25)
96 return subID
97}
98
99// maturedAction drives the REAL guardian realm to a matured timelocked
100// action and returns its id.
101var targetSeq int
102
103func maturedAction(cur realm, t *testing.T) string {
104 t.Helper()
105 targetSeq++
106 name := "uktarget" + string(rune('a'+targetSeq%26))
107 testing.SetRealm(testing.NewUserRealm(towner))
108 testing.SetOriginCaller(towner)
109 testing.SetOriginSend(nil)
110 guardian.RegisterTarget(cross(cur), name, guardian.MinDelayFloor, "")
111
112 testing.SetRealm(testing.NewUserRealm(towner))
113 testing.SetOriginCaller(towner)
114 testing.SetOriginSend(nil)
115 id := guardian.Schedule(cross(cur), name, "poke-fixture", guardian.MinDelayFloor)
116
117 testing.SkipHeights(13) // 65s at 5s/height >= the 60s floor
118 return id
119}
120
121func TestInitialState(t *testing.T) {
122 if Admin() != testAdmin || Pot() != 0 || Pokes() != 0 || Funded() != 0 {
123 t.Fatal("unexpected initial state")
124 }
125 if RewardFor(TaskSubExpire) != 0 || RewardFor(TaskTimelockExecute) != 0 {
126 t.Fatal("rewards must default to 0")
127 }
128 conserved(t)
129}
130
131func TestFundAndRewardConfig(cur realm, t *testing.T) {
132 fundAs(cur, funder, 100000)
133 if Pot() != 100000 || Funded() != 100000 {
134 t.Fatalf("pot=%d funded=%d", Pot(), Funded())
135 }
136 conserved(t)
137
138 testing.SetRealm(testing.NewUserRealm(mallory))
139 testing.SetOriginCaller(mallory)
140 testing.SetOriginSend(nil)
141 uassert.AbortsWithMessage(t, cur, "admin only", func() {
142 SetReward(cross(cur), TaskSubExpire, 100)
143 })
144
145 testing.SetRealm(testing.NewUserRealm(testAdmin))
146 testing.SetOriginCaller(testAdmin)
147 testing.SetOriginSend(nil)
148 uassert.AbortsContains(t, cur, "reward must be in", func() {
149 SetReward(cross(cur), TaskSubExpire, MaxReward+1)
150 })
151 uassert.AbortsContains(t, cur, "unknown task", func() {
152 SetReward(cross(cur), "nosuch", 1)
153 })
154
155 adminSet(cur, TaskSubExpire, 5000)
156 adminSet(cur, TaskTimelockExecute, 7000)
157 if RewardFor(TaskSubExpire) != 5000 || RewardFor(TaskTimelockExecute) != 7000 {
158 t.Fatal("rewards not set")
159 }
160}
161
162// The realm-to-realm happy path: poke the REAL subscriptions realm.
163func TestPokeExpire(cur realm, t *testing.T) {
164 syncHeld()
165 subID := expirableSub(cur, t)
166
167 // Downstream state before: still Active.
168 _, _, _, _, _, _, status := subs.SubInfo(subID)
169 if status != "active" {
170 t.Fatalf("fixture status %q", status)
171 }
172
173 potBefore, balBefore := Pot(), BalanceOf(poker)
174 testing.SetRealm(testing.NewUserRealm(poker))
175 testing.SetOriginCaller(poker)
176 testing.SetOriginSend(nil)
177 PokeExpire(cross(cur), subID)
178
179 // Downstream state changed by OUR realm's crossing call...
180 _, _, _, _, _, _, status = subs.SubInfo(subID)
181 if status != "expired" {
182 t.Fatalf("downstream not expired: %q", status)
183 }
184 // ...and the reward moved pot -> caller balance atomically.
185 if Pot() != potBefore-5000 || BalanceOf(poker) != balBefore+5000 {
186 t.Fatalf("reward accounting wrong: pot %d->%d bal %d->%d",
187 potBefore, Pot(), balBefore, BalanceOf(poker))
188 }
189 if Pokes() != 1 {
190 t.Fatalf("pokes=%d", Pokes())
191 }
192 conserved(t)
193}
194
195// Replay: the downstream realm is the replay guard; a second poke on the
196// same subscription aborts THERE and no reward is paid HERE.
197func TestPokeExpireReplay(cur realm, t *testing.T) {
198 syncHeld()
199 potBefore, balBefore := Pot(), BalanceOf(mallory)
200 testing.SetRealm(testing.NewUserRealm(mallory))
201 testing.SetOriginCaller(mallory)
202 testing.SetOriginSend(nil)
203 uassert.AbortsContains(t, cur, "subscription is expired", func() {
204 PokeExpire(cross(cur), 0) // the sub expired in TestPokeExpire
205 })
206 if Pot() != potBefore || BalanceOf(mallory) != balBefore || Pokes() != 1 {
207 t.Fatal("aborted poke mutated reward state")
208 }
209 conserved(t)
210}
211
212// A premature poke aborts downstream ("not expirable") and pays nothing.
213func TestPokeExpirePremature(cur realm, t *testing.T) {
214 syncHeld()
215 testing.SetRealm(testing.NewUserRealm(provider))
216 testing.SetOriginCaller(provider)
217 testing.SetOriginSend(nil)
218 planID := subs.CreatePlan(cross(cur), "fresh", "d", 1, 5000, 1000)
219 testing.SetRealm(testing.NewUserRealm(subber))
220 testing.SetOriginCaller(subber)
221 testing.SetOriginSend(ugnot(1))
222 testing.IssueCoins(subs.Address(), ugnot(1))
223 freshID := subs.Subscribe(cross(cur), planID)
224
225 potBefore := Pot()
226 testing.SetRealm(testing.NewUserRealm(poker))
227 testing.SetOriginCaller(poker)
228 testing.SetOriginSend(nil)
229 uassert.AbortsContains(t, cur, "not expirable", func() {
230 PokeExpire(cross(cur), freshID)
231 })
232 uassert.AbortsContains(t, cur, "unknown subscription id", func() {
233 PokeExpire(cross(cur), 99999)
234 })
235 if Pot() != potBefore {
236 t.Fatal("failed pokes touched the pot")
237 }
238 conserved(t)
239}
240
241// The second realm-to-realm integration: the REAL guardian realm.
242func TestPokeExecute(cur realm, t *testing.T) {
243 syncHeld()
244 id := maturedAction(cur, t)
245 potBefore, balBefore := Pot(), BalanceOf(poker)
246
247 testing.SetRealm(testing.NewUserRealm(poker))
248 testing.SetOriginCaller(poker)
249 testing.SetOriginSend(nil)
250 PokeExecute(cross(cur), id)
251
252 if Pot() != potBefore-7000 || BalanceOf(poker) != balBefore+7000 {
253 t.Fatal("timelock reward accounting wrong")
254 }
255 // Replay refused downstream.
256 testing.SetRealm(testing.NewUserRealm(mallory))
257 testing.SetOriginCaller(mallory)
258 testing.SetOriginSend(nil)
259 uassert.AbortsContains(t, cur, "already executed", func() {
260 PokeExecute(cross(cur), id)
261 })
262 uassert.AbortsContains(t, cur, "action not found", func() {
263 PokeExecute(cross(cur), "tl-nosuch")
264 })
265 // Premature: schedule a new one, poke before maturity.
266 testing.SetRealm(testing.NewUserRealm(towner))
267 testing.SetOriginCaller(towner)
268 testing.SetOriginSend(nil)
269 guardian.RegisterTarget(cross(cur), "ukearly", guardian.MinDelayFloor, "")
270 testing.SetRealm(testing.NewUserRealm(towner))
271 testing.SetOriginCaller(towner)
272 testing.SetOriginSend(nil)
273 early := guardian.Schedule(cross(cur), "ukearly", "x", guardian.MinDelayFloor)
274 testing.SetRealm(testing.NewUserRealm(poker))
275 testing.SetOriginCaller(poker)
276 testing.SetOriginSend(nil)
277 uassert.AbortsContains(t, cur, "too early", func() {
278 PokeExecute(cross(cur), early)
279 })
280 conserved(t)
281}
282
283// Pot exhaustion refuses BEFORE the downstream call: no valve fires
284// unrewarded through this realm. Entering here the pot is deterministic:
285// 100000 funded minus the 5000 and 7000 awarded above = 88000, so four
286// MaxReward pokes leave 8000 < MaxReward and the next must refuse.
287func TestPotExhaustion(cur realm, t *testing.T) {
288 syncHeld()
289 adminSet(cur, TaskSubExpire, MaxReward)
290
291 // Drain by REAL pokes until the pot no longer covers the reward.
292 for i := 0; Pot() >= MaxReward; i++ {
293 if i >= 10 {
294 t.Fatalf("fixture arithmetic broken: pot=%d after %d pokes", Pot(), i)
295 }
296 id := expirableSub(cur, t)
297 testing.SetRealm(testing.NewUserRealm(poker))
298 testing.SetOriginCaller(poker)
299 testing.SetOriginSend(nil)
300 PokeExpire(cross(cur), id)
301 }
302
303 short := expirableSub(cur, t)
304 potBefore := Pot()
305 testing.SetRealm(testing.NewUserRealm(poker))
306 testing.SetOriginCaller(poker)
307 testing.SetOriginSend(nil)
308 uassert.AbortsContains(t, cur, "reward pot cannot cover", func() {
309 PokeExpire(cross(cur), short)
310 })
311 // Downstream MUST be untouched by the refused poke.
312 _, _, _, _, _, _, status := subs.SubInfo(short)
313 if status != "active" {
314 t.Fatal("refused poke reached the downstream realm")
315 }
316 if Pot() != potBefore {
317 t.Fatal("refused poke touched the pot")
318 }
319 conserved(t)
320 adminSet(cur, TaskSubExpire, 5000) // restore for the later tests
321}
322
323// The refusal boundary, deterministic: unknown task, zero reward, and
324// pot-short all refuse before any downstream effect.
325func TestPokeRefusals(cur realm, t *testing.T) {
326 adminSet(cur, TaskSubExpire, 0)
327 testing.SetRealm(testing.NewUserRealm(poker))
328 testing.SetOriginCaller(poker)
329 testing.SetOriginSend(nil)
330 uassert.AbortsContains(t, cur, "no reward configured", func() {
331 PokeExpire(cross(cur), 0)
332 })
333 adminSet(cur, TaskSubExpire, 5000)
334
335 testing.SetRealm(testing.NewUserRealm(poker))
336 testing.SetOriginCaller(poker)
337 testing.SetOriginSend(ugnot(5))
338 uassert.AbortsWithMessage(t, cur, "this function does not accept coins", func() {
339 PokeExpire(cross(cur), 0)
340 })
341 testing.SetRealm(testing.NewUserRealm(poker))
342 testing.SetOriginCaller(poker)
343 testing.SetOriginSend(ugnot(5))
344 uassert.AbortsWithMessage(t, cur, "this function does not accept coins", func() {
345 PokeExecute(cross(cur), "x")
346 })
347}
348
349func TestClaims(cur realm, t *testing.T) {
350 syncHeld()
351 earned := BalanceOf(poker)
352 if earned == 0 {
353 t.Fatal("fixture: poker should have earnings")
354 }
355 before := coinsOf(poker)
356 testing.SetRealm(testing.NewUserRealm(poker))
357 testing.SetOriginCaller(poker)
358 testing.SetOriginSend(nil)
359 ClaimAll(cross(cur))
360 if coinsOf(poker)-before != earned || BalanceOf(poker) != 0 {
361 t.Fatal("claim did not move the earned balance")
362 }
363 testing.SetRealm(testing.NewUserRealm(mallory))
364 testing.SetOriginCaller(mallory)
365 testing.SetOriginSend(nil)
366 uassert.AbortsWithMessage(t, cur, "nothing to claim", func() {
367 ClaimAll(cross(cur))
368 })
369 uassert.AbortsContains(t, cur, "insufficient balance", func() {
370 Claim(cross(cur), 1)
371 })
372 conserved(t)
373}
374
375func TestSweepReserve(cur realm, t *testing.T) {
376 syncHeld()
377 testing.SetRealm(testing.NewUserRealm(testAdmin))
378 testing.SetOriginCaller(testAdmin)
379 testing.SetOriginSend(nil)
380 uassert.AbortsContains(t, cur, "no surplus to sweep", func() {
381 SweepDenom(cross(cur), Denom)
382 })
383 testing.IssueCoins(Address(), ugnot(77))
384 reserveBefore := Pot() + UsersTotal()
385 got := coinsOf(testAdmin)
386 testing.SetRealm(testing.NewUserRealm(testAdmin))
387 testing.SetOriginCaller(testAdmin)
388 testing.SetOriginSend(nil)
389 SweepDenom(cross(cur), Denom)
390 if coinsOf(testAdmin)-got != 77 {
391 t.Fatal("sweep amount wrong")
392 }
393 if Pot()+UsersTotal() != reserveBefore {
394 t.Fatal("sweep touched the reserve")
395 }
396 testing.SetRealm(testing.NewUserRealm(mallory))
397 testing.SetOriginCaller(mallory)
398 testing.SetOriginSend(nil)
399 uassert.AbortsWithMessage(t, cur, "sweeper only", func() {
400 SweepDenom(cross(cur), Denom)
401 })
402 conserved(t)
403}
404
405func TestAdminHandover(cur realm, t *testing.T) {
406 testing.SetRealm(testing.NewUserRealm(testAdmin))
407 testing.SetOriginCaller(testAdmin)
408 testing.SetOriginSend(nil)
409 TransferAdmin(cross(cur), funder)
410 testing.SetRealm(testing.NewUserRealm(mallory))
411 testing.SetOriginCaller(mallory)
412 testing.SetOriginSend(nil)
413 uassert.AbortsWithMessage(t, cur, "only the staged successor may accept", func() {
414 AcceptAdmin(cross(cur))
415 })
416 testing.SetRealm(testing.NewUserRealm(funder))
417 testing.SetOriginCaller(funder)
418 testing.SetOriginSend(nil)
419 AcceptAdmin(cross(cur))
420 if Admin() != funder {
421 t.Fatal("authority did not move")
422 }
423 // Former admin locked out; sweeper moved with admin.
424 testing.SetRealm(testing.NewUserRealm(testAdmin))
425 testing.SetOriginCaller(testAdmin)
426 testing.SetOriginSend(nil)
427 uassert.AbortsWithMessage(t, cur, "admin only", func() {
428 SetReward(cross(cur), TaskSubExpire, 1)
429 })
430 uassert.AbortsWithMessage(t, cur, "sweeper only", func() {
431 SweepDenom(cross(cur), Denom)
432 })
433 // Hand back.
434 testing.SetRealm(testing.NewUserRealm(funder))
435 testing.SetOriginCaller(funder)
436 testing.SetOriginSend(nil)
437 TransferAdmin(cross(cur), testAdmin)
438 testing.SetRealm(testing.NewUserRealm(testAdmin))
439 testing.SetOriginCaller(testAdmin)
440 testing.SetOriginSend(nil)
441 AcceptAdmin(cross(cur))
442}
443
444func TestRender(t *testing.T) {
445 out := Render("")
446 if !strings.Contains(out, "sub_expire") || !strings.Contains(out, "timelock_execute") {
447 t.Fatal("render missing task lines")
448 }
449 if Render("x") == Render("") {
450 t.Fatal("unknown path fell through")
451 }
452}