settle_test.gno
17.03 Kb · 401 lines
1package bolo
2
3import (
4 "chain"
5 "chain/banker"
6 "math"
7 "strconv"
8 "testing"
9
10 "gno.land/p/nt/seqid/v0"
11 "gno.land/p/nt/testutils/v0"
12)
13
14// The helpers' tests come first: a write's test that panics through a crossing call
15// ends the package's unit tests.
16
17func TestExitClaim(t *testing.T) {
18 for _, k := range []struct{ left, pool, base, want int64 }{
19 {100, 150, 300, 50},
20 {1, 1, 3, 0}, // rounding down: dust stays in the escrow
21 {2, 1, 3, 0},
22 {3, math.MaxInt64, 7, 3952873730080618203},
23 {math.MaxInt64, math.MaxInt64, math.MaxInt64, math.MaxInt64},
24 {math.MaxInt64 / 2, math.MaxInt64 - 1, math.MaxInt64, 4611686018427387902},
25 {1_000_000_000_000_000, 1_000_000_000_000_000, 3_000_000_000_000_007, 333333333333332},
26 {123456789, 987654321987, 999999999999, 121932631},
27 {5, 0, 9, 0},
28 } {
29 if got := exitClaim(k.left, k.pool, k.base); got != k.want {
30 t.Errorf("exitClaim(%d, %d, %d) = %d, want %d", k.left, k.pool, k.base, got, k.want)
31 }
32 }
33 // Neither of Div64's own panics, keyless, is ever reached.
34 for name, k := range map[string]struct{ left, pool, base int64 }{
35 "a base of 0": {0, 10, 0},
36 "a left above the base": {11, 10, 10},
37 "a negative pool": {1, -1, 10},
38 "a negative base": {1, 10, -1},
39 "past 64 bits otherwise": {math.MaxInt64, math.MaxInt64, math.MaxInt64 - 1},
40 } {
41 if !panicsWith(func() { exitClaim(k.left, k.pool, k.base) }, ErrBooksMismatch) {
42 t.Errorf("%s: not ErrBooksMismatch", name)
43 }
44 }
45}
46
47// exitCampaign is an escrow campaign whose exit window is open: ada, bo and cy gave
48// amounts, then withdrawn was taken, and it closed ExitDelay ago. Its escrow holds the
49// tracked rest.
50func exitCampaign(id seqid.ID, withdrawn int64, amounts ...int64) (*campaign, []address) {
51 c := testCampaign(id, modeEscrow, 0)
52 donors := []address{testutils.TestAddress("ada"), testutils.TestAddress("bo"), testutils.TestAddress("cy")}
53 for i, amount := range amounts {
54 c.contributions.Set(donors[i].String(), &contribution{amount: amount})
55 c.state.raised += amount
56 c.state.donorCount++
57 }
58 s := c.state
59 s.withdrawn, s.everWithdrawn = withdrawn, true
60 s.status, s.closedAt = statusClosed, now()-c.params.ExitDelay
61 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", s.raised-withdrawn)))
62 return c, donors
63}
64
65// TestExitRefunds: the first claim freezes the pool and the base, each donor claims
66// once, pro-rata, and the claims never add up to more than the pool (INV-17). The pool
67// is the tracked balance: coins sent outside Bolo aren't the donors'. The first model
68// is the reviewer's: three gifts of 100, 150 withdrawn, 50 each — where a claim
69// repeated against a recomputed pool once drained about 100.
70func TestExitRefunds(t *testing.T) {
71 for i, k := range []struct {
72 withdrawn int64
73 amounts, claims []int64
74 }{
75 {150, []int64{100, 100, 100}, []int64{50, 50, 50}},
76 {300, []int64{100, 200, 300}, []int64{50, 100, 150}},
77 {100, []int64{100, 100, 101}, []int64{66, 66, 67}}, // rounded down: 2 stay in the escrow
78 } {
79 c, donors := exitCampaign(seqid.ID(9501+100*i), k.withdrawn, k.amounts...)
80 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 7))) // sent outside Bolo
81 id, unstore := storeCampaign(c)
82 pool, base := c.state.raised-k.withdrawn, c.state.raised
83 checkBooks(t, c)
84 if v := GetContribution(id, donors[1]); v.Window != windowExit || v.RefundableNow != k.claims[1] {
85 t.Errorf("model %d, bo before any claim: %v", i, v)
86 }
87 var paid int64
88 for j, want := range k.claims {
89 r := bookRefund(c, donors[j])
90 checkBooks(t, c)
91 if r.amount != want || r.kind != windowExit {
92 t.Errorf("model %d, claim %d = %v, want %d", i, j, *r, want)
93 }
94 paid += r.amount
95 // The snapshot is the first claim's, never recomputed (INV-17).
96 if s := c.state; !s.exitFrozen || s.exitPool != pool || s.exitBase != base {
97 t.Errorf("model %d, after claim %d: frozen %t, pool %d, base %d", i, j, s.exitFrozen, s.exitPool, s.exitBase)
98 }
99 if !panicsWith(func() { bookRefund(c, donors[j]) }, ErrRefundWindowClosed) {
100 t.Errorf("model %d, a second claim by donor %d: not ErrRefundWindowClosed", i, j)
101 }
102 if v := GetContribution(id, donors[j]); v.Window != "" || v.RefundableNow != 0 || !v.ExitClaimed {
103 t.Errorf("model %d, donor %d after their claim: %v", i, j, v)
104 }
105 }
106 if paid > pool {
107 t.Errorf("model %d: the claims add up to %d, past the pool %d", i, paid, pool)
108 }
109 unstore()
110 }
111}
112
113// TestExitClaimCap: a claim above the tracked balance is ErrBooksMismatch, whatever
114// the snapshot says (INV-17).
115func TestExitClaimCap(t *testing.T) {
116 c, donors := exitCampaign(9520, 150, 100, 100, 100)
117 s := c.state
118 s.exitFrozen, s.exitPool, s.exitBase = true, 300, 100 // ada's claim: 300, the tracked balance 150
119 if !panicsWith(func() { bookRefund(c, donors[0]) }, ErrBooksMismatch) {
120 t.Errorf("a claim above the tracked balance: not ErrBooksMismatch")
121 }
122}
123
124// TestExitBase: the base is what is left of every record when the first claim comes,
125// Raised − Refunded: an open-window refund before the withdrawal is taken out of it.
126func TestExitBase(t *testing.T) {
127 c := testCampaign(9502, modeEscrow, 0)
128 ada, bo, cy := testutils.TestAddress("ada"), testutils.TestAddress("bo"), testutils.TestAddress("cy")
129 c.contributions.Set(ada.String(), &contribution{amount: 400})
130 c.contributions.Set(bo.String(), &contribution{amount: 200})
131 c.contributions.Set(cy.String(), &contribution{amount: 300, refunded: 300}) // all back, in the open window
132 s := c.state
133 s.raised, s.refunded, s.withdrawn, s.everWithdrawn = 900, 300, 300, true
134 s.status, s.closedAt = statusClosed, now()-c.params.ExitDelay
135 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 300)))
136 checkBooks(t, c)
137 // Tracked 300, base 600 — ada's 400 and bo's 200: ada claims 400 × 300 / 600.
138 if r := bookRefund(c, ada); r.amount != 200 || s.exitBase != 600 || s.exitPool != 300 {
139 t.Errorf("ada's claim %d; base %d, pool %d", r.amount, s.exitBase, s.exitPool)
140 }
141 checkBooks(t, c)
142}
143
144// TestExitUneven: forty donors with uneven gifts, some refunded in the open window,
145// then a withdrawal, more gifts from half of them and others, and every donor's claim
146// in a scrambled order. Each claim is paid from the tracked balance, the claims never
147// pass the pool, and what is left is rounding dust, under one ugnot a claim (INV-17).
148func TestExitUneven(t *testing.T) {
149 c := testCampaign(9519, modeEscrow, 0)
150 x := int64(12345)
151 next := func() int64 { // a fixed sequence of amounts from 1 to about a million
152 x = (x*1103515245 + 12345) % 2147483648
153 return x%1_000_003 + 1
154 }
155 donors := make([]address, 40)
156 for i := range donors {
157 donors[i] = testutils.TestAddress("donor" + strconv.Itoa(i))
158 }
159 s := c.state
160 for i := 0; i < 20; i++ {
161 if i%7 == 3 { // refunded in full in the open window: the coins went back
162 a := next()
163 c.contributions.Set(donors[i].String(), &contribution{amount: a, refunded: a})
164 s.raised, s.refunded, s.donorCount = s.raised+a, s.refunded+a, s.donorCount+1
165 continue
166 }
167 fund(c, donors[i], next())
168 }
169 bookWithdrawal(c)
170 for i := 10; i < 40; i++ {
171 fund(c, donors[i], next())
172 }
173 s.status, s.closedAt = statusClosed, now()-c.params.ExitDelay
174 checkBooks(t, c)
175 pool := trackedBalance(c)
176 var paid, claims int64
177 for i := 0; i < 40; i++ {
178 d := donors[i*17%40] // every donor once: 17 and 40 share no factor
179 if k := contributionOf(c, d); k.amount == k.refunded {
180 continue
181 }
182 left := trackedBalance(c)
183 r := bookRefund(c, d)
184 checkBooks(t, c)
185 if r.amount > left || r.amount <= 0 {
186 t.Errorf("a claim of %d against a tracked %d", r.amount, left)
187 }
188 paid += r.amount
189 claims++
190 }
191 if paid > pool || pool-paid >= claims || claims != 39 { // all but donor 3: refunded in full, never gave again
192 t.Errorf("%d claims paid %d of a pool %d", claims, paid, pool)
193 }
194}
195
196// TestExitWindow: the exit window needs a withdrawal, a close, ExitDelay passed and a
197// tracked balance; a claim that rounds to 0 uses nothing.
198func TestExitWindow(t *testing.T) {
199 c, donors := exitCampaign(9503, 300, 100, 200, 300)
200 c.state.closedAt = now() - c.params.ExitDelay + 1
201 if !panicsWith(func() { checkRefund(c, donors[0]) }, ErrRefundWindowClosed) {
202 t.Errorf("a second before the exit window: not ErrRefundWindowClosed")
203 }
204 c.state.closedAt = now() - c.params.ExitDelay
205 checkRefund(c, donors[0])
206 c.state.status = statusSettled
207 checkRefund(c, donors[0]) // settlement closes no window (INV-16)
208 c.state.status = statusOpen
209 if !panicsWith(func() { checkRefund(c, donors[0]) }, ErrRefundWindowClosed) {
210 t.Errorf("open: not ErrRefundWindowClosed")
211 }
212 c.state.status = statusClosed
213 c.state.withdrawn = 600 // nothing tracked left
214 if !panicsWith(func() { checkRefund(c, donors[0]) }, ErrRefundWindowClosed) {
215 t.Errorf("no tracked balance: not ErrRefundWindowClosed")
216 }
217
218 // Dust: a gift of 1 against a pool far smaller than the base claims 0.
219 d := testCampaign(9504, modeEscrow, 0)
220 tiny, big := testutils.TestAddress("tiny"), testutils.TestAddress("big")
221 d.contributions.Set(tiny.String(), &contribution{amount: 1})
222 d.contributions.Set(big.String(), &contribution{amount: 1000})
223 d.state.raised, d.state.withdrawn, d.state.everWithdrawn = 1001, 1000, true
224 d.state.status, d.state.closedAt = statusClosed, now()-d.params.ExitDelay
225 if !panicsWith(func() { checkRefund(d, tiny) }, ErrNothingToRefund) {
226 t.Errorf("a claim of 0: not ErrNothingToRefund")
227 }
228 if contributionOf(d, tiny).exitClaimed {
229 t.Errorf("a claim of 0 used the claim")
230 }
231 dustID, unstoreDust := storeCampaign(d)
232 if v := GetContribution(dustID, tiny); v.Window != "" || v.RefundableNow != 0 {
233 t.Errorf("a claim of 0 shows a window Refund refuses: %v", v)
234 }
235 unstoreDust()
236
237 // GetContribution counts a passed deadline as recorded: the stored status is open.
238 e := testCampaign(9505, modeEscrow, now()-61*secondsPerDay)
239 e.contributions.Set(tiny.String(), &contribution{amount: 100})
240 e.contributions.Set(big.String(), &contribution{amount: 100})
241 e.state.raised, e.state.withdrawn, e.state.everWithdrawn = 200, 100, true
242 id, unstore := storeCampaign(e)
243 defer unstore()
244 if v := GetContribution(id, tiny); v.Window != windowExit || v.RefundableNow != 50 {
245 t.Errorf("past an unrecorded deadline and its exit delay: %v", v)
246 }
247}
248
249func TestCheckFinalizable(t *testing.T) {
250 c := testCampaign(9506, modeEscrow, 0)
251 if !panicsWith(func() { checkFinalizable(c) }, ErrNotFinalizable) {
252 t.Errorf("open: not ErrNotFinalizable")
253 }
254 c.state.status, c.state.closedAt = statusClosed, now()
255 checkFinalizable(c) // nothing tracked
256 c.state.raised = 10
257 if !panicsWith(func() { checkFinalizable(c) }, ErrNotFinalizable) {
258 t.Errorf("a tracked balance before the exit window: not ErrNotFinalizable")
259 }
260 c.state.closedAt = now() - c.params.ExitDelay
261 checkFinalizable(c) // the exit window is open
262 d := testCampaign(9507, modeDirect, 0)
263 d.state.status, d.state.closedAt = statusClosed, now()
264 checkFinalizable(d) // direct: at once
265}
266
267// TestFinalizeBooks: the first call settles; untracked coins go to the beneficiary,
268// the tracked balance never; the bond address's coins to the organizer, but a bond on
269// hold; the bond once its hold ends, unless slashed; later calls forward what arrives.
270func TestFinalizeBooks(t *testing.T) {
271 c := testCampaign(9508, modeEscrow, 0)
272 fund(c, testutils.TestAddress("donor"), 500) // tracked, the donors'
273 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 70)))
274 testing.IssueCoins(bondAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 1000+300)))
275 c.state.bond = 300
276 c.state.status, c.state.closedAt = statusClosed, now()-c.params.ExitDelay
277 f := bookFinalize(c)
278 if !f.settled || c.state.status != statusSettled || f.toBeneficiary != 70 || f.toOrganizer != 1300 || f.released != 300 || !c.state.bondReleased {
279 // ExitDelay (60 days) is past BondHold (30): the bond goes too.
280 t.Errorf("first call: %v", f)
281 }
282
283 // On hold: a campaign closed now keeps its bond at the bond address.
284 d := testCampaign(9509, modeDirect, 0)
285 testing.IssueCoins(bondAddr(d.id), chain.NewCoins(chain.NewCoin("ugnot", 1000+300)))
286 d.state.bond = 300
287 d.state.status, d.state.closedAt = statusClosed, now()
288 f = bookFinalize(d)
289 if !f.settled || f.toOrganizer != 1000 || f.released != 0 || d.state.bondReleased {
290 t.Errorf("bond on hold: %v", f)
291 }
292 // The coins don't move here; a later call, before the hold ends, has the same
293 // 1000 to forward and still holds the bond, without settling again.
294 if f = bookFinalize(d); f.settled || f.toOrganizer != 1000 || f.released != 0 {
295 t.Errorf("a second call on hold: %v", f)
296 }
297 // A slashed bond is never released: the slash took it, and the deposit is left.
298 e := testCampaign(9518, modeDirect, 0)
299 testing.IssueCoins(bondAddr(e.id), chain.NewCoins(chain.NewCoin("ugnot", 1000)))
300 e.state.bond, e.state.bondSlashed = 300, true
301 e.state.status, e.state.closedAt = statusClosed, now()-e.params.BondHold
302 if f = bookFinalize(e); f.released != 0 || e.state.bondReleased || f.toOrganizer != 1000 {
303 t.Errorf("slashed: %v", f)
304 }
305 // Within the hold too: the slash took the bond, so the deposit goes at once.
306 s := testCampaign(9530, modeDirect, 0)
307 testing.IssueCoins(bondAddr(s.id), chain.NewCoins(chain.NewCoin("ugnot", 1000)))
308 s.state.bond, s.state.bondSlashed = 300, true
309 s.state.status, s.state.closedAt = statusClosed, now()
310 if f = bookFinalize(s); !f.settled || f.released != 0 || f.toOrganizer != 1000 {
311 t.Errorf("slashed, within the hold: %v", f)
312 }
313
314 // A bond address holding less than the bond on hold stops it (INV-7).
315 h := testCampaign(9521, modeDirect, 0)
316 testing.IssueCoins(bondAddr(h.id), chain.NewCoins(chain.NewCoin("ugnot", 200)))
317 h.state.bond = 300
318 h.state.status, h.state.closedAt = statusClosed, now()
319 if !panicsWith(func() { bookFinalize(h) }, ErrBooksMismatch) {
320 t.Errorf("a bond above the bond address's coins: not ErrBooksMismatch")
321 }
322
323 // Books above the bank stop it (INV-1).
324 g := testCampaign(9510, modeEscrow, 0)
325 g.state.raised = 5
326 g.state.status, g.state.closedAt = statusClosed, now()-g.params.ExitDelay
327 if !panicsWith(func() { bookFinalize(g) }, ErrBooksMismatch) {
328 t.Errorf("books above the bank: not ErrBooksMismatch")
329 }
330}
331
332func TestCanFinalize(t *testing.T) {
333 open := testCampaign(9511, modeEscrow, 0)
334 waiting := testCampaign(9512, modeEscrow, 0)
335 fund(waiting, testutils.TestAddress("donor"), 10)
336 waiting.state.status, waiting.state.closedAt = statusClosed, now()
337 ready := testCampaign(9513, modeDirect, 0)
338 ready.state.status, ready.state.closedAt = statusClosed, now()
339 passed := testCampaign(9514, modeDirect, now()-1) // past its deadline, unrecorded
340 exit := testCampaign(9522, modeEscrow, 0)
341 fund(exit, testutils.TestAddress("donor"), 10)
342 exit.state.status, exit.state.closedAt = statusClosed, now()-exit.params.ExitDelay // its exit window opens now
343 settled := testCampaign(9515, modeDirect, 0)
344 settled.state.status, settled.state.closedAt = statusSettled, now()
345 stray := testCampaign(9516, modeDirect, 0)
346 stray.state.status, stray.state.closedAt = statusSettled, now()
347 testing.IssueCoins(escrowAddr(stray.id), chain.NewCoins(chain.NewCoin("ugnot", 3)))
348 for _, k := range []struct {
349 name string
350 c *campaign
351 ok bool
352 why string
353 }{
354 {"open", open, false, statusOpen},
355 {"waiting for the exit window", waiting, false, "not_yet"},
356 {"closed, direct", ready, true, "settle"},
357 {"past an unrecorded deadline", passed, true, "settle"},
358 {"at the exit window", exit, true, "settle"},
359 {"settled, nothing to move", settled, false, statusSettled},
360 {"settled, coins arrived", stray, true, "forward"},
361 } {
362 id, unstore := storeCampaign(k.c)
363 ok, why := CanFinalize(id)
364 unstore()
365 if ok != k.ok || why != k.why {
366 t.Errorf("%s: CanFinalize = %t, %q, want %t, %q", k.name, ok, why, k.ok, k.why)
367 }
368 }
369}
370
371// TestFinalize settles as anyone, straight from the test's body, and moves the coins
372// bookFinalize names: the untracked to the beneficiary, the deposit to the organizer,
373// the tracked balance nowhere (INV-16), which a donor still claims after (API §3.4).
374func TestFinalize(cur realm, t *testing.T) {
375 rb := banker.NewReadonlyBanker()
376 c, donors := exitCampaign(9517, 300, 100, 200, 300)
377 id, unstore := storeCampaign(c)
378 defer unstore()
379 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 40))) // sent outside Bolo
380 testing.IssueCoins(bondAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 1000))) // the deposit
381 checkBooks(t, c)
382 beneficiary, organizer := rb.GetCoin(c.beneficiary, "ugnot"), rb.GetCoin(c.organizer, "ugnot")
383 testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("passerby")))
384 Finalize(cross(cur), id)
385 checkBooks(t, c)
386 if got := rb.GetCoin(c.beneficiary, "ugnot") - beneficiary; got != 40 {
387 t.Errorf("the beneficiary got %d, want the untracked 40", got)
388 }
389 if got := rb.GetCoin(c.organizer, "ugnot") - organizer; got != 1000 {
390 t.Errorf("the organizer got %d, want the deposit 1000", got)
391 }
392 if got := rb.GetCoin(escrowAddr(c.id), "ugnot"); got != 300 || c.state.status != statusSettled {
393 t.Errorf("the escrow holds %d; status %s", got, c.state.status)
394 }
395 // Claims still paid after settlement (INV-16).
396 testing.SetRealm(testing.NewUserRealm(donors[2]))
397 if got := Refund(cross(cur), id); got != 150 {
398 t.Errorf("cy's exit claim after settlement = %d, want 150", got)
399 }
400 checkBooks(t, c)
401}