revshare_test.gno
17.17 Kb · 531 lines
1package revshare
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)
14
15var (
16 testAdmin = testutils.TestAddress("rsadmin")
17 alice = testutils.TestAddress("rsalice")
18 bob = testutils.TestAddress("rsbob")
19 subber = testutils.TestAddress("rssubber")
20 subber2 = testutils.TestAddress("rssubber2")
21 mallory = testutils.TestAddress("rsmallory")
22)
23
24// Harness facts, carried from the sibling suites: init runs with an
25// empty origin (real deploys capture the signer); realm globals persist
26// across tests while the banker resets; SetRealm binds to the CALLING
27// frame only, so arming is inlined at every site; SkipHeights wipes the
28// origin context. NEW for a two-realm money suite: the banker reset
29// hits BOTH realms, so held-balance sync must restore revshare's
30// backing AND the downstream subscriptions realm's backing, or the
31// first cross-boundary banker send machine-panics the run.
32func init() {
33 admin = testAdmin
34 sweeper = testAdmin
35 self = chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/revshare")
36}
37
38func ugnot(n int64) chain.Coins { return chain.NewCoins(chain.NewCoin(Denom, n)) }
39
40func coinsOf(a address) int64 { return banker.NewReadonlyBanker().GetCoin(a, Denom) }
41
42// conserved asserts the local conservation equation at rest:
43// everything this realm holds is members' earned-but-unclaimed value.
44// (Out-of-band surplus tests manage their own expectations.)
45func conserved(t *testing.T) {
46 t.Helper()
47 if Held() != UsersTotal() {
48 t.Fatalf("conservation violated: Held=%d users=%d", Held(), UsersTotal())
49 }
50}
51
52// syncHeld restores BOTH realms' banker backing to match their
53// persistent ledgers after the harness banker reset.
54func syncHeld() {
55 if want, have := UsersTotal(), coinsOf(Address()); want > have {
56 testing.IssueCoins(Address(), ugnot(want-have))
57 }
58 if want, have := subs.Liabilities(), coinsOf(subs.Address()); want > have {
59 testing.IssueCoins(subs.Address(), ugnot(want-have))
60 }
61}
62
63// SetRealm binds to the CALLING frame only, so arming is inlined at
64// every call site — a helper that arms and returns is silently
65// ineffective (the sibling suites' documented trap).
66
67// subscribe drives the REAL subscriptions realm: who pays exactly
68// price into planID. Nothing is mocked.
69func subscribe(cur realm, t *testing.T, who address, planID, price int64) int64 {
70 t.Helper()
71 testing.SetRealm(testing.NewUserRealm(who))
72 testing.SetOriginCaller(who)
73 testing.SetOriginSend(ugnot(price))
74 testing.IssueCoins(subs.Address(), ugnot(price))
75 return subs.Subscribe(cross(cur), planID)
76}
77
78func TestInitialState(t *testing.T) {
79 if Admin() != testAdmin || MemberCount() != 0 || TotalWeight() != 0 ||
80 Pulled() != 0 || NumPlans() != 0 {
81 t.Fatal("unexpected initial state")
82 }
83 conserved(t)
84}
85
86// The no-members refusal must fire BEFORE any downstream call.
87func TestPullNoMembers(cur realm, t *testing.T) {
88 testing.SetRealm(testing.NewUserRealm(mallory))
89 testing.SetOriginCaller(mallory)
90 testing.SetOriginSend(nil)
91 uassert.AbortsWithMessage(t, cur, "no members configured to receive revenue", func() {
92 Pull(cross(cur))
93 })
94}
95
96func TestMembership(cur realm, t *testing.T) {
97 testing.SetRealm(testing.NewUserRealm(mallory))
98 testing.SetOriginCaller(mallory)
99 testing.SetOriginSend(nil)
100 uassert.AbortsWithMessage(t, cur, "admin only", func() {
101 AddMember(cross(cur), alice, 3)
102 })
103
104 testing.SetRealm(testing.NewUserRealm(testAdmin))
105 testing.SetOriginCaller(testAdmin)
106 testing.SetOriginSend(nil)
107 var zero address
108 uassert.AbortsContains(t, cur, "zero address", func() {
109 AddMember(cross(cur), zero, 1)
110 })
111 uassert.AbortsContains(t, cur, "weight must be in", func() {
112 AddMember(cross(cur), alice, 0)
113 })
114 uassert.AbortsContains(t, cur, "weight must be in", func() {
115 AddMember(cross(cur), alice, MaxWeight+1)
116 })
117
118 testing.SetRealm(testing.NewUserRealm(testAdmin))
119 testing.SetOriginCaller(testAdmin)
120 testing.SetOriginSend(nil)
121 AddMember(cross(cur), alice, 3)
122 testing.SetRealm(testing.NewUserRealm(testAdmin))
123 testing.SetOriginCaller(testAdmin)
124 testing.SetOriginSend(nil)
125 AddMember(cross(cur), bob, 1)
126
127 testing.SetRealm(testing.NewUserRealm(testAdmin))
128 testing.SetOriginCaller(testAdmin)
129 testing.SetOriginSend(nil)
130 uassert.AbortsWithMessage(t, cur, "already a member", func() {
131 AddMember(cross(cur), alice, 5)
132 })
133 uassert.AbortsWithMessage(t, cur, "not a member", func() {
134 SetWeight(cross(cur), mallory, 2)
135 })
136 uassert.AbortsContains(t, cur, "weight must be in", func() {
137 SetWeight(cross(cur), alice, 0)
138 })
139
140 if MemberCount() != 2 || TotalWeight() != 4 ||
141 MemberWeight(alice) != 3 || MemberWeight(bob) != 1 {
142 t.Fatal("membership state wrong")
143 }
144}
145
146// The provider flow across all three value boundaries, against the
147// REAL subscriptions realm.
148func TestProviderFlowAndPull(cur realm, t *testing.T) {
149 syncHeld()
150
151 // Plan creation is admin-gated HERE (quota griefing) before the
152 // downstream call.
153 testing.SetRealm(testing.NewUserRealm(mallory))
154 testing.SetOriginCaller(mallory)
155 testing.SetOriginSend(nil)
156 uassert.AbortsWithMessage(t, cur, "admin only", func() {
157 CreatePlan(cross(cur), "team", "d", 1000, 10, 1000)
158 })
159
160 testing.SetRealm(testing.NewUserRealm(testAdmin))
161 testing.SetOriginCaller(testAdmin)
162 testing.SetOriginSend(nil)
163 planID := CreatePlan(cross(cur), "team", "d", 1000, 10, 1000)
164 if NumPlans() != 1 || PlanID(0) != planID {
165 t.Fatal("plan bookkeeping wrong")
166 }
167
168 // Downstream truth: the PROVIDER of the plan is this realm.
169 provider, _, price, _, _, status, _ := subs.PlanInfo(planID)
170 if provider != Address() || price != 1000 || status != "active" {
171 t.Fatalf("downstream plan wrong: provider=%v price=%d status=%q",
172 provider, price, status)
173 }
174
175 // Boundary B1: subscriber pays the subscriptions realm directly.
176 subID := subscribe(cur, t, subber, planID, 1000)
177 if Receivable() != 1000 {
178 t.Fatalf("receivable=%d, want 1000", Receivable())
179 }
180 if Held() != 0 {
181 t.Fatal("nothing should be held here before Pull")
182 }
183 _ = subID
184
185 // Boundary B2: anyone may crank the pull (permissionless).
186 heldBefore := Held()
187 testing.SetRealm(testing.NewUserRealm(mallory))
188 testing.SetOriginCaller(mallory)
189 testing.SetOriginSend(nil)
190 got := Pull(cross(cur))
191 if got != 1000 || Held()-heldBefore != 1000 || Receivable() != 0 {
192 t.Fatalf("pull moved wrong value: got=%d held=%d receivable=%d",
193 got, Held(), Receivable())
194 }
195 // 3:1 split of 1000 -> 750 / 250, no dust.
196 if BalanceOf(alice) != 750 || BalanceOf(bob) != 250 || BalanceOf(mallory) != 0 {
197 t.Fatalf("split wrong: alice=%d bob=%d mallory=%d",
198 BalanceOf(alice), BalanceOf(bob), BalanceOf(mallory))
199 }
200 if Pulled() != 1000 || UsersTotal() != 1000 {
201 t.Fatal("lifetime accounting wrong")
202 }
203 conserved(t)
204}
205
206// Replay: the downstream realm holds the replay guard — an empty
207// receivable aborts there and no local state changes.
208func TestPullReplay(cur realm, t *testing.T) {
209 syncHeld()
210 usersBefore, pulledBefore := UsersTotal(), Pulled()
211 testing.SetRealm(testing.NewUserRealm(mallory))
212 testing.SetOriginCaller(mallory)
213 testing.SetOriginSend(nil)
214 uassert.AbortsWithMessage(t, cur, "nothing to claim", func() {
215 Pull(cross(cur))
216 })
217 if UsersTotal() != usersBefore || Pulled() != pulledBefore {
218 t.Fatal("aborted pull mutated state")
219 }
220 conserved(t)
221}
222
223// Rounding: dust goes to the highest-weight member, deterministically,
224// and the sum always equals the pulled amount exactly.
225func TestRoundingDust(cur realm, t *testing.T) {
226 syncHeld()
227 testing.SetRealm(testing.NewUserRealm(testAdmin))
228 testing.SetOriginCaller(testAdmin)
229 testing.SetOriginSend(nil)
230 planID := CreatePlan(cross(cur), "odd", "d", 1001, 10, 1000)
231 subscribe(cur, t, subber2, planID, 1001)
232
233 a0, b0 := BalanceOf(alice), BalanceOf(bob)
234 testing.SetRealm(testing.NewUserRealm(mallory))
235 testing.SetOriginCaller(mallory)
236 testing.SetOriginSend(nil)
237 Pull(cross(cur))
238 // 1001 at 3:1 -> alice floor 750 + dust 1 = 751, bob 250.
239 if BalanceOf(alice)-a0 != 751 || BalanceOf(bob)-b0 != 250 {
240 t.Fatalf("dust policy wrong: alice+%d bob+%d",
241 BalanceOf(alice)-a0, BalanceOf(bob)-b0)
242 }
243 conserved(t)
244}
245
246// Minimum values: a 1ugnot pull where every floor share is zero still
247// conserves — the whole amount is dust to the highest weight.
248func TestMinimumPull(cur realm, t *testing.T) {
249 syncHeld()
250 testing.SetRealm(testing.NewUserRealm(testAdmin))
251 testing.SetOriginCaller(testAdmin)
252 testing.SetOriginSend(nil)
253 planID := CreatePlan(cross(cur), "min", "d", 1, 10, 1000)
254 subscribe(cur, t, subber, planID, 1)
255
256 a0, b0 := BalanceOf(alice), BalanceOf(bob)
257 testing.SetRealm(testing.NewUserRealm(mallory))
258 testing.SetOriginCaller(mallory)
259 testing.SetOriginSend(nil)
260 got := Pull(cross(cur))
261 if got != 1 || BalanceOf(alice)-a0 != 1 || BalanceOf(bob)-b0 != 0 {
262 t.Fatal("minimum pull mishandled")
263 }
264 conserved(t)
265}
266
267// Weight changes shape FUTURE pulls only; earned balances never move.
268func TestWeightChangeFutureOnly(cur realm, t *testing.T) {
269 syncHeld()
270 a0, b0 := BalanceOf(alice), BalanceOf(bob)
271
272 testing.SetRealm(testing.NewUserRealm(testAdmin))
273 testing.SetOriginCaller(testAdmin)
274 testing.SetOriginSend(nil)
275 SetWeight(cross(cur), bob, 3)
276 if BalanceOf(alice) != a0 || BalanceOf(bob) != b0 {
277 t.Fatal("weight change touched earned balances")
278 }
279 if TotalWeight() != 6 {
280 t.Fatalf("totalWeight=%d, want 6", TotalWeight())
281 }
282
283 testing.SetRealm(testing.NewUserRealm(testAdmin))
284 testing.SetOriginCaller(testAdmin)
285 testing.SetOriginSend(nil)
286 planID := CreatePlan(cross(cur), "even", "d", 600, 10, 1000)
287 subscribe(cur, t, subber2, planID, 600)
288 testing.SetRealm(testing.NewUserRealm(mallory))
289 testing.SetOriginCaller(mallory)
290 testing.SetOriginSend(nil)
291 Pull(cross(cur))
292 // 600 at 3:3 -> 300 each.
293 if BalanceOf(alice)-a0 != 300 || BalanceOf(bob)-b0 != 300 {
294 t.Fatal("new weights not applied to new revenue")
295 }
296 conserved(t)
297}
298
299// Boundary B3: members pull their earnings out; strangers get nothing.
300func TestClaims(cur realm, t *testing.T) {
301 syncHeld()
302 earned := BalanceOf(alice)
303 if earned == 0 {
304 t.Fatal("fixture: alice should have earnings")
305 }
306 before := coinsOf(alice)
307 testing.SetRealm(testing.NewUserRealm(alice))
308 testing.SetOriginCaller(alice)
309 testing.SetOriginSend(nil)
310 ClaimAll(cross(cur))
311 if coinsOf(alice)-before != earned || BalanceOf(alice) != 0 {
312 t.Fatal("claim did not move the earned balance")
313 }
314
315 bobBefore, bobCoins := BalanceOf(bob), coinsOf(bob)
316 testing.SetRealm(testing.NewUserRealm(bob))
317 testing.SetOriginCaller(bob)
318 testing.SetOriginSend(nil)
319 Claim(cross(cur), 100)
320 if BalanceOf(bob) != bobBefore-100 || coinsOf(bob)-bobCoins != 100 {
321 t.Fatal("partial claim wrong")
322 }
323 testing.SetRealm(testing.NewUserRealm(bob))
324 testing.SetOriginCaller(bob)
325 testing.SetOriginSend(nil)
326 uassert.AbortsContains(t, cur, "insufficient balance", func() {
327 Claim(cross(cur), BalanceOf(bob)+1)
328 })
329
330 testing.SetRealm(testing.NewUserRealm(mallory))
331 testing.SetOriginCaller(mallory)
332 testing.SetOriginSend(nil)
333 uassert.AbortsWithMessage(t, cur, "nothing to claim", func() {
334 ClaimAll(cross(cur))
335 })
336 conserved(t)
337}
338
339// Removal never touches earned value: the ex-member keeps claiming.
340func TestRemoveMemberKeepsBalance(cur realm, t *testing.T) {
341 syncHeld()
342 bobBal := BalanceOf(bob)
343 if bobBal == 0 {
344 t.Fatal("fixture: bob should still have a balance")
345 }
346 testing.SetRealm(testing.NewUserRealm(testAdmin))
347 testing.SetOriginCaller(testAdmin)
348 testing.SetOriginSend(nil)
349 RemoveMember(cross(cur), bob)
350 if MemberCount() != 1 || TotalWeight() != 3 || BalanceOf(bob) != bobBal {
351 t.Fatal("removal corrupted state")
352 }
353
354 // Future revenue goes entirely to the remaining member.
355 testing.SetRealm(testing.NewUserRealm(testAdmin))
356 testing.SetOriginCaller(testAdmin)
357 testing.SetOriginSend(nil)
358 planID := CreatePlan(cross(cur), "solo", "d", 500, 10, 1000)
359 subscribe(cur, t, subber, planID, 500)
360 a0 := BalanceOf(alice)
361 testing.SetRealm(testing.NewUserRealm(mallory))
362 testing.SetOriginCaller(mallory)
363 testing.SetOriginSend(nil)
364 Pull(cross(cur))
365 if BalanceOf(alice)-a0 != 500 || BalanceOf(bob) != bobBal {
366 t.Fatal("post-removal distribution wrong")
367 }
368
369 // The ex-member's balance remains claimable.
370 before := coinsOf(bob)
371 testing.SetRealm(testing.NewUserRealm(bob))
372 testing.SetOriginCaller(bob)
373 testing.SetOriginSend(nil)
374 ClaimAll(cross(cur))
375 if coinsOf(bob)-before != bobBal {
376 t.Fatal("ex-member could not claim")
377 }
378 conserved(t)
379}
380
381// Plan retirement crosses the boundary admin-gated here, provider-only
382// downstream.
383func TestRetirePlan(cur realm, t *testing.T) {
384 syncHeld()
385 planID := PlanID(0)
386 testing.SetRealm(testing.NewUserRealm(mallory))
387 testing.SetOriginCaller(mallory)
388 testing.SetOriginSend(nil)
389 uassert.AbortsWithMessage(t, cur, "admin only", func() {
390 RetirePlan(cross(cur), planID)
391 })
392 testing.SetRealm(testing.NewUserRealm(testAdmin))
393 testing.SetOriginCaller(testAdmin)
394 testing.SetOriginSend(nil)
395 RetirePlan(cross(cur), planID)
396 _, _, _, _, _, status, _ := subs.PlanInfo(planID)
397 if status != "retired" {
398 t.Fatalf("downstream not retired: %q", status)
399 }
400 // Downstream refuses new subscriptions to a retired plan.
401 testing.SetRealm(testing.NewUserRealm(mallory))
402 testing.SetOriginCaller(mallory)
403 testing.SetOriginSend(ugnot(1000))
404 testing.IssueCoins(subs.Address(), ugnot(1000))
405 uassert.AbortsContains(t, cur, "plan is not active", func() {
406 subs.Subscribe(cross(cur), planID)
407 })
408}
409
410// Every entrypoint refuses coin-carrying transactions.
411func TestStraySend(cur realm, t *testing.T) {
412 for _, f := range []func(){
413 func() { Pull(cross(cur)) },
414 func() { ClaimAll(cross(cur)) },
415 func() { AddMember(cross(cur), mallory, 1) },
416 func() { CreatePlan(cross(cur), "x", "d", 1000, 10, 1000) },
417 func() { SweepDenom(cross(cur), Denom) },
418 } {
419 testing.SetRealm(testing.NewUserRealm(testAdmin))
420 testing.SetOriginCaller(testAdmin)
421 testing.SetOriginSend(ugnot(5))
422 uassert.AbortsWithMessage(t, cur, "this function does not accept coins", f)
423 }
424}
425
426// Out-of-band value: the reserve protects member balances; foreign
427// denominations sweep in full.
428func TestSweepReserve(cur realm, t *testing.T) {
429 syncHeld()
430 testing.SetRealm(testing.NewUserRealm(testAdmin))
431 testing.SetOriginCaller(testAdmin)
432 testing.SetOriginSend(nil)
433 uassert.AbortsContains(t, cur, "no surplus to sweep", func() {
434 SweepDenom(cross(cur), Denom)
435 })
436
437 testing.IssueCoins(Address(), ugnot(77))
438 reserveBefore := UsersTotal()
439 got := coinsOf(testAdmin)
440 testing.SetRealm(testing.NewUserRealm(testAdmin))
441 testing.SetOriginCaller(testAdmin)
442 testing.SetOriginSend(nil)
443 SweepDenom(cross(cur), Denom)
444 if coinsOf(testAdmin)-got != 77 || UsersTotal() != reserveBefore {
445 t.Fatal("sweep wrong or touched the reserve")
446 }
447
448 testing.IssueCoins(Address(), chain.NewCoins(chain.NewCoin("foo", 9)))
449 testing.SetRealm(testing.NewUserRealm(testAdmin))
450 testing.SetOriginCaller(testAdmin)
451 testing.SetOriginSend(nil)
452 SweepDenom(cross(cur), "foo")
453 if banker.NewReadonlyBanker().GetCoin(testAdmin, "foo") != 9 {
454 t.Fatal("foreign denom not swept in full")
455 }
456
457 testing.SetRealm(testing.NewUserRealm(mallory))
458 testing.SetOriginCaller(mallory)
459 testing.SetOriginSend(nil)
460 uassert.AbortsWithMessage(t, cur, "sweeper only", func() {
461 SweepDenom(cross(cur), Denom)
462 })
463 conserved(t)
464}
465
466func TestAdminHandover(cur realm, t *testing.T) {
467 testing.SetRealm(testing.NewUserRealm(testAdmin))
468 testing.SetOriginCaller(testAdmin)
469 testing.SetOriginSend(nil)
470 TransferAdmin(cross(cur), alice)
471 testing.SetRealm(testing.NewUserRealm(mallory))
472 testing.SetOriginCaller(mallory)
473 testing.SetOriginSend(nil)
474 uassert.AbortsWithMessage(t, cur, "only the staged successor may accept", func() {
475 AcceptAdmin(cross(cur))
476 })
477 testing.SetRealm(testing.NewUserRealm(alice))
478 testing.SetOriginCaller(alice)
479 testing.SetOriginSend(nil)
480 AcceptAdmin(cross(cur))
481 if Admin() != alice {
482 t.Fatal("authority did not move")
483 }
484 // Former admin locked out of members, plans and sweep.
485 testing.SetRealm(testing.NewUserRealm(testAdmin))
486 testing.SetOriginCaller(testAdmin)
487 testing.SetOriginSend(nil)
488 uassert.AbortsWithMessage(t, cur, "admin only", func() {
489 AddMember(cross(cur), mallory, 1)
490 })
491 uassert.AbortsWithMessage(t, cur, "sweeper only", func() {
492 SweepDenom(cross(cur), Denom)
493 })
494 // Hand back.
495 testing.SetRealm(testing.NewUserRealm(alice))
496 testing.SetOriginCaller(alice)
497 testing.SetOriginSend(nil)
498 TransferAdmin(cross(cur), testAdmin)
499 testing.SetRealm(testing.NewUserRealm(testAdmin))
500 testing.SetOriginCaller(testAdmin)
501 testing.SetOriginSend(nil)
502 AcceptAdmin(cross(cur))
503}
504
505func TestMemberTableBounds(cur realm, t *testing.T) {
506 testing.SetRealm(testing.NewUserRealm(testAdmin))
507 testing.SetOriginCaller(testAdmin)
508 testing.SetOriginSend(nil)
509 for i := MemberCount(); i < int64(MaxMembers); i++ {
510 testing.SetRealm(testing.NewUserRealm(testAdmin))
511 testing.SetOriginCaller(testAdmin)
512 testing.SetOriginSend(nil)
513 AddMember(cross(cur), testutils.TestAddress("rsfill"+string(rune('a'+i))), 1)
514 }
515 testing.SetRealm(testing.NewUserRealm(testAdmin))
516 testing.SetOriginCaller(testAdmin)
517 testing.SetOriginSend(nil)
518 uassert.AbortsContains(t, cur, "member table is full", func() {
519 AddMember(cross(cur), testutils.TestAddress("rsoverflow"), 1)
520 })
521}
522
523func TestRender(t *testing.T) {
524 out := Render("")
525 if !strings.Contains(out, "receivable") || !strings.Contains(out, "| member |") {
526 t.Fatal("render missing sections")
527 }
528 if Render("x") == Render("") {
529 t.Fatal("unknown path fell through")
530 }
531}