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}