Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}