feeledger_test.gno
9.85 Kb · 330 lines
1package feeledger
2
3import "testing"
4
5const (
6 maxInt64 = int64(1<<63 - 1)
7 minInt64 = int64(-1 << 63)
8)
9
10// mustPanicWith asserts fn panics with an error whose message is want.
11// Non-crossing panics in the same package are recoverable.
12func mustPanicWith(t *testing.T, want string, fn func()) {
13 t.Helper()
14 defer func() {
15 r := recover()
16 if r == nil {
17 t.Fatalf("expected panic %q, got none", want)
18 }
19 err, ok := r.(error)
20 if !ok || err.Error() != want {
21 t.Fatalf("panic = %v, want %q", r, want)
22 }
23 }()
24 fn()
25}
26
27func TestNewBounds(t *testing.T) {
28 for _, bad := range []int64{-1, BpsDenominator + 1, minInt64, maxInt64} {
29 if _, err := New(bad); err != ErrInvalidBps {
30 t.Fatalf("New(%d) err = %v, want ErrInvalidBps", bad, err)
31 }
32 }
33 for _, ok := range []int64{0, 1, BpsDenominator} {
34 if _, err := New(ok); err != nil {
35 t.Fatalf("New(%d) err = %v", ok, err)
36 }
37 }
38 mustPanicWith(t, ErrInvalidBps.Error(), func() { MustNew(-1) })
39}
40
41func TestFeeForRounding(t *testing.T) {
42 // floor(max * 0.9999) computed the same overflow-free way feeFor does.
43 wantMaxAt9999 := maxInt64/10000*9999 + maxInt64%10000*9999/10000
44 cases := []struct {
45 amount, bps, want int64
46 }{
47 {999, 100, 9}, // 9.99 floors to 9
48 {10000, 1, 1}, // exact
49 {9999, 1, 0}, // 0.9999 floors to 0 — no minimum fee
50 {1, 9999, 0}, // 0.9999 floors to 0
51 {10, 9999, 9}, // 9.999 floors to 9
52 {100, 10000, 100}, // full-fee bps: fee == amount
53 {0, 500, 0}, // zero amount previews zero fee
54 {1000, 0, 0}, // zero-fee config
55 {1000, 250, 25}, // exact 2.5%
56 {999, 250, 24}, // 24.975 floors to 24
57 {maxInt64, 10000, maxInt64}, // no intermediate overflow
58 {maxInt64, 9999, wantMaxAt9999},
59 }
60 for _, c := range cases {
61 got, err := FeeFor(c.amount, c.bps)
62 if err != nil {
63 t.Fatalf("FeeFor(%d,%d) err = %v", c.amount, c.bps, err)
64 }
65 if got != c.want {
66 t.Fatalf("FeeFor(%d,%d) = %d, want %d", c.amount, c.bps, got, c.want)
67 }
68 if got < 0 || got > c.amount {
69 t.Fatalf("FeeFor(%d,%d) = %d out of [0,amount]", c.amount, c.bps, got)
70 }
71 }
72 if _, err := FeeFor(-1, 100); err != ErrInvalidAmount {
73 t.Fatalf("FeeFor(-1,100) err = %v, want ErrInvalidAmount", err)
74 }
75 if _, err := FeeFor(100, -1); err != ErrInvalidBps {
76 t.Fatalf("FeeFor(100,-1) err = %v, want ErrInvalidBps", err)
77 }
78 if _, err := FeeFor(100, BpsDenominator+1); err != ErrInvalidBps {
79 t.Fatalf("FeeFor over-bps err = %v, want ErrInvalidBps", err)
80 }
81}
82
83func TestDepositValidation(t *testing.T) {
84 l := MustNew(500)
85 type bad struct {
86 account string
87 amount, bps int64
88 want error
89 }
90 for _, c := range []bad{
91 {"", 100, 0, ErrEmptyAccount},
92 {"alice", 0, 0, ErrInvalidAmount},
93 {"alice", -7, 0, ErrInvalidAmount},
94 {"alice", minInt64, 0, ErrInvalidAmount},
95 {"alice", 100, -1, ErrInvalidBps},
96 {"alice", 100, 501, ErrInvalidBps}, // above the ledger cap
97 } {
98 if _, _, err := l.Deposit(c.account, c.amount, c.bps); err != c.want {
99 t.Fatalf("Deposit(%q,%d,%d) err = %v, want %v", c.account, c.amount, c.bps, err, c.want)
100 }
101 }
102 if l.UsersTotal() != 0 || l.FeesAccrued() != 0 || l.Accounts() != 0 {
103 t.Fatal("failed deposits mutated state")
104 }
105}
106
107func TestDepositAndAccumulate(t *testing.T) {
108 l := MustNew(1000)
109 credited, fee, err := l.Deposit("alice", 1000, 250)
110 if err != nil || credited != 975 || fee != 25 {
111 t.Fatalf("Deposit = (%d,%d,%v), want (975,25,nil)", credited, fee, err)
112 }
113 credited, fee, err = l.Deposit("alice", 999, 250)
114 if err != nil || credited != 975 || fee != 24 { // fee floors, credit rounds up
115 t.Fatalf("Deposit#2 = (%d,%d,%v), want (975,24,nil)", credited, fee, err)
116 }
117 if got := l.BalanceOf("alice"); got != 1950 {
118 t.Fatalf("BalanceOf = %d, want 1950", got)
119 }
120 if l.UsersTotal() != 1950 || l.FeesAccrued() != 49 || l.Liabilities() != 1999 {
121 t.Fatalf("totals = %d/%d/%d", l.UsersTotal(), l.FeesAccrued(), l.Liabilities())
122 }
123 // credited + fee always reassembles both deposits exactly.
124 if l.Liabilities() != 1000+999 {
125 t.Fatal("conservation broken: credited+fee != deposited")
126 }
127}
128
129func TestFullFeeBpsCreditsZero(t *testing.T) {
130 l := MustNew(BpsDenominator)
131 credited, fee, err := l.Deposit("alice", 777, BpsDenominator)
132 if err != nil || credited != 0 || fee != 777 {
133 t.Fatalf("Deposit = (%d,%d,%v), want (0,777,nil)", credited, fee, err)
134 }
135 if l.Accounts() != 0 || l.BalanceOf("alice") != 0 {
136 t.Fatal("zero-credit deposit must not create an account entry")
137 }
138 if l.FeesAccrued() != 777 || l.UsersTotal() != 0 {
139 t.Fatal("fee pot mismatch on full-fee deposit")
140 }
141}
142
143func TestDepositOverflow(t *testing.T) {
144 l := MustNew(BpsDenominator)
145 if _, _, err := l.Deposit("alice", maxInt64, 0); err != nil {
146 t.Fatalf("seed deposit err = %v", err)
147 }
148 // Account balance would overflow.
149 if _, _, err := l.Deposit("alice", 1, 0); err != ErrOverflow {
150 t.Fatalf("balance-overflow err = %v, want ErrOverflow", err)
151 }
152 // Joint liabilities (users + fees) would overflow even though the
153 // fee pot alone would not.
154 if _, _, err := l.Deposit("bob", 100, BpsDenominator); err != ErrOverflow {
155 t.Fatalf("liabilities-overflow err = %v, want ErrOverflow", err)
156 }
157 if l.BalanceOf("bob") != 0 || l.FeesAccrued() != 0 || l.UsersTotal() != maxInt64 {
158 t.Fatal("failed overflow deposits mutated state")
159 }
160}
161
162func TestWithdraw(t *testing.T) {
163 l := MustNew(0)
164 l.MustDeposit("alice", 1000, 0)
165
166 if err := l.Withdraw("", 10); err != ErrEmptyAccount {
167 t.Fatalf("empty account err = %v", err)
168 }
169 if err := l.Withdraw("alice", 0); err != ErrInvalidAmount {
170 t.Fatalf("zero amount err = %v", err)
171 }
172 if err := l.Withdraw("alice", -3); err != ErrInvalidAmount {
173 t.Fatalf("negative amount err = %v", err)
174 }
175 if err := l.Withdraw("alice", 1001); err != ErrInsufficient {
176 t.Fatalf("over-balance err = %v", err)
177 }
178 if err := l.Withdraw("ghost", 1); err != ErrInsufficient {
179 t.Fatalf("absent account err = %v", err)
180 }
181 if l.BalanceOf("alice") != 1000 || l.UsersTotal() != 1000 {
182 t.Fatal("failed withdrawals mutated state")
183 }
184
185 if err := l.Withdraw("alice", 400); err != nil {
186 t.Fatalf("partial withdraw err = %v", err)
187 }
188 if l.BalanceOf("alice") != 600 || l.UsersTotal() != 600 || l.Accounts() != 1 {
189 t.Fatal("partial withdraw wrong state")
190 }
191 if err := l.Withdraw("alice", 600); err != nil {
192 t.Fatalf("exact withdraw err = %v", err)
193 }
194 if l.BalanceOf("alice") != 0 || l.UsersTotal() != 0 || l.Accounts() != 0 {
195 t.Fatal("exact withdraw must remove the account entry")
196 }
197 if err := l.Withdraw("alice", 1); err != ErrInsufficient {
198 t.Fatalf("repeat withdraw err = %v, want ErrInsufficient", err)
199 }
200 mustPanicWith(t, ErrInsufficient.Error(), func() { l.MustWithdraw("alice", 1) })
201}
202
203func TestWithdrawAll(t *testing.T) {
204 l := MustNew(0)
205 if got, err := l.WithdrawAll("nobody"); err != nil || got != 0 {
206 t.Fatalf("WithdrawAll(nobody) = (%d,%v), want (0,nil)", got, err)
207 }
208 if _, err := l.WithdrawAll(""); err != ErrEmptyAccount {
209 t.Fatalf("WithdrawAll(\"\") err = %v", err)
210 }
211 l.MustDeposit("alice", 555, 0)
212 if got, err := l.WithdrawAll("alice"); err != nil || got != 555 {
213 t.Fatalf("WithdrawAll = (%d,%v), want (555,nil)", got, err)
214 }
215 if got, err := l.WithdrawAll("alice"); err != nil || got != 0 {
216 t.Fatalf("repeat WithdrawAll = (%d,%v), want (0,nil)", got, err)
217 }
218 if l.UsersTotal() != 0 || l.Accounts() != 0 {
219 t.Fatal("WithdrawAll left state behind")
220 }
221}
222
223func TestWithdrawFees(t *testing.T) {
224 l := MustNew(1000)
225 l.MustDeposit("alice", 10000, 1000) // fee 1000
226 l.MustDeposit("bob", 10000, 1000) // fee 1000
227 if got := l.WithdrawFees(); got != 2000 {
228 t.Fatalf("WithdrawFees = %d, want 2000", got)
229 }
230 if got := l.WithdrawFees(); got != 0 {
231 t.Fatalf("repeat WithdrawFees = %d, want 0", got)
232 }
233 if l.FeesAccrued() != 0 || l.UsersTotal() != 18000 {
234 t.Fatal("WithdrawFees corrupted totals")
235 }
236}
237
238func TestConservationAcrossSequence(t *testing.T) {
239 l := MustNew(1000)
240 deposited := int64(0)
241 withdrawn := int64(0)
242 feesOut := int64(0)
243
244 step := func() {
245 t.Helper()
246 // Internal consistency: usersTotal == sum of balances.
247 sum := int64(0)
248 l.Iterate(func(_ string, b int64) bool {
249 if b <= 0 {
250 t.Fatal("zero/negative balance stored")
251 }
252 sum += b
253 return false
254 })
255 if sum != l.UsersTotal() {
256 t.Fatalf("usersTotal %d != Σ balances %d", l.UsersTotal(), sum)
257 }
258 // Conservation: everything in == everything owed + everything out.
259 if deposited != l.Liabilities()+withdrawn+feesOut {
260 t.Fatalf("conservation: in=%d owed=%d out=%d+%d",
261 deposited, l.Liabilities(), withdrawn, feesOut)
262 }
263 }
264
265 deposit := func(who string, amt, bps int64) {
266 l.MustDeposit(who, amt, bps)
267 deposited += amt
268 step()
269 }
270 claim := func(who string, amt int64) {
271 l.MustWithdraw(who, amt)
272 withdrawn += amt
273 step()
274 }
275
276 deposit("alice", 1000, 0)
277 deposit("bob", 999, 250)
278 deposit("alice", 12345, 1000)
279 claim("alice", 1000)
280 deposit("carol", 1, 1000) // fee floors to 0
281 feesOut += l.WithdrawFees()
282 step()
283 claim("bob", 974)
284 all, _ := l.WithdrawAll("alice")
285 withdrawn += all
286 step()
287 deposit("dave", 7, 999)
288 feesOut += l.WithdrawFees()
289 step()
290}
291
292func TestCheckedAddBranches(t *testing.T) {
293 cases := []struct {
294 a, b, want int64
295 ok bool
296 }{
297 {5, 3, 8, true},
298 {5, -3, 2, true}, // negative-b branch, no overflow
299 {-4, -6, -10, true},
300 {maxInt64, 1, 0, false}, // positive overflow
301 {minInt64, -1, 0, false}, // negative overflow
302 {maxInt64, -maxInt64, 0, true},
303 }
304 for _, c := range cases {
305 got, ok := checkedAdd(c.a, c.b)
306 if ok != c.ok || (ok && got != c.want) {
307 t.Fatalf("checkedAdd(%d,%d) = (%d,%v), want (%d,%v)", c.a, c.b, got, ok, c.want, c.ok)
308 }
309 }
310}
311
312func TestIterateSortedAndEarlyStop(t *testing.T) {
313 l := MustNew(0)
314 l.MustDeposit("carol", 3, 0)
315 l.MustDeposit("alice", 1, 0)
316 l.MustDeposit("bob", 2, 0)
317 got := ""
318 l.Iterate(func(a string, _ int64) bool {
319 got += a + ","
320 return false
321 })
322 if got != "alice,bob,carol," {
323 t.Fatalf("Iterate order = %q", got)
324 }
325 n := 0
326 l.Iterate(func(string, int64) bool { n++; return n == 2 })
327 if n != 2 {
328 t.Fatalf("early stop visited %d", n)
329 }
330}