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.

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}