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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

payout_test.gno

11.64 Kb · 297 lines
  1package bolo
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"testing"
  7
  8	"gno.land/p/nt/seqid/v0"
  9	"gno.land/p/nt/testutils/v0"
 10)
 11
 12// checkBooks holds c's books against the bank (INV-1) and against its own records:
 13// the contributions add up to Raised and Refunded. Money tests run it after every
 14// step.
 15func checkBooks(t *testing.T, c *campaign) {
 16	s := c.state
 17	if trackedBalance(c) < 0 {
 18		t.Errorf("campaign %d: balance %d, below 0 (INV-2)", uint64(c.id), trackedBalance(c))
 19	}
 20	if bank := banker.NewReadonlyBanker().GetCoin(escrowAddr(c.id), "ugnot"); bank < trackedBalance(c) {
 21		t.Errorf("campaign %d: books %d, bank %d (INV-1)", uint64(c.id), trackedBalance(c), bank)
 22	}
 23	if c.mode == modeDirect && trackedBalance(c) != 0 {
 24		t.Errorf("campaign %d, direct: balance %d (INV-2)", uint64(c.id), trackedBalance(c))
 25	}
 26	var amount, refunded int64
 27	c.contributions.Iterate("", "", func(_ string, v any) bool {
 28		amount += v.(*contribution).amount
 29		refunded += v.(*contribution).refunded
 30		return false
 31	})
 32	if amount != s.raised || refunded != s.refunded {
 33		t.Errorf("campaign %d: contributions %d given, %d refunded; books %d, %d", uint64(c.id), amount, refunded, s.raised, s.refunded)
 34	}
 35}
 36
 37// fund records a gift of amount by donor in c's books and issues the coins to its
 38// escrow, as Donate leaves them, without a crossing call.
 39func fund(c *campaign, donor address, amount int64) {
 40	k := contributionOf(c, donor)
 41	if k == nil {
 42		k = &contribution{}
 43		c.contributions.Set(donor.String(), k)
 44		c.state.donorCount++
 45	}
 46	k.amount += amount
 47	c.state.raised += amount
 48	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", amount)))
 49}
 50
 51// The helpers' tests come first: a write's test that panics through a crossing call
 52// ends the package's unit tests.
 53
 54func TestCheckWithdrawable(t *testing.T) {
 55	c := testCampaign(9401, modeEscrow, 0)
 56	checkWithdrawable(c)
 57	c.state.status, c.state.closedAt = statusClosed, now()-c.params.ExitDelay+1
 58	checkWithdrawable(c) // closed, a second before the exit window
 59	for name, k := range map[string]struct {
 60		mode, status string
 61		closedAt     int64
 62		err          Error
 63	}{
 64		"direct":               {modeDirect, statusOpen, 0, ErrWrongMode},
 65		"direct, and settled":  {modeDirect, statusSettled, 0, ErrWrongMode},
 66		"settled":              {modeEscrow, statusSettled, 0, ErrCampaignSettled},
 67		"exit window open":     {modeEscrow, statusClosed, now() - c.params.ExitDelay, ErrExitOpen},
 68		"long past the window": {modeEscrow, statusClosed, 1, ErrExitOpen},
 69	} {
 70		c.mode, c.state.status, c.state.closedAt = k.mode, k.status, k.closedAt
 71		if !panicsWith(func() { checkWithdrawable(c) }, k.err) {
 72			t.Errorf("%s: not %s", name, k.err)
 73		}
 74	}
 75}
 76
 77func TestCheckRefund(t *testing.T) {
 78	donor, stranger := testutils.TestAddress("donor"), testutils.TestAddress("stranger")
 79	c := testCampaign(9402, modeEscrow, 0)
 80	c.contributions.Set(donor.String(), &contribution{amount: 700, refunded: 200})
 81	k, window, amount := checkRefund(c, donor)
 82	if k.amount != 700 || window != windowOpen || amount != 500 {
 83		t.Errorf("checkRefund = %v, %q, %d", *k, window, amount)
 84	}
 85	if !panicsWith(func() { checkRefund(c, stranger) }, ErrNothingToRefund) {
 86		t.Errorf("a stranger: not ErrNothingToRefund")
 87	}
 88	c.state.everWithdrawn = true
 89	if !panicsWith(func() { checkRefund(c, donor) }, ErrRefundWindowClosed) {
 90		t.Errorf("after a withdrawal: not ErrRefundWindowClosed")
 91	}
 92	k.refunded = k.amount
 93	if !panicsWith(func() { checkRefund(c, donor) }, ErrNothingToRefund) {
 94		t.Errorf("all refunded, window closed: not ErrNothingToRefund")
 95	}
 96	c.mode = modeDirect
 97	if !panicsWith(func() { checkRefund(c, donor) }, ErrWrongMode) {
 98		t.Errorf("direct, nothing left: not ErrWrongMode")
 99	}
100}
101
102// TestAssertBooks corrupts the books on purpose (INV-1): books above the bank stop
103// every payout, before anything is written.
104func TestAssertBooks(t *testing.T) {
105	donor := testutils.TestAddress("donor")
106	c := testCampaign(9403, modeEscrow, 0)
107	fund(c, donor, 1000)
108	assertBooks(c)
109	c.state.raised += 1 // the books now claim a coin the escrow doesn't hold
110	if !panicsWith(func() { assertBooks(c) }, ErrBooksMismatch) {
111		t.Fatalf("books above the bank: not ErrBooksMismatch")
112	}
113	for name, f := range map[string]func(){
114		"a withdrawal": func() { bookWithdrawal(c) },
115		"a refund":     func() { bookRefund(c, donor) },
116	} {
117		if !panicsWith(f, ErrBooksMismatch) {
118			t.Errorf("%s on books above the bank: not ErrBooksMismatch", name)
119		}
120	}
121	if c.state.withdrawn != 0 || c.state.refunded != 0 || c.withdrawals.Size() != 0 || c.refunds.Size() != 0 {
122		t.Errorf("a refused payout wrote the books")
123	}
124}
125
126func TestBookWithdrawal(t *testing.T) {
127	donor := testutils.TestAddress("donor")
128	c := testCampaign(9404, modeEscrow, 0)
129	if !panicsWith(func() { bookWithdrawal(c) }, ErrNothingToWithdraw) {
130		t.Errorf("an empty escrow: not ErrNothingToWithdraw")
131	}
132
133	// Untracked coins alone: withdrawn as untracked, and the open window stays open.
134	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 300)))
135	w := bookWithdrawal(c)
136	checkBooks(t, c)
137	if w.amount != 0 || w.untracked != 300 || w.seq != 1 || c.state.everWithdrawn || c.state.withdrawn != 0 {
138		t.Errorf("untracked only: %v; everWithdrawn %t", *w, c.state.everWithdrawn)
139	}
140	// Tracked and untracked: Withdrawn grows by the tracked part only (INV-2). The
141	// books moved no coin, so the 300 are still there.
142	fund(c, donor, 1000)
143	w = bookWithdrawal(c)
144	checkBooks(t, c)
145	if w.amount != 1000 || w.untracked != 300 || !c.state.everWithdrawn || c.state.withdrawn != 1000 || trackedBalance(c) != 0 {
146		t.Errorf("tracked and untracked: %v; withdrawn %d", *w, c.state.withdrawn)
147	}
148	if got := c.withdrawals.Get(seqid.ID(2).Binary()).(*withdrawal); got != w || w.to != c.beneficiary || w.at != now() {
149		t.Errorf("stored record %v", *got)
150	}
151}
152
153func TestBookRefund(t *testing.T) {
154	ada, bo := testutils.TestAddress("ada"), testutils.TestAddress("bo")
155	c := testCampaign(9405, modeEscrow, 0)
156	fund(c, ada, 2000)
157	fund(c, bo, 1000)
158	fund(c, c.organizer, 500) // a record a misdirected refund could pay from
159	r := bookRefund(c, ada)
160	checkBooks(t, c)
161	if r.amount != 2000 || r.donor != ada || r.kind != windowOpen || r.seq != 1 {
162		t.Errorf("refund %v", *r)
163	}
164	if k := contributionOf(c, ada); k.refunded != 2000 || c.state.refunded != 2000 || c.state.refundCount != 1 {
165		t.Errorf("after a refund: contribution %v, refunded %d", *k, c.state.refunded)
166	}
167	// It pays only from the caller's own record: bo's is untouched (INV-13).
168	if k := contributionOf(c, bo); k.refunded != 0 {
169		t.Errorf("bo's record changed: %v", *k)
170	}
171	if !panicsWith(func() { bookRefund(c, ada) }, ErrNothingToRefund) {
172		t.Errorf("a second refund: not ErrNothingToRefund")
173	}
174}
175
176func TestGetContribution(t *testing.T) {
177	donor := testutils.TestAddress("donor")
178	c := testCampaign(9406, modeEscrow, 0)
179	id, unstore := storeCampaign(c)
180	defer unstore()
181	if v := GetContribution(id, testutils.TestAddress("stranger")); v != (ContributionView{}) {
182		t.Errorf("a stranger: %v", v)
183	}
184	c.contributions.Set(donor.String(), &contribution{amount: 700, refunded: 200, public: true})
185	if v := GetContribution(id, donor); v != (ContributionView{Amount: 700, Refunded: 200, RefundableNow: 500, Window: windowOpen}) {
186		t.Errorf("open window: %v", v)
187	}
188	c.state.everWithdrawn = true
189	if v := GetContribution(id, donor); v.Window != "" || v.RefundableNow != 0 || v.Amount != 700 {
190		t.Errorf("after a withdrawal: %v", v)
191	}
192	if !panicsWith(func() { GetContribution("042", donor) }, ErrNotFound) {
193		t.Errorf("GetContribution(\"042\"): not ErrNotFound")
194	}
195}
196
197// TestWithdraw withdraws as the beneficiary, straight from the test's body as
198// AssertOriginCall requires (plan §3.3), the books held against the bank after every
199// step. Its refusals are unit-tested above and in txtar (money_out.txtar).
200func TestWithdraw(cur realm, t *testing.T) {
201	defer func(cap int64) { escrowCap = cap }(escrowCap)
202	rb := banker.NewReadonlyBanker()
203	donor := testutils.TestAddress("donor")
204	c := testCampaign(9407, modeEscrow, 0)
205	id, unstore := storeCampaign(c)
206	defer unstore()
207	other := testCampaign(9408, modeEscrow, 0)
208	idOther, unstoreOther := storeCampaign(other)
209	defer unstoreOther()
210	fund(c, donor, 3000)
211	fund(other, donor, 500)
212	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 40))) // outside Bolo
213	checkBooks(t, c)
214
215	// Everything goes to the beneficiary: the tracked 3000 counted as Withdrawn,
216	// the 40 recorded apart. The other campaign's escrow isn't touched.
217	before, otherBefore := rb.GetCoin(c.beneficiary, "ugnot"), rb.GetCoin(escrowAddr(other.id), "ugnot")
218	testing.SetRealm(testing.NewUserRealm(c.beneficiary))
219	if got := Withdraw(cross(cur), id); got != 3040 {
220		t.Errorf("Withdraw = %d, want 3040", got)
221	}
222	checkBooks(t, c)
223	if got := rb.GetCoin(c.beneficiary, "ugnot") - before; got != 3040 || rb.GetCoin(escrowAddr(c.id), "ugnot") != 0 {
224		t.Errorf("the beneficiary got %d; the escrow holds %d", got, rb.GetCoin(escrowAddr(c.id), "ugnot"))
225	}
226	if v := GetCampaign(id); v.Withdrawn != 3000 || v.Balance != 0 || !v.EverWithdrawn {
227		t.Errorf("after the withdrawal: withdrawn %d, balance %d", v.Withdrawn, v.Balance)
228	}
229	if rb.GetCoin(escrowAddr(other.id), "ugnot") != otherBefore || GetCampaign(idOther).Balance != 500 {
230		t.Errorf("a withdrawal touched another campaign")
231	}
232
233	// A withdrawal makes room under the cap.
234	escrowCap = 3000
235	checkRoom(c, 3000)
236
237	// Closing traps nothing: closed, before the exit window, the beneficiary
238	// still withdraws.
239	fund(c, donor, 700)
240	c.state.status, c.state.closedAt = statusClosed, now()
241	if got := Withdraw(cross(cur), id); got != 700 {
242		t.Errorf("Withdraw from a closed campaign = %d, want 700", got)
243	}
244	checkBooks(t, c)
245}
246
247// TestRefund refunds as a donor in the open window, the books held against the bank
248// after every step: the caller is paid from their own record, and their receipts show
249// it (INV-12, INV-13).
250func TestRefund(cur realm, t *testing.T) {
251	rb := banker.NewReadonlyBanker()
252	ada, bo := testutils.TestAddress("ada"), testutils.TestAddress("bo")
253	c := testCampaign(9409, modeEscrow, 0)
254	id, unstore := storeCampaign(c)
255	defer unstore()
256	testing.SetRealm(testing.NewUserRealm(ada))
257	testing.SetOriginSend(chain.NewCoins(chain.NewCoin("ugnot", 2_000_000)))
258	testing.IssueCoins(realmAddr(), chain.NewCoins(chain.NewCoin("ugnot", 2_000_000)))
259	Donate(cross(cur), id, 0, "", false)
260	defer byDonor.Remove(ada.String() + "/" + c.id.Binary())
261	fund(c, bo, 1_000_000)
262	checkBooks(t, c)
263
264	before := rb.GetCoin(ada, "ugnot")
265	if got := Refund(cross(cur), id); got != 2_000_000 {
266		t.Errorf("Refund = %d, want 2000000", got)
267	}
268	checkBooks(t, c)
269	if got := rb.GetCoin(ada, "ugnot") - before; got != 2_000_000 {
270		t.Errorf("ada got %d back", got)
271	}
272	if r := GetReceipt(id, 1); r.DonorRefunded != 2_000_000 {
273		t.Errorf("ada's receipt: DonorRefunded %d", r.DonorRefunded)
274	}
275	if v := GetCampaign(id); v.Refunded != 2_000_000 || v.RefundCount != 1 || v.Balance != 1_000_000 || v.NetRaised != 1_000_000 {
276		t.Errorf("after the refund: %v", v)
277	}
278	if v := GetContribution(id, bo); v.RefundableNow != 1_000_000 || v.Window != windowOpen {
279		t.Errorf("bo's record: %v", v)
280	}
281
282	// Given again after a refund, ada is paid what is left of her record, never all
283	// of it: the escrow holds enough that paying more would go through (INV-13).
284	testing.SetOriginSend(chain.NewCoins(chain.NewCoin("ugnot", 1_000_000)))
285	testing.IssueCoins(realmAddr(), chain.NewCoins(chain.NewCoin("ugnot", 1_000_000)))
286	Donate(cross(cur), id, 0, "", false)
287	fund(c, bo, 2_000_000)
288	checkBooks(t, c)
289	before = rb.GetCoin(ada, "ugnot")
290	if got := Refund(cross(cur), id); got != 1_000_000 {
291		t.Errorf("a second Refund = %d, want 1000000", got)
292	}
293	checkBooks(t, c)
294	if got := rb.GetCoin(ada, "ugnot") - before; got != 1_000_000 {
295		t.Errorf("ada got %d back the second time, want 1000000", got)
296	}
297}