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

pullpayment.gno

5.26 Kb · 180 lines
  1// Package pullpayment is the escrow ledger behind the pull-payment pattern, as
  2// a pure, reusable package.
  3//
  4// The pattern is the classic Solidity answer to reentrancy: never push value to
  5// an address, credit it and let the recipient withdraw. A push sends control to
  6// the recipient in the middle of your state transition, and a malicious
  7// recipient re-enters before you have finished updating. Pull inverts that —
  8// the recipient calls in, and their own withdrawal is the only state being
  9// touched.
 10//
 11// This package is the BOOKKEEPING half only: who is owed what, and the
 12// checks-effects-interactions ordering that makes a withdrawal safe. It moves
 13// no coins. The realm that holds the funds performs the transfer AFTER calling
 14// Withdraw, which is exactly the ordering the pattern demands — the balance is
 15// already zeroed when the transfer happens, so a reentrant call finds nothing
 16// left to take.
 17//
 18// Iteration is over sorted addresses, never a built-in map range: gno map
 19// iteration order is unspecified and a Render built from one can differ between
 20// nodes, which is a consensus bug rather than a cosmetic one.
 21//
 22// A live demo of this package is at
 23// [r/moul/x/daily/pullpaymentdemo](/r/moul/x/daily/pullpaymentdemo/v0).
 24package pullpayment
 25
 26import (
 27	"errors"
 28	"sort"
 29)
 30
 31// MaxPayees bounds the ledger so gas stays predictable.
 32const MaxPayees = 4096
 33
 34var (
 35	ErrBadAmount = errors.New("pullpayment: amount must be positive")
 36	ErrFull      = errors.New("pullpayment: too many payees")
 37	ErrNothing   = errors.New("pullpayment: nothing to withdraw")
 38	ErrOverflow  = errors.New("pullpayment: credit would overflow")
 39)
 40
 41const maxInt64 = int64(9223372036854775807)
 42
 43// Ledger records what each address is owed.
 44type Ledger struct {
 45	owed      map[string]int64
 46	total     int64
 47	withdrawn int64
 48}
 49
 50// New returns an empty Ledger.
 51func New() *Ledger { return &Ledger{owed: map[string]int64{}} }
 52
 53// Credit records that payee is owed amount more. Amounts accumulate: crediting
 54// twice owes the sum.
 55func (l *Ledger) Credit(payee string, amount int64) error {
 56	if amount <= 0 {
 57		return ErrBadAmount
 58	}
 59	cur, seen := l.owed[payee]
 60	if !seen && len(l.owed) >= MaxPayees {
 61		return ErrFull
 62	}
 63	if cur > maxInt64-amount || l.total > maxInt64-amount {
 64		return ErrOverflow
 65	}
 66	l.owed[payee] = cur + amount
 67	l.total += amount
 68	return nil
 69}
 70
 71// Balance returns what payee is currently owed; zero when nothing.
 72func (l *Ledger) Balance(payee string) int64 { return l.owed[payee] }
 73
 74// Withdraw zeroes payee's balance and returns what was owed.
 75//
 76// The caller transfers the returned amount AFTER this call. That ordering is
 77// the point of the pattern: the credit is already gone from the ledger when
 78// control passes to the recipient, so a reentrant Withdraw returns ErrNothing.
 79func (l *Ledger) Withdraw(payee string) (int64, error) {
 80	amount, ok := l.owed[payee]
 81	if !ok || amount == 0 {
 82		return 0, ErrNothing
 83	}
 84	delete(l.owed, payee) // effects before interactions
 85	l.total -= amount
 86	l.withdrawn += amount
 87	return amount, nil
 88}
 89
 90// Forfeit drops a payee's credit without paying it, returning what was dropped.
 91func (l *Ledger) Forfeit(payee string) (int64, error) {
 92	amount, ok := l.owed[payee]
 93	if !ok || amount == 0 {
 94		return 0, ErrNothing
 95	}
 96	delete(l.owed, payee)
 97	l.total -= amount
 98	return amount, nil
 99}
100
101// TotalOwed returns the sum of every outstanding balance — what the holding
102// realm must keep in reserve.
103func (l *Ledger) TotalOwed() int64 { return l.total }
104
105// TotalWithdrawn returns the lifetime sum of successful withdrawals.
106func (l *Ledger) TotalWithdrawn() int64 { return l.withdrawn }
107
108// Payees returns every address with an outstanding balance, sorted.
109func (l *Ledger) Payees() []string {
110	out := make([]string, 0, len(l.owed))
111	for p := range l.owed {
112		out = append(out, p)
113	}
114	sort.Strings(out)
115	return out
116}
117
118// Count returns how many payees are owed something.
119func (l *Ledger) Count() int { return len(l.owed) }
120
121// IsEmpty reports whether nothing is owed to anyone.
122func (l *Ledger) IsEmpty() bool { return len(l.owed) == 0 }
123
124// Iterate calls fn for each payee in sorted order. Returning true stops.
125func (l *Ledger) Iterate(fn func(payee string, amount int64) bool) {
126	for _, p := range l.Payees() {
127		if fn(p, l.owed[p]) {
128			return
129		}
130	}
131}
132
133// CreditMany credits several payees, applying nothing unless every entry is
134// valid — a partial split would leave the ledger disagreeing with the funds.
135func (l *Ledger) CreditMany(payees []string, amounts []int64) error {
136	if len(payees) != len(amounts) {
137		return ErrBadAmount
138	}
139	// Validate first.
140	for _, a := range amounts {
141		if a <= 0 {
142			return ErrBadAmount
143		}
144	}
145	probe := len(l.owed)
146	for _, p := range payees {
147		if _, seen := l.owed[p]; !seen {
148			probe++
149		}
150	}
151	if probe > MaxPayees {
152		return ErrFull
153	}
154	var sum int64
155	for _, a := range amounts {
156		if sum > maxInt64-a {
157			return ErrOverflow
158		}
159		sum += a
160	}
161	if l.total > maxInt64-sum {
162		return ErrOverflow
163	}
164	// Then apply.
165	for i, p := range payees {
166		l.owed[p] += amounts[i]
167	}
168	l.total += sum
169	return nil
170}
171
172// Consistent reports whether TotalOwed equals the sum of the balances. Always
173// true through the public API; exported so callers can assert the invariant.
174func (l *Ledger) Consistent() bool {
175	var sum int64
176	for _, a := range l.owed {
177		sum += a
178	}
179	return sum == l.total
180}