Search Apps Documentation Source Content File Folder Download Copy Actions Download

allowancesender_test.gno

18.26 Kb · 553 lines
  1package allowancesender
  2
  3import (
  4	"chain"
  5	"testing"
  6
  7	"gno.land/p/nt/uassert/v0"
  8	"gno.land/p/nt/urequire/v0"
  9)
 10
 11// mockBanker implements banker.Banker for unit testing without
 12// touching real bank state. Records every SendCoins call.
 13//
 14// Since the public New constructor rejects non-canonical bankers via
 15// banker.IsCanonical, mockBanker cannot flow through New. Tests that
 16// need a controllable mock build the AllowanceSender via newWithMock,
 17// which uses in-package struct-literal construction. Same-package
 18// authorship is the trust boundary; the public API remains unforgeable
 19// to other packages.
 20type mockBanker struct {
 21	sends    []sendCall
 22	failNext bool   // if true, next SendCoins panics
 23	failMsg  string // panic message for failNext
 24}
 25
 26type sendCall struct {
 27	from, to address
 28	amt      chain.Coins
 29}
 30
 31func (m *mockBanker) GetCoins(addr address) chain.Coins { return nil }
 32func (m *mockBanker) SendCoins(from, to address, amt chain.Coins) {
 33	if m.failNext {
 34		m.failNext = false
 35		panic(m.failMsg)
 36	}
 37	m.sends = append(m.sends, sendCall{from, to, amt})
 38}
 39func (m *mockBanker) TotalCoin(denom string) int64                        { return 0 }
 40func (m *mockBanker) IssueCoin(addr address, denom string, amount int64)  {}
 41func (m *mockBanker) RemoveCoin(addr address, denom string, amount int64) {}
 42
 43// newWithMock constructs an *AllowanceSender wrapping a mockBanker via
 44// struct-literal initialization, bypassing New (which would reject the
 45// mock as non-canonical). Used only in this test file.
 46func newWithMock(mb *mockBanker, p address, limit chain.Coins) *AllowanceSender {
 47	return &AllowanceSender{
 48		inner: mb,
 49		payer: p,
 50		limit: limit,
 51	}
 52}
 53
 54var (
 55	payer = address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")
 56	bob   = address("g1mtmrdmqfu0aryqfl4aw65n35haw2wdjkh5p4cp")
 57	carol = address("g16dverxg6s6vzha89k22c3zusx6sug5ycjyjq2j")
 58)
 59
 60// ---------------------------------------------------------------------
 61// Constructor tests
 62// ---------------------------------------------------------------------
 63
 64func TestNew_Valid(t *testing.T) {
 65	mb := &mockBanker{}
 66	limit := chain.NewCoins(chain.NewCoin("ugnot", 1000))
 67	al := newWithMock(mb, payer, limit)
 68
 69	uassert.Equal(t, false, al.Closed())
 70	uassert.Equal(t, payer.String(), al.Payer().String())
 71	uassert.Equal(t, limit.String(), al.Cap().String())
 72}
 73
 74func TestNew_NonCanonicalBanker(t *testing.T) {
 75	// A hand-rolled banker.Banker (mockBanker) is not the canonical
 76	// chain/banker.Banker; New must reject it. This is the security
 77	// hole the IsCanonical gate closes — without it, a malicious caller
 78	// could pass a no-op banker and cause AllowanceSender.Send to
 79	// silently succeed without moving real coins.
 80	mb := &mockBanker{}
 81	uassert.PanicsWithMessage(t,
 82		"allowancesender: inner banker is not the canonical chain/banker.Banker",
 83		func() {
 84			New(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1)))
 85		})
 86}
 87
 88func TestNew_NilInner(t *testing.T) {
 89	// Nil also fails IsCanonical (nil interface assertion is false).
 90	uassert.PanicsWithMessage(t,
 91		"allowancesender: inner banker is not the canonical chain/banker.Banker",
 92		func() {
 93			New(nil, payer, chain.NewCoins(chain.NewCoin("ugnot", 1)))
 94		})
 95}
 96
 97func TestNew_EmptyCap(t *testing.T) {
 98	// Empty cap is valid at construction (every Send will panic with
 99	// "cap exceeded"). Useful for "preallocated zero-allowance" cases.
100	mb := &mockBanker{}
101	al := newWithMock(mb, payer, chain.Coins{})
102	uassert.Equal(t, false, al.Closed())
103}
104
105// ---------------------------------------------------------------------
106// Send happy-path tests
107// ---------------------------------------------------------------------
108
109func TestSend_WithinCap(t *testing.T) {
110	mb := &mockBanker{}
111	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
112
113	urequire.NotPanics(t, func() {
114		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 400)))
115	})
116
117	uassert.Equal(t, 1, len(mb.sends))
118	uassert.Equal(t, payer.String(), mb.sends[0].from.String())
119	uassert.Equal(t, bob.String(), mb.sends[0].to.String())
120	uassert.Equal(t, int64(400), mb.sends[0].amt.AmountOf("ugnot"))
121}
122
123func TestSend_MultipleWithinCap(t *testing.T) {
124	mb := &mockBanker{}
125	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
126
127	urequire.NotPanics(t, func() {
128		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 300)))
129	})
130	urequire.NotPanics(t, func() {
131		al.Send(carol, chain.NewCoins(chain.NewCoin("ugnot", 600)))
132	})
133
134	uassert.Equal(t, 2, len(mb.sends))
135	uassert.Equal(t, int64(900), al.Spent().AmountOf("ugnot"))
136	uassert.Equal(t, int64(100), al.Remaining().AmountOf("ugnot"))
137}
138
139func TestSend_ExactlyAtCap(t *testing.T) {
140	mb := &mockBanker{}
141	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
142
143	urequire.NotPanics(t, func() {
144		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
145	})
146	uassert.Equal(t, int64(1000), al.Spent().AmountOf("ugnot"))
147	uassert.Equal(t, 0, len(al.Remaining()))
148}
149
150// ---------------------------------------------------------------------
151// Send rejection tests
152// ---------------------------------------------------------------------
153
154func TestSend_ExceedsCapInOneCall(t *testing.T) {
155	mb := &mockBanker{}
156	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
157
158	uassert.PanicsWithMessage(t, "allowancesender: cap exceeded for denom ugnot", func() {
159		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 1001)))
160	})
161	// State must not change on rejection.
162	uassert.Equal(t, 0, len(mb.sends))
163	uassert.Equal(t, int64(0), al.Spent().AmountOf("ugnot"))
164}
165
166func TestSend_ExceedsCapAcrossCalls(t *testing.T) {
167	mb := &mockBanker{}
168	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
169
170	urequire.NotPanics(t, func() {
171		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 700)))
172	})
173	uassert.PanicsWithMessage(t, "allowancesender: cap exceeded for denom ugnot", func() {
174		al.Send(carol, chain.NewCoins(chain.NewCoin("ugnot", 400)))
175	})
176	// First send happened, second didn't.
177	uassert.Equal(t, 1, len(mb.sends))
178	uassert.Equal(t, int64(700), al.Spent().AmountOf("ugnot"))
179}
180
181func TestSend_DenomNotInCap(t *testing.T) {
182	mb := &mockBanker{}
183	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
184
185	// "atom" is not in cap; capAmt=0; any positive amount panics.
186	uassert.PanicsWithMessage(t, "allowancesender: cap exceeded for denom atom", func() {
187		al.Send(bob, chain.NewCoins(chain.NewCoin("atom", 1)))
188	})
189}
190
191func TestSend_ZeroCap(t *testing.T) {
192	mb := &mockBanker{}
193	al := newWithMock(mb, payer, chain.Coins{}) // empty cap
194
195	uassert.PanicsWithMessage(t, "allowancesender: cap exceeded for denom ugnot", func() {
196		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 1)))
197	})
198}
199
200func TestSend_AfterClose(t *testing.T) {
201	mb := &mockBanker{}
202	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
203
204	al.Close()
205	uassert.True(t, al.Closed())
206
207	uassert.PanicsWithMessage(t, "allowancesender: closed", func() {
208		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 100)))
209	})
210	uassert.Equal(t, 0, len(mb.sends))
211}
212
213// ---------------------------------------------------------------------
214// Multi-denom tests
215// ---------------------------------------------------------------------
216
217func TestSend_MultiDenomCap(t *testing.T) {
218	mb := &mockBanker{}
219	al := newWithMock(mb, payer, chain.NewCoins(
220		chain.NewCoin("ugnot", 1000),
221		chain.NewCoin("foo", 50),
222	))
223
224	// Spend on both denoms within their caps.
225	urequire.NotPanics(t, func() {
226		al.Send(bob, chain.NewCoins(
227			chain.NewCoin("ugnot", 600),
228			chain.NewCoin("foo", 30),
229		))
230	})
231
232	uassert.Equal(t, int64(600), al.Spent().AmountOf("ugnot"))
233	uassert.Equal(t, int64(30), al.Spent().AmountOf("foo"))
234
235	rem := al.Remaining()
236	uassert.Equal(t, int64(400), rem.AmountOf("ugnot"))
237	uassert.Equal(t, int64(20), rem.AmountOf("foo"))
238}
239
240func TestSend_OneDenomExceedsBlocksAll(t *testing.T) {
241	mb := &mockBanker{}
242	al := newWithMock(mb, payer, chain.NewCoins(
243		chain.NewCoin("ugnot", 1000),
244		chain.NewCoin("foo", 50),
245	))
246
247	// foo would exceed (51 > 50); whole send rejected, ugnot doesn't go through.
248	uassert.PanicsWithMessage(t, "allowancesender: cap exceeded for denom foo", func() {
249		al.Send(bob, chain.NewCoins(
250			chain.NewCoin("ugnot", 100),
251			chain.NewCoin("foo", 51),
252		))
253	})
254
255	// Neither denom should have been spent.
256	uassert.Equal(t, int64(0), al.Spent().AmountOf("ugnot"))
257	uassert.Equal(t, int64(0), al.Spent().AmountOf("foo"))
258	uassert.Equal(t, 0, len(mb.sends))
259}
260
261// ---------------------------------------------------------------------
262// Close behavior
263// ---------------------------------------------------------------------
264
265func TestClose_Idempotent(t *testing.T) {
266	mb := &mockBanker{}
267	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
268
269	al.Close()
270	urequire.NotPanics(t, func() { al.Close() })
271	urequire.NotPanics(t, func() { al.Close() })
272	uassert.True(t, al.Closed())
273}
274
275func TestClose_DoesNotInterfereWithReadMethods(t *testing.T) {
276	mb := &mockBanker{}
277	limit := chain.NewCoins(chain.NewCoin("ugnot", 1000))
278	al := newWithMock(mb, payer, limit)
279
280	urequire.NotPanics(t, func() {
281		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 250)))
282	})
283	al.Close()
284
285	// Read methods must keep working after close.
286	uassert.Equal(t, limit.String(), al.Cap().String())
287	uassert.Equal(t, int64(250), al.Spent().AmountOf("ugnot"))
288	uassert.Equal(t, int64(750), al.Remaining().AmountOf("ugnot"))
289	uassert.True(t, al.Closed())
290}
291
292// ---------------------------------------------------------------------
293// Inner-banker panic tests
294// ---------------------------------------------------------------------
295
296func TestSend_InnerPanicRollsBackSpent(t *testing.T) {
297	mb := &mockBanker{failNext: true, failMsg: "insufficient funds"}
298	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
299
300	uassert.PanicsWithMessage(t, "insufficient funds", func() {
301		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 500)))
302	})
303
304	// On inner panic, spent must be rolled back.
305	uassert.Equal(t, int64(0), al.Spent().AmountOf("ugnot"))
306	uassert.Equal(t, int64(1000), al.Remaining().AmountOf("ugnot"))
307
308	// And subsequent valid Send still works.
309	urequire.NotPanics(t, func() {
310		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 200)))
311	})
312	uassert.Equal(t, int64(200), al.Spent().AmountOf("ugnot"))
313}
314
315// ---------------------------------------------------------------------
316// Remaining / Spent edge cases
317// ---------------------------------------------------------------------
318
319func TestRemaining_EmptyCap(t *testing.T) {
320	mb := &mockBanker{}
321	al := newWithMock(mb, payer, chain.Coins{})
322
323	uassert.Equal(t, 0, len(al.Remaining()))
324}
325
326func TestRemaining_FullySpent(t *testing.T) {
327	mb := &mockBanker{}
328	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 100)))
329
330	urequire.NotPanics(t, func() {
331		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 100)))
332	})
333	uassert.Equal(t, 0, len(al.Remaining()))
334}
335
336func TestRemaining_PartialAcrossDenoms(t *testing.T) {
337	mb := &mockBanker{}
338	al := newWithMock(mb, payer, chain.NewCoins(
339		chain.NewCoin("ugnot", 1000),
340		chain.NewCoin("foo", 50),
341	))
342
343	// Spend all of foo, half of ugnot.
344	urequire.NotPanics(t, func() {
345		al.Send(bob, chain.NewCoins(
346			chain.NewCoin("ugnot", 500),
347			chain.NewCoin("foo", 50),
348		))
349	})
350
351	rem := al.Remaining()
352	// foo should be omitted (zero remainder); ugnot has 500 left.
353	uassert.Equal(t, 1, len(rem))
354	uassert.Equal(t, int64(500), rem.AmountOf("ugnot"))
355	uassert.Equal(t, int64(0), rem.AmountOf("foo"))
356}
357
358// ---------------------------------------------------------------------
359// Pointer semantics: shared state across copies
360// ---------------------------------------------------------------------
361
362func TestPointerSemantics_CloseVisibleViaAlias(t *testing.T) {
363	mb := &mockBanker{}
364	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
365
366	// Aliasing the pointer should share state.
367	alias := al
368	urequire.NotPanics(t, func() {
369		alias.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 100)))
370	})
371	uassert.Equal(t, int64(100), al.Spent().AmountOf("ugnot"))
372
373	// Close via either reference.
374	alias.Close()
375	uassert.True(t, al.Closed())
376	uassert.True(t, alias.Closed())
377
378	uassert.PanicsWithMessage(t, "allowancesender: closed", func() {
379		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 1)))
380	})
381}
382
383// ---------------------------------------------------------------------
384// Defensive copy: mutating returned Coins must not leak into state
385// ---------------------------------------------------------------------
386
387func TestCap_ReturnsDefensiveCopy(t *testing.T) {
388	mb := &mockBanker{}
389	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
390
391	// Mutate the returned cap; internal state must not change.
392	got := al.Cap()
393	if len(got) > 0 {
394		got[0].Amount = 9_999_999_999
395	}
396
397	// Confirm internal state intact: re-read Cap.
398	again := al.Cap()
399	uassert.Equal(t, int64(1000), again.AmountOf("ugnot"))
400
401	// Confirm enforcement still works: 1001 should still be rejected.
402	uassert.PanicsWithMessage(t, "allowancesender: cap exceeded for denom ugnot", func() {
403		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 1001)))
404	})
405}
406
407func TestSpent_ReturnsDefensiveCopy(t *testing.T) {
408	mb := &mockBanker{}
409	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
410
411	urequire.NotPanics(t, func() {
412		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 300)))
413	})
414
415	got := al.Spent()
416	if len(got) > 0 {
417		got[0].Amount = 0 // try to "rewind" spent
418	}
419
420	// Internal spent must remain accurate.
421	uassert.Equal(t, int64(300), al.Spent().AmountOf("ugnot"))
422	uassert.Equal(t, int64(700), al.Remaining().AmountOf("ugnot"))
423}
424
425// ---------------------------------------------------------------------
426// Independence of separate AllowanceSenders
427// ---------------------------------------------------------------------
428
429func TestMultipleAllowances_Independent(t *testing.T) {
430	mb := &mockBanker{}
431	a1 := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 100)))
432	a2 := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 200)))
433
434	urequire.NotPanics(t, func() {
435		a1.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 80)))
436	})
437	urequire.NotPanics(t, func() {
438		a2.Send(carol, chain.NewCoins(chain.NewCoin("ugnot", 150)))
439	})
440
441	uassert.Equal(t, int64(80), a1.Spent().AmountOf("ugnot"))
442	uassert.Equal(t, int64(150), a2.Spent().AmountOf("ugnot"))
443
444	// Closing one must not close the other.
445	a1.Close()
446	uassert.True(t, a1.Closed())
447	uassert.False(t, a2.Closed())
448
449	urequire.NotPanics(t, func() {
450		a2.Send(carol, chain.NewCoins(chain.NewCoin("ugnot", 50)))
451	})
452	uassert.PanicsWithMessage(t, "allowancesender: closed", func() {
453		a1.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 1)))
454	})
455}
456
457// ---------------------------------------------------------------------
458// Overflow / negative-amount adversarial inputs
459// ---------------------------------------------------------------------
460
461// Adversary attempts to bypass the cap check by passing a value that
462// overflows int64 when added to an existing spent counter. Must panic
463// with the overflow-specific message rather than silently wrapping
464// (which would let the cap check pass and allow an absurd send).
465func TestSend_OverflowAttempt(t *testing.T) {
466	mb := &mockBanker{}
467	const maxInt64 = int64(9223372036854775807)
468	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", maxInt64)))
469
470	// First spend uses most of the cap.
471	urequire.NotPanics(t, func() {
472		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", maxInt64-100)))
473	})
474
475	// Now spent ≈ MaxInt64 - 100. An adversary tries to pass amount
476	// 200, which would overflow when added to spent. Must panic with
477	// "amount overflow", not silently wrap to negative and bypass cap.
478	uassert.PanicsWithMessage(t, "allowancesender: amount overflow on spent+amt for denom ugnot", func() {
479		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 200)))
480	})
481
482	// State must not have changed.
483	uassert.Equal(t, maxInt64-100, al.Spent().AmountOf("ugnot"))
484	// Bank-side recorded only the first valid send.
485	uassert.Equal(t, 1, len(mb.sends))
486}
487
488func TestSend_NegativeAmount(t *testing.T) {
489	mb := &mockBanker{}
490	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
491
492	// Spend something so we have a non-zero spent.
493	urequire.NotPanics(t, func() {
494		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 200)))
495	})
496
497	// Adversary tries to "uncharge" the allowance by passing a negative
498	// amount. Without this guard, spent would decrease, allowing more
499	// future spends than originally permitted.
500	uassert.PanicsWithMessage(t, "allowancesender: negative amount not allowed for denom ugnot", func() {
501		al.Send(bob, chain.Coins{chain.Coin{Denom: "ugnot", Amount: -50}})
502	})
503
504	// Spent is unchanged after the rejected attempt.
505	uassert.Equal(t, int64(200), al.Spent().AmountOf("ugnot"))
506}
507
508// Adversary's defer-recover swallows the panic from inner.SendCoins to
509// hide a failed send. Verifies that even with recovery, spent has been
510// rolled back — i.e., a recovered panic doesn't burn allowance against
511// a transfer that never moved funds.
512func TestSend_RecoveredPanicDoesNotBurnAllowance(t *testing.T) {
513	mb := &mockBanker{failNext: true, failMsg: "insufficient funds"}
514	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
515
516	// Caller deliberately recovers from the inner panic.
517	func() {
518		defer func() {
519			_ = recover() // swallow
520		}()
521		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 500)))
522	}()
523
524	// After the recovered panic: spent must be 0 (no funds moved →
525	// allowance must not be charged).
526	uassert.Equal(t, int64(0), al.Spent().AmountOf("ugnot"))
527	uassert.Equal(t, int64(1000), al.Remaining().AmountOf("ugnot"))
528
529	// And subsequent valid Send (with non-failing inner) succeeds with
530	// full cap available.
531	urequire.NotPanics(t, func() {
532		al.Send(bob, chain.NewCoins(chain.NewCoin("ugnot", 800)))
533	})
534	uassert.Equal(t, int64(800), al.Spent().AmountOf("ugnot"))
535}
536
537// ---------------------------------------------------------------------
538// Empty/zero-amount edge cases
539// ---------------------------------------------------------------------
540
541func TestSend_EmptyAmt(t *testing.T) {
542	mb := &mockBanker{}
543	al := newWithMock(mb, payer, chain.NewCoins(chain.NewCoin("ugnot", 1000)))
544
545	// Sending zero coins should not panic; just no-op forwards to inner.
546	urequire.NotPanics(t, func() {
547		al.Send(bob, chain.Coins{})
548	})
549
550	// inner.SendCoins was still called (the wrapper doesn't filter
551	// zero sends; that's the inner banker's choice). State unchanged.
552	uassert.Equal(t, int64(0), al.Spent().AmountOf("ugnot"))
553}