allowancesender.gno
12.58 Kb · 322 lines
1// Package allowancesender provides a bounded, granter-revocable
2// spending capability that wraps a banker.Banker source.
3//
4// # Use case
5//
6// Realm A wants to grant realm B the ability to spend up to some
7// bounded amount from A's address (or A's tx envelope) within a single
8// call, without giving B unbounded access. After A's call to B returns,
9// A revokes the capability via Close(); B cannot use it across tx
10// boundaries even if it persisted the reference.
11//
12// # Canonical pattern
13//
14// src := banker.NewBanker(banker.BankerTypeRealmSend)
15// al := allowancesender.New(src, granterAddr,
16// chain.NewCoins(chain.NewCoin("ugnot", 1_000_000)))
17// defer al.Close()
18// bRealm.DoSomething(cross, al) // B can call al.Send up to cap
19//
20// On return (success or panic), `defer al.Close()` flips the closed
21// flag. If B persisted al, B's stored reference is now dead — any
22// later al.Send() panics with "closed".
23//
24// # Naming note
25//
26// Despite the underlying type wrapping a banker.Banker, *AllowanceSender
27// does NOT satisfy the banker.Banker interface. The other Banker methods
28// (GetCoins, TotalCoin, IssueCoin, RemoveCoin) have no meaningful
29// behavior on an allowance abstraction:
30//
31// - GetCoins would either leak the granter realm's full balance
32// (misleading) or return Remaining() (lying about the addr arg).
33// - TotalCoin/IssueCoin/RemoveCoin are out of scope.
34//
35// Send is the only meaningful operation; hence the "Sender" name. If
36// you need to plug into a banker.Banker-shaped API, write an adapter
37// in your own package.
38//
39// # Security model
40//
41// - Pointer-based API. All callees that hold a *AllowanceSender share
42// the same underlying state. Close() flips a bool that every reader
43// observes synchronously.
44// - Inner banker is an unexported field. Callees cannot extract it
45// to bypass the wrapper. Language-enforced.
46// - Cap is enforced per-denom via chain.Coins.AmountOf. Any denom in
47// amt that is missing from cap implicitly has capAmt=0; positive
48// spend on such a denom panics with "cap exceeded".
49// - Persistence is the kill switch's friend, not its enemy. The
50// closed flag persists with the struct, so a callee that stored
51// the reference for cross-tx use sees a closed allowance.
52// - Defer-close survives panic. If B's call panics, the tx reverts
53// entirely (so the close is irrelevant — state reverts anyway). On
54// success, defer fires before granter's function returns,
55// committing closed=true with the rest of the tx state.
56// - Cap/Spent return defensive copies. Mutating the returned
57// chain.Coins does not affect internal state.
58// - Inner banker panic on SendCoins (e.g. bank-keeper "insufficient
59// funds") rolls back the spent counter — caller's accounting stays
60// accurate even if a defer-recover swallows the panic. (Without
61// recovery, the entire tx reverts and the rollback is moot.)
62// - Arithmetic uses math/overflow.Add64. Overflow on (spent+amount)
63// panics rather than silently wrapping (which could bypass the cap
64// check). Negative amounts are explicitly rejected.
65//
66// # Design choices
67//
68// - Pointer type, not value: forces shared state across references.
69// Value-copy AllowanceSender would have separate closed flags and
70// defeat the kill switch.
71// - One-shot Close, not graduated: simpler invariant. If you need
72// "resume later," create a fresh allowance.
73// - No destination allowlist: out of scope. If the granter wants to
74// restrict where funds go, wrap this further.
75// - No time-based expiry: rely on Close. Block-height stamping or
76// deadline checks would require runtime cooperation; this package
77// is pure Gno.
78// - Cap is multi-denom (chain.Coins, not int64): supports
79// heterogeneous payment policies. Most callers use single-denom.
80// - Idempotent Close: safe to defer-Close even if Close was called
81// manually earlier in the function.
82// - Does NOT satisfy banker.Banker: see "Naming note" above.
83//
84// # Limitations
85//
86// - Granter retains direct access to the underlying source banker.
87// The AllowanceSender only constrains the wrapped capability, not
88// the granter's ability to spend its own funds via other means.
89// - No cross-realm enforcement of granter identity. The source
90// banker's own pkgAddr check (banker.gno) rejects mismatched
91// froms. This package relies on banker's own protection there.
92// - Re-entrancy: nested allowances work (each is independent), but
93// do NOT re-use the same AllowanceSender pointer across grants —
94// always create a fresh one. Re-using would mix spent counters.
95// - Cap with duplicate denoms (constructed by hand, not via
96// chain.NewCoins) will trigger chain.Coins.AmountOf to panic on
97// read. Use chain.NewCoins(...) to construct cap; it deduplicates.
98// - Persistence: granter is responsible for clearing references to
99// closed AllowanceSender pointers from its own state if it wants
100// them garbage-collected; otherwise they linger as dead structs.
101//
102// # What this package does NOT solve
103//
104// - "Allowance survives across N txs but not N+1 txs." Use a session
105// counter pattern in the granter realm; this package's Close is
106// binary, not deadline-based.
107// - "Fungible-token (grc20) allowances." Use the grc20 package's
108// own Approve/Allowance/TransferFrom triple. AllowanceSender is
109// for native (chain-coin) sends only.
110//
111// # Events
112//
113// AllowanceSender emits chain events at two points so off-chain
114// observers can track allowance lifecycles. Events are emitted from
115// the calling realm's package path (whichever realm holds the
116// AllowanceSender pointer when the method is called).
117//
118// - "AllowanceSenderSend": on each successful Send, with attributes
119// "payer", "to", "amount", "spent_total", "remaining".
120// - "AllowanceSenderClose": on Close (only the first call; idempotent
121// re-Closes do not re-emit).
122//
123// The underlying inner banker also emits its own bank-keeper events
124// for the actual coin movement; AllowanceSender's events sit on top of
125// those for allowance-level audit trails.
126package allowancesender
127
128import (
129 "chain"
130 "chain/banker"
131 "math/overflow"
132)
133
134// Event names emitted by AllowanceSender. Use these constants when
135// asserting on events from off-chain observers or test code.
136const (
137 EventSend = "AllowanceSenderSend"
138 EventClose = "AllowanceSenderClose"
139)
140
141// AllowanceSender is a bounded, revocable spending capability over a
142// source banker.Banker. Always pass *AllowanceSender (pointer); value
143// copies are not supported and would not share state.
144//
145// Does NOT satisfy banker.Banker — the other Banker methods (GetCoins,
146// TotalCoin, IssueCoin, RemoveCoin) have no meaningful behavior on an
147// allowance and are deliberately omitted.
148type AllowanceSender struct {
149 inner banker.Banker // unexported — callees cannot extract
150 payer address // address debited; must match inner's source
151 limit chain.Coins // maximum cumulative spend, per-denom
152 spent chain.Coins // running total of spends, per-denom
153 closed bool // once true, all further Send calls panic
154}
155
156// New creates a new AllowanceSender wrapping inner with the given limit.
157// payer is the address that inner.SendCoins will draw from (must match
158// the realm address inner was created against; the banker enforces this
159// at SendCoins time).
160//
161// inner must be the canonical Banker produced by banker.NewBanker;
162// hand-rolled Banker implementations (no-op fakes, decorators) are
163// rejected via banker.IsCanonical. This guarantees that a callee
164// receiving a *AllowanceSender from a granter realm can rely on Send
165// actually moving real coins via the bank-keeper, not via a fake
166// banker that no-ops SendCoins.
167//
168// Panics if inner is not canonical or payer is invalid. limit may be
169// empty (zero allowance — every Send panics with "cap exceeded");
170// limit may also contain multiple denoms.
171func New(inner banker.Banker, payer address, limit chain.Coins) *AllowanceSender {
172 if !banker.IsCanonical(inner) {
173 panic("allowancesender: inner banker is not the canonical chain/banker.Banker")
174 }
175 if !payer.IsValid() {
176 panic("allowancesender: payer address is invalid")
177 }
178 return &AllowanceSender{
179 inner: inner,
180 payer: payer,
181 limit: limit,
182 }
183}
184
185// Send debits up to (cap - spent) per denom from payer to to via the
186// inner banker. Updates spent on success. On any panic (including
187// inner.SendCoins panic for bank-level reasons like insufficient
188// balance), spent is rolled back — caller's accounting stays accurate
189// even if a caller wraps Send in a defer-recover.
190//
191// Arithmetic uses math/overflow.Add64; an overflow on (spent+amount)
192// panics with a distinct message rather than silently wrapping. This
193// closes the int64-overflow vector where a malicious callee passes a
194// MaxInt64-style amount to bypass the cap check.
195//
196// Negative amounts are rejected explicitly. chain.NewCoin permits
197// signed amounts at construction; we don't accept them.
198//
199// Emits "AllowanceSenderSend" event on success.
200//
201// Panics:
202// - "closed" if Close has been called.
203// - "negative amount not allowed" if any c.Amount < 0.
204// - "amount overflow on spent+amt" if int64 addition would overflow.
205// - "cap exceeded for denom <denom>" if any denom in amt would push
206// spent over cap.
207// - inner.SendCoins panics propagate (typically "insufficient
208// funds" from the bank keeper); spent is rolled back first.
209func (a *AllowanceSender) Send(to address, amt chain.Coins) {
210 if a.closed {
211 panic("allowancesender: closed")
212 }
213
214 // Per-denom validation. Done in a separate pass before any state
215 // mutation so a partial failure doesn't leak into spent.
216 for _, c := range amt {
217 if c.Amount < 0 {
218 panic("allowancesender: negative amount not allowed for denom " + c.Denom)
219 }
220 capAmt := a.limit.AmountOf(c.Denom)
221 spentAmt := a.spent.AmountOf(c.Denom)
222 sum, ok := overflow.Add64(spentAmt, c.Amount)
223 if !ok {
224 panic("allowancesender: amount overflow on spent+amt for denom " + c.Denom)
225 }
226 if sum > capAmt {
227 panic("allowancesender: cap exceeded for denom " + c.Denom)
228 }
229 }
230
231 // Snapshot for rollback. If inner.SendCoins panics — even if a
232 // caller's defer-recover swallows the panic — the deferred
233 // rollback below restores spent before re-raising. Without this,
234 // a recovered panic would leave spent inflated for a transfer
235 // that didn't move funds at the bank-keeper level, allowing an
236 // adversary to "burn" allowance against failed sends.
237 prevSpent := a.spent
238 a.spent = a.spent.Add(amt)
239
240 defer func() {
241 if r := recover(); r != nil {
242 a.spent = prevSpent
243 panic(r) // re-raise so caller learns of the failure
244 }
245 }()
246
247 a.inner.SendCoins(a.payer, to, amt)
248
249 // Reached only on successful inner.SendCoins. Emit the audit
250 // event from the granter realm's perspective.
251 chain.Emit(EventSend,
252 "payer", a.payer.String(),
253 "to", to.String(),
254 "amount", amt.String(),
255 "spent_total", a.spent.String(),
256 "remaining", a.Remaining().String(),
257 )
258}
259
260// Cap returns a defensive copy of the maximum cumulative spend.
261// Mutating the returned slice does not affect internal state.
262func (a *AllowanceSender) Cap() chain.Coins {
263 return cloneCoins(a.limit)
264}
265
266// Spent returns a defensive copy of the cumulative amount spent.
267// Mutating the returned slice does not affect internal state.
268func (a *AllowanceSender) Spent() chain.Coins {
269 return cloneCoins(a.spent)
270}
271
272// Remaining returns cap - spent, per denom. Denoms with non-positive
273// remainder are omitted.
274func (a *AllowanceSender) Remaining() chain.Coins {
275 if len(a.limit) == 0 {
276 return nil
277 }
278 out := make(chain.Coins, 0, len(a.limit))
279 for _, c := range a.limit {
280 rem := c.Amount - a.spent.AmountOf(c.Denom)
281 if rem > 0 {
282 out = append(out, chain.NewCoin(c.Denom, rem))
283 }
284 }
285 return out
286}
287
288// Closed reports whether Close has been called.
289func (a *AllowanceSender) Closed() bool {
290 return a.closed
291}
292
293// Close terminates the allowance. Subsequent Send calls panic.
294// Idempotent — calling twice is fine; the second call is a no-op and
295// does not re-emit the AllowanceSenderClose event.
296func (a *AllowanceSender) Close() {
297 if a.closed {
298 return
299 }
300 a.closed = true
301 chain.Emit(EventClose,
302 "payer", a.payer.String(),
303 "spent_total", a.spent.String(),
304 )
305}
306
307// Payer returns the address that this allowance debits from.
308func (a *AllowanceSender) Payer() address {
309 return a.payer
310}
311
312// cloneCoins returns a fresh chain.Coins that does not share an
313// underlying array with the source. Used to defend internal state
314// from caller mutation.
315func cloneCoins(src chain.Coins) chain.Coins {
316 if len(src) == 0 {
317 return nil
318 }
319 out := make(chain.Coins, len(src))
320 copy(out, src)
321 return out
322}