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

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}