curated_test.gno
14.00 Kb · 416 lines
1package curated
2
3import (
4 "strings"
5 "testing"
6
7 "gno.land/p/nt/testutils/v0"
8 "gno.land/p/nt/uassert/v0"
9 "gno.land/p/nt/urequire/v0"
10)
11
12const (
13 deposit = int64(1000)
14 window = int64(100)
15)
16
17var (
18 alice = testutils.TestAddress("alice")
19 bob = testutils.TestAddress("bob")
20 carol = testutils.TestAddress("carol")
21 dave = testutils.TestAddress("dave")
22)
23
24// list returns a registry holding one entry owned by alice, listed at height
25// 100, which is the starting point of most cases below.
26func list(t *testing.T) *Registry {
27 t.Helper()
28 r := New(deposit, window)
29 urequire.NoError(t, r.Apply("gnoswap", "https://gnoswap.io", "an AMM", alice, deposit, 100))
30 return r
31}
32
33func TestValidKey(t *testing.T) {
34 tests := []struct {
35 key string
36 want bool
37 }{
38 {"gnoswap", true},
39 {"a", true},
40 {"0", true},
41 {"gno-swap_v2.1", true},
42 {strings.Repeat("x", MaxKeyLen), true},
43 {"", false},
44 {strings.Repeat("x", MaxKeyLen+1), false},
45 {"GnoSwap", false},
46 {"gno swap", false},
47 {"gno/swap", false},
48 {"gno:swap", false},
49 {".", false},
50 {"..", false},
51 {"-leading", false},
52 {"_leading", false},
53 {"émoji", false},
54 }
55 for _, tt := range tests {
56 uassert.Equal(t, tt.want, ValidKey(tt.key), tt.key)
57 }
58}
59
60func TestValidURL(t *testing.T) {
61 tests := []struct {
62 name string
63 url string
64 want bool
65 }{
66 {"https", "https://gnoswap.io/pools", true},
67 {"an on-chain path, which has no scheme to carry", "/r/moul/home", true},
68 {"query and fragment", "https://x.io/a?b=c#d", true},
69 {"a pipe is escaped at render time, not refused", "https://x.io/a|b", true},
70 {"empty", "", false},
71 {"too long", "https://x.io/" + strings.Repeat("x", MaxURLLen), false},
72 {"a space", "https://x.io/a b", false},
73 {"a newline", "https://x.io/a\nb", false},
74 {"a control character", "https://x.io/a\x01b", false},
75 }
76 for _, tt := range tests {
77 uassert.Equal(t, tt.want, ValidURL(tt.url), tt.name)
78 }
79}
80
81func TestValidDescription(t *testing.T) {
82 tests := []struct {
83 name string
84 desc string
85 want bool
86 }{
87 {"plain", "an AMM on gno.land", true},
88 {"markdown is allowed and escaped later", "[x](y) | z", true},
89 {"empty", "", false},
90 {"blank", " ", false},
91 {"too long", strings.Repeat("x", MaxDescLen+1), false},
92 {"a newline would break its own table cell", "a\nb", false},
93 {"a tab, same reason", "a\tb", false},
94 }
95 for _, tt := range tests {
96 uassert.Equal(t, tt.want, ValidDescription(tt.desc), tt.name)
97 }
98}
99
100func TestApplyRefusesWhatItCannotStore(t *testing.T) {
101 tests := []struct {
102 name string
103 key string
104 url string
105 desc string
106 paid int64
107 want error
108 }{
109 {"bad key", "Gnoswap", "https://x.io", "an AMM", deposit, ErrBadKey},
110 {"bad url", "gnoswap", "", "an AMM", deposit, ErrBadURL},
111 {"bad description", "gnoswap", "https://x.io", " ", deposit, ErrBadDescription},
112 {"underpaid", "gnoswap", "https://x.io", "an AMM", deposit - 1, ErrWrongDeposit},
113 {"overpaid", "gnoswap", "https://x.io", "an AMM", deposit + 1, ErrWrongDeposit},
114 {"nothing attached", "gnoswap", "https://x.io", "an AMM", 0, ErrWrongDeposit},
115 }
116 for _, tt := range tests {
117 r := New(deposit, window)
118 err := r.Apply(tt.key, tt.url, tt.desc, alice, tt.paid, 100)
119 uassert.ErrorIs(t, err, tt.want, tt.name)
120 uassert.Equal(t, 0, r.Count(), tt.name+": nothing was listed")
121 uassert.Equal(t, int64(0), r.Locked(), tt.name+": nothing was locked")
122 }
123}
124
125func TestApplyListsImmediately(t *testing.T) {
126 r := list(t)
127
128 e, ok := r.Get("gnoswap")
129 urequire.True(t, ok, "the entry exists")
130 uassert.Equal(t, "https://gnoswap.io", e.URL)
131 uassert.Equal(t, "an AMM", e.Description)
132 uassert.Equal(t, alice.String(), e.Owner.String())
133 uassert.Equal(t, deposit, e.Deposit)
134 uassert.Equal(t, int64(100), e.At)
135 uassert.Equal(t, "listed", e.State.String())
136
137 uassert.True(t, r.IsListed("gnoswap"))
138 uassert.False(t, r.IsListed("nothing"))
139 uassert.Equal(t, 1, r.Count())
140 uassert.Equal(t, 1, r.Records())
141 uassert.Equal(t, deposit, r.Locked())
142 uassert.Equal(t, int64(0), r.Owed())
143}
144
145// A key is unique while it is on the list, and free again once it is off it: a
146// challenge removes an entry, it does not burn the name.
147func TestAKeyIsTakenUntilItIsRemoved(t *testing.T) {
148 r := list(t)
149
150 uassert.ErrorIs(t, r.Apply("gnoswap", "https://other.io", "mine now", bob, deposit, 101), ErrTaken)
151 urequire.NoError(t, r.Unlist("gnoswap", alice))
152 uassert.False(t, r.IsListed("gnoswap"))
153
154 urequire.NoError(t, r.Apply("gnoswap", "https://other.io", "mine now", bob, deposit, 102))
155 e, _ := r.Get("gnoswap")
156 uassert.Equal(t, bob.String(), e.Owner.String())
157 uassert.Equal(t, int64(102), e.At, "a re-application is a fresh entry")
158 uassert.Equal(t, 1, r.Count())
159 uassert.Equal(t, 1, r.Records(), "and it reuses its place in the listing order")
160}
161
162func TestListedIsOldestFirstAndSkipsTheRemoved(t *testing.T) {
163 r := New(deposit, window)
164 urequire.NoError(t, r.Apply("one", "https://1.io", "first", alice, deposit, 100))
165 urequire.NoError(t, r.Apply("two", "https://2.io", "second", alice, deposit, 101))
166 urequire.NoError(t, r.Apply("three", "https://3.io", "third", alice, deposit, 102))
167
168 got := r.Listed()
169 urequire.Equal(t, 3, len(got))
170 uassert.Equal(t, "one", got[0].Key)
171 uassert.Equal(t, "two", got[1].Key)
172 uassert.Equal(t, "three", got[2].Key)
173
174 urequire.NoError(t, r.Unlist("two", alice))
175 got = r.Listed()
176 urequire.Equal(t, 2, len(got))
177 uassert.Equal(t, "one", got[0].Key)
178 uassert.Equal(t, "three", got[1].Key)
179 uassert.Equal(t, 0, len(r.Challenged()))
180}
181
182func TestChallengeRefusals(t *testing.T) {
183 tests := []struct {
184 name string
185 key string
186 who address
187 bond int64
188 want error
189 }{
190 {"no such entry", "nothing", bob, deposit, ErrNoEntry},
191 {"the bond must match", "gnoswap", bob, deposit - 1, ErrWrongBond},
192 {"and may not exceed it either", "gnoswap", bob, deposit + 1, ErrWrongBond},
193 {"an owner cannot shield their own entry", "gnoswap", alice, deposit, ErrSelfChallenge},
194 }
195 for _, tt := range tests {
196 r := list(t)
197 uassert.ErrorIs(t, r.Challenge(tt.key, tt.who, tt.bond, 200), tt.want, tt.name)
198 uassert.Equal(t, deposit, r.Locked(), tt.name+": no bond was taken")
199 _, open := r.ChallengeOf("gnoswap")
200 uassert.False(t, open, tt.name+": no challenge was opened")
201 }
202}
203
204func TestChallengeOpensOnceAndSetsTheDeadline(t *testing.T) {
205 r := list(t)
206
207 bond, ok := r.BondFor("gnoswap")
208 urequire.True(t, ok, "it is challengeable")
209 uassert.Equal(t, deposit, bond, "the bond matches that entry's own deposit")
210
211 urequire.NoError(t, r.Challenge("gnoswap", bob, bond, 200))
212 c, open := r.ChallengeOf("gnoswap")
213 urequire.True(t, open, "the challenge is readable")
214 uassert.Equal(t, bob.String(), c.Challenger.String())
215 uassert.Equal(t, int64(200+window), c.Deadline)
216 uassert.True(t, c.Open(200+window-1))
217 uassert.False(t, c.Open(200+window), "the deadline is exclusive")
218 uassert.Equal(t, "challenged", mustGet(t, r, "gnoswap").State.String())
219 uassert.True(t, r.IsListed("gnoswap"), "a challenge is an objection, not a verdict")
220 uassert.Equal(t, 2*deposit, r.Locked(), "the deposit and the bond")
221
222 // A second challenge would be a second bond on one entry.
223 uassert.ErrorIs(t, r.Challenge("gnoswap", carol, deposit, 201), ErrChallenged)
224 _, ok = r.BondFor("gnoswap")
225 uassert.False(t, ok, "and BondFor says so before any money is read")
226}
227
228func TestVoteIsOneAddressOneVoteWhileTheChallengeIsOpen(t *testing.T) {
229 r := list(t)
230 uassert.ErrorIs(t, r.Vote("gnoswap", carol, true, 200), ErrNoChallenge)
231 uassert.ErrorIs(t, r.Vote("nothing", carol, true, 200), ErrNoEntry)
232
233 urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200))
234 urequire.NoError(t, r.Vote("gnoswap", carol, true, 210))
235 uassert.ErrorIs(t, r.Vote("gnoswap", carol, false, 211), ErrAlreadyVoted)
236 uassert.ErrorIs(t, r.Vote("gnoswap", carol, true, 212), ErrAlreadyVoted)
237 urequire.NoError(t, r.Vote("gnoswap", dave, false, 213))
238
239 c, _ := r.ChallengeOf("gnoswap")
240 uassert.Equal(t, int64(1), c.Keep)
241 uassert.Equal(t, int64(1), c.Remove)
242 uassert.Equal(t, 2, c.Voters())
243 uassert.True(t, c.HasVoted(carol))
244 uassert.False(t, c.HasVoted(alice))
245
246 // Past the deadline the poll is shut, and so is the deadline itself.
247 uassert.ErrorIs(t, r.Vote("gnoswap", alice, true, 200+window), ErrVotingClosed)
248 uassert.Equal(t, 2, c.Voters())
249}
250
251// The outcome table: who wins, and what the loser pays. A tie keeps the entry.
252func TestResolvePaysTheWinnerAndTiesKeep(t *testing.T) {
253 tests := []struct {
254 name string
255 keep []address
256 remove []address
257 wantKept bool
258 wantWinner address
259 wantAmount int64
260 }{
261 {"nobody voted, the incumbent keeps it", nil, nil, true, alice, deposit},
262 {"a tie keeps it", []address{carol}, []address{dave}, true, alice, deposit},
263 {"a majority to keep", []address{carol, dave}, nil, true, alice, deposit},
264 {"a majority to remove", nil, []address{carol, dave}, false, bob, 2 * deposit},
265 }
266 for _, tt := range tests {
267 r := list(t)
268 urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200))
269 for _, who := range tt.keep {
270 urequire.NoError(t, r.Vote("gnoswap", who, true, 210))
271 }
272 for _, who := range tt.remove {
273 urequire.NoError(t, r.Vote("gnoswap", who, false, 210))
274 }
275
276 out, err := r.Resolve("gnoswap", 200+window)
277 urequire.NoError(t, err, tt.name)
278 uassert.Equal(t, tt.wantKept, out.Kept, tt.name)
279 uassert.Equal(t, tt.wantWinner.String(), out.Winner.String(), tt.name)
280 uassert.Equal(t, tt.wantAmount, out.Amount, tt.name)
281 uassert.Equal(t, tt.wantAmount, r.CreditOf(tt.wantWinner), tt.name+": credited, not sent")
282 uassert.Equal(t, tt.wantKept, r.IsListed("gnoswap"), tt.name)
283
284 // Whatever happened, every ugnot is either still backing the entry or
285 // assigned to somebody.
286 uassert.Equal(t, 2*deposit, r.Locked()+r.Owed(), tt.name+": the books balance")
287
288 // The challenge is gone either way, so nothing resolves twice.
289 _, open := r.ChallengeOf("gnoswap")
290 uassert.False(t, open, tt.name)
291 _, err = r.Resolve("gnoswap", 200+window)
292 uassert.ErrorIs(t, err, ErrNoChallenge, tt.name)
293 }
294}
295
296func TestResolveRefusesBeforeTheDeadline(t *testing.T) {
297 r := list(t)
298 _, err := r.Resolve("gnoswap", 200)
299 uassert.ErrorIs(t, err, ErrNoChallenge, "an unchallenged entry has nothing to resolve")
300 _, err = r.Resolve("nothing", 200)
301 uassert.ErrorIs(t, err, ErrNoEntry)
302
303 urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200))
304 _, err = r.Resolve("gnoswap", 200+window-1)
305 uassert.ErrorIs(t, err, ErrTooEarly)
306 uassert.Equal(t, int64(0), r.Owed(), "nothing was paid out early")
307 uassert.Equal(t, "challenged", mustGet(t, r, "gnoswap").State.String())
308}
309
310// Losing a challenge frees the key, and the deposit that backed it has already
311// gone to the challenger, so re-listing it costs a fresh deposit.
312func TestAnEntryThatLosesIsRemovedAndItsDepositIsGone(t *testing.T) {
313 r := list(t)
314 urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200))
315 urequire.NoError(t, r.Vote("gnoswap", carol, false, 210))
316 _, err := r.Resolve("gnoswap", 200+window)
317 urequire.NoError(t, err)
318
319 uassert.Equal(t, "removed", mustGet(t, r, "gnoswap").State.String())
320 uassert.Equal(t, 0, r.Count())
321 uassert.Equal(t, int64(0), r.Locked())
322 uassert.Equal(t, 2*deposit, r.Owed())
323 uassert.Equal(t, int64(0), r.CreditOf(alice), "the owner lost the deposit")
324 uassert.ErrorIs(t, r.Unlist("gnoswap", alice), ErrNoEntry, "there is nothing left to take down")
325}
326
327func TestUnlistIsTheOwnersAndNotWhileChallenged(t *testing.T) {
328 r := list(t)
329 uassert.ErrorIs(t, r.Unlist("nothing", alice), ErrNoEntry)
330 uassert.ErrorIs(t, r.Unlist("gnoswap", bob), ErrNotOwner)
331
332 urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200))
333 uassert.ErrorIs(t, r.Unlist("gnoswap", alice), ErrChallenged,
334 "an owner who could walk away mid-challenge would be risking nothing")
335 uassert.Equal(t, int64(0), r.CreditOf(alice))
336
337 // Once the challenge is behind it, the entry is the owner's to take down.
338 urequire.NoError(t, r.Vote("gnoswap", carol, true, 210))
339 _, err := r.Resolve("gnoswap", 200+window)
340 urequire.NoError(t, err)
341 urequire.NoError(t, r.Unlist("gnoswap", alice))
342 uassert.Equal(t, 2*deposit, r.CreditOf(alice), "the bond she won, plus her own deposit back")
343 uassert.Equal(t, int64(0), r.Locked())
344}
345
346func TestWithdrawZeroesTheCreditBeforeItIsPaid(t *testing.T) {
347 r := list(t)
348 urequire.NoError(t, r.Unlist("gnoswap", alice))
349 uassert.Equal(t, deposit, r.Owed())
350
351 _, err := r.Withdraw(bob)
352 uassert.ErrorIs(t, err, ErrNothingOwed, "bob is owed nothing")
353
354 got, err := r.Withdraw(alice)
355 urequire.NoError(t, err)
356 uassert.Equal(t, deposit, got)
357 uassert.Equal(t, int64(0), r.CreditOf(alice))
358 uassert.Equal(t, int64(0), r.Owed())
359
360 // A second call, which is what a reentrant one would be, finds nothing.
361 _, err = r.Withdraw(alice)
362 uassert.ErrorIs(t, err, ErrNothingOwed)
363}
364
365func TestCreditsAccumulateAcrossEntries(t *testing.T) {
366 r := list(t)
367 urequire.NoError(t, r.Apply("gnoscan", "https://gnoscan.io", "an explorer", alice, deposit, 101))
368 urequire.NoError(t, r.Unlist("gnoswap", alice))
369 urequire.NoError(t, r.Unlist("gnoscan", alice))
370
371 uassert.Equal(t, 2*deposit, r.CreditOf(alice), "crediting twice owes the sum")
372 got, err := r.Withdraw(alice)
373 urequire.NoError(t, err)
374 uassert.Equal(t, 2*deposit, got, "and one withdrawal collects both")
375}
376
377// Everything the registry ever took in is either locked behind something live
378// or owed to somebody, at every step of a full round.
379func TestTheBooksBalanceAtEveryStep(t *testing.T) {
380 r := New(deposit, window)
381 paidIn := int64(0)
382
383 check := func(step string) {
384 t.Helper()
385 uassert.Equal(t, paidIn, r.Locked()+r.Owed(), step)
386 }
387 check("empty")
388
389 urequire.NoError(t, r.Apply("one", "https://1.io", "first", alice, deposit, 100))
390 paidIn += deposit
391 check("after a listing")
392
393 urequire.NoError(t, r.Challenge("one", bob, deposit, 200))
394 paidIn += deposit
395 check("after a challenge")
396
397 urequire.NoError(t, r.Vote("one", carol, false, 210))
398 check("after a vote")
399
400 _, err := r.Resolve("one", 200+window)
401 urequire.NoError(t, err)
402 check("after a resolution")
403
404 _, err = r.Withdraw(bob)
405 urequire.NoError(t, err)
406 paidIn -= 2 * deposit
407 check("after the winner collected")
408 uassert.Equal(t, int64(0), r.Locked()+r.Owed(), "and the realm owes nothing")
409}
410
411func mustGet(t *testing.T, r *Registry, key string) *Entry {
412 t.Helper()
413 e, ok := r.Get(key)
414 urequire.True(t, ok, "entry "+key+" exists")
415 return e
416}