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}