Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}