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

vault.gno

12.11 Kb · 347 lines
  1// Realm vault holds GNOT deposits with per-user balances and an
  2// explicit, inspectable protocol fee, built on
  3// gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger.
  4//
  5// Flow: an EOA deposits GNOT by calling Deposit with coins attached
  6// (`-send`); the configured fee (FeeBps, capped at MaxFeeBps) accrues to
  7// a separate fee pot and the remainder credits the depositor's balance.
  8// Users claim their balance back at any time, partially or fully. The
  9// fee recipient — a role distinct from the admin — withdraws the fee
 10// pot.
 11//
 12// FEE MODEL (all values inspectable via FeeBps/FeeRecipient/MaxFeeBps):
 13//   - fee = floor(amount * FeeBps / 10000); rounding favors the
 14//     depositor; no minimum fee (small deposits may pay 0).
 15//   - FeeBps starts at 0 (no fee) and is changed only by the admin via
 16//     SetFeeBps, hard-capped at MaxFeeBps (10% — the cap itself is a
 17//     compile-time constant and cannot be raised by anyone).
 18//   - A fee change applies to FUTURE deposits only; already-accrued fees
 19//     and user balances are untouched.
 20//   - Fees accrue to a pot, not an address. Changing the fee recipient
 21//     re-points who may withdraw the pot, INCLUDING what was accrued
 22//     under the previous recipient (positional, documented trade-off).
 23//   - With FeeBps == 0 deposits credit in full and the pot grows by 0.
 24//   - The fee can never exceed the deposit: FeeBps <= 10000 structurally
 25//     and <= 1000 by this realm's cap.
 26//
 27// ACCOUNTING INVARIANT (conservation): let H be the ugnot held at this
 28// realm's address, U the sum of user balances, F the accrued fee pot.
 29// At every transaction boundary:
 30//
 31//	H == U + F + S,  S >= 0
 32//
 33// where S (surplus) is ugnot pushed to the realm address outside
 34// Deposit (e.g. a direct bank send). S stays 0 if all coins arrive via
 35// Deposit. Derivation from chain semantics: (1) a MsgCall `-send`
 36// envelope is transferred to the realm address BEFORE the call body
 37// runs, and Deposit's IsUserCall guard is exactly the case where that
 38// receipt is guaranteed, so Deposit raises U+F by the amount already
 39// added to H; (2) Claim/WithdrawFees debit the ledger first, then move
 40// the identical amount out via a RealmSend banker, lowering H and U+F
 41// equally; (3) any panic aborts the whole transaction, reverting ledger
 42// and coin movements together (atomicity); (4) this realm never uses
 43// IssueCoin/RemoveCoin. Surplus is intentionally unreachable: it can
 44// only be swept by the fee recipient via SweepSurplus, never counted as
 45// a user balance.
 46//
 47// ONLY GNOT: Deposit rejects any transaction whose send envelope is not
 48// exactly one ugnot coin. Other assets force-sent to the realm address
 49// are not accepted, not tracked, and sit in surplus.
 50package vault
 51
 52import (
 53	"chain"
 54	"chain/banker"
 55	"chain/runtime/unsafe"
 56	"strconv"
 57
 58	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
 59)
 60
 61// Denom is the only asset this vault accepts.
 62const Denom = "ugnot"
 63
 64// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%. Nobody can
 65// configure a higher fee; changing this requires deploying a new realm.
 66const MaxFeeBps = int64(1000)
 67
 68var (
 69	admin        address // may set fee, fee recipient, and successor admin
 70	feeRecipient address // may withdraw the fee pot and sweep surplus
 71	feeBps       int64   // current protocol fee in basis points
 72
 73	self   address // this realm's own address, captured at deploy
 74	ledger = feeledger.MustNew(MaxFeeBps)
 75)
 76
 77func init() {
 78	admin = unsafe.OriginCaller() // the deployer
 79	feeRecipient = admin
 80	self = unsafe.CurrentRealm().Address()
 81}
 82
 83// Deposit credits the caller with the attached GNOT minus the current
 84// protocol fee. Only direct EOA calls (maketx call with -send) are
 85// accepted: that is the only shape where the chain guarantees the send
 86// envelope landed at this realm's address before the body runs. The
 87// envelope must be exactly one coin of denom ugnot with positive
 88// amount.
 89func Deposit(cur realm) {
 90	if !cur.Previous().IsUserCall() {
 91		panic("deposit must be a direct EOA call with -send (realms and maketx-run are rejected)")
 92	}
 93	sent := unsafe.OriginSend()
 94	if len(sent) != 1 || sent[0].Denom != Denom {
 95		panic("send exactly one coin type: " + Denom)
 96	}
 97	amount := sent[0].Amount
 98	if amount <= 0 {
 99		panic("deposit amount must be positive")
100	}
101
102	depositor := cur.Previous().Address()
103	credited, fee, err := ledger.Deposit(depositor.String(), amount, feeBps)
104	if err != nil {
105		panic(err)
106	}
107	chain.Emit("Deposit",
108		"from", depositor.String(),
109		"amount", itoa(amount),
110		"credited", itoa(credited),
111		"fee", itoa(fee),
112	)
113}
114
115// Claim sends amount ugnot of the caller's balance back to the caller.
116// Fails if amount is not positive or exceeds the caller's balance.
117func Claim(cur realm, amount int64) {
118	caller := cur.Previous().Address()
119	if err := ledger.Withdraw(caller.String(), amount); err != nil {
120		panic(err)
121	}
122	send(cur, caller, amount)
123	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
124}
125
126// ClaimAll sends the caller's entire balance back to the caller. Fails
127// if the caller has no balance.
128func ClaimAll(cur realm) {
129	caller := cur.Previous().Address()
130	amount, err := ledger.WithdrawAll(caller.String())
131	if err != nil {
132		panic(err)
133	}
134	if amount == 0 {
135		panic("nothing to claim")
136	}
137	send(cur, caller, amount)
138	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
139}
140
141// WithdrawFees sends the entire accrued fee pot to the fee recipient.
142// Only the fee recipient may call it. Fails if the pot is empty.
143func WithdrawFees(cur realm) {
144	caller := cur.Previous().Address()
145	if caller != feeRecipient {
146		panic("only the fee recipient may withdraw fees")
147	}
148	amount := ledger.WithdrawFees()
149	if amount == 0 {
150		panic("no fees accrued")
151	}
152	send(cur, feeRecipient, amount)
153	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
154}
155
156// SweepSurplus sends coins that sit at the realm address ABOVE the
157// ledger's liabilities (ugnot force-sent outside Deposit, or any other
158// denomination) to the fee recipient. Only the fee recipient may call
159// it. User balances and the fee pot are untouchable by construction:
160// only the excess over Liabilities() moves. Fails if there is no
161// surplus.
162func SweepSurplus(cur realm) {
163	caller := cur.Previous().Address()
164	if caller != feeRecipient {
165		panic("only the fee recipient may sweep surplus")
166	}
167	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
168	held := bk.GetCoins(self)
169	var out chain.Coins
170	for _, c := range held {
171		if c.Denom == Denom {
172			surplus := c.Amount - ledger.Liabilities()
173			if surplus > 0 {
174				out = append(out, chain.NewCoin(Denom, surplus))
175			}
176			continue
177		}
178		if c.Amount > 0 {
179			out = append(out, c)
180		}
181	}
182	if len(out) == 0 {
183		panic("no surplus to sweep")
184	}
185	bk.SendCoins(self, feeRecipient, out)
186	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", out.String())
187}
188
189// SweepDenom sends the surplus of a single denomination to the fee
190// recipient. This is the bounded escape hatch for when SweepSurplus
191// would exceed gas because a third party force-sent many junk
192// denominations to the realm address: each call touches exactly one
193// denomination, so ugnot surplus can always be recovered regardless of
194// how many foreign denoms accumulate. For ugnot only the excess over
195// Liabilities() moves; any other denom moves wholly. Only the fee
196// recipient may call it.
197func SweepDenom(cur realm, denom string) {
198	caller := cur.Previous().Address()
199	if caller != feeRecipient {
200		panic("only the fee recipient may sweep surplus")
201	}
202	if denom == "" {
203		panic("empty denom")
204	}
205	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
206	amount := bk.GetCoin(self, denom)
207	if denom == Denom {
208		amount -= ledger.Liabilities()
209	}
210	if amount <= 0 {
211		panic("no surplus to sweep for " + denom)
212	}
213	bk.SendCoins(self, feeRecipient, chain.Coins{chain.NewCoin(denom, amount)})
214	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(amount)+denom)
215}
216
217// SetFeeBps sets the protocol fee for FUTURE deposits. Admin only;
218// bounded by [0, MaxFeeBps].
219func SetFeeBps(cur realm, bps int64) {
220	assertAdmin(cur.Previous().Address())
221	if bps < 0 || bps > MaxFeeBps {
222		panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]")
223	}
224	old := feeBps
225	feeBps = bps
226	chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps))
227}
228
229// SetFeeRecipient re-points who may withdraw the fee pot (including
230// fees accrued before the change) and sweep surplus. Admin only; the
231// zero address is rejected.
232func SetFeeRecipient(cur realm, next address) {
233	assertAdmin(cur.Previous().Address())
234	var zero address
235	if next == zero {
236		panic("empty fee recipient")
237	}
238	old := feeRecipient
239	feeRecipient = next
240	chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String())
241}
242
243// TransferAdmin hands the admin role to next. Admin only; the zero
244// address is rejected. One-step: a transfer to a wrong-but-valid
245// address permanently loses fee administration (deposits and claims
246// keep working).
247func TransferAdmin(cur realm, next address) {
248	assertAdmin(cur.Previous().Address())
249	var zero address
250	if next == zero {
251		panic("empty admin address")
252	}
253	admin = next
254	chain.Emit("AdminTransferred", "newAdmin", next.String())
255}
256
257// --- read-only views ---
258
259// Admin returns the current admin.
260func Admin() address { return admin }
261
262// FeeRecipient returns who may withdraw the fee pot.
263func FeeRecipient() address { return feeRecipient }
264
265// FeeBps returns the protocol fee applied to future deposits, in basis
266// points of the deposit amount.
267func FeeBps() int64 { return feeBps }
268
269// FeeOn previews the fee and credited amount for a deposit of amount at
270// the CURRENT FeeBps.
271func FeeOn(amount int64) (fee, credited int64) {
272	f, err := feeledger.FeeFor(amount, feeBps)
273	if err != nil {
274		panic(err)
275	}
276	return f, amount - f
277}
278
279// BalanceOf returns addr's claimable balance.
280func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
281
282// UsersTotal returns the sum of all user balances.
283func UsersTotal() int64 { return ledger.UsersTotal() }
284
285// FeesAccrued returns the fee pot awaiting withdrawal.
286func FeesAccrued() int64 { return ledger.FeesAccrued() }
287
288// Liabilities returns UsersTotal() + FeesAccrued().
289func Liabilities() int64 { return ledger.Liabilities() }
290
291// Held returns the ugnot actually held at the realm address.
292func Held() int64 {
293	return banker.NewReadonlyBanker().GetCoin(self, Denom)
294}
295
296// Surplus returns Held() - Liabilities(): ugnot at the realm address
297// that the ledger does not owe to anyone. Negative would indicate a
298// conservation bug (see the invariant in the package doc).
299func Surplus() int64 { return Held() - ledger.Liabilities() }
300
301// Address returns this realm's own address (the deposit target).
302func Address() address { return self }
303
304// Render shows configuration, totals, and the live conservation check.
305func Render(_ string) string {
306	held := Held()
307	liab := ledger.Liabilities()
308	status := "OK"
309	if held < liab {
310		status = "VIOLATED"
311	}
312	out := "# Vault\n\n"
313	out += "GNOT deposits with per-user balances and an explicit protocol fee.\n\n"
314	out += "## Fee configuration\n\n"
315	out += "- fee: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) + " bps)\n"
316	out += "- fee recipient: " + feeRecipient.String() + "\n"
317	out += "- admin: " + admin.String() + "\n\n"
318	out += "## Accounting\n\n"
319	out += "- users total: " + itoa(ledger.UsersTotal()) + Denom + "\n"
320	out += "- fees accrued: " + itoa(ledger.FeesAccrued()) + Denom + "\n"
321	out += "- held: " + itoa(held) + Denom + "\n"
322	out += "- accounts: " + strconv.Itoa(ledger.Accounts()) + "\n"
323	out += "- conservation (held >= users+fees): " + status + "\n"
324	return out
325}
326
327// --- internals ---
328
329// assertAdmin gates admin-only entrypoints; callers resolve identity at
330// the crossing boundary and pass the address down.
331func assertAdmin(caller address) {
332	if caller != admin {
333		panic("admin only")
334	}
335}
336
337// send moves amount ugnot from the realm to `to`. Callers must have
338// debited the ledger first (checks-effects-interactions); a panic here
339// aborts the transaction, reverting the debit with it.
340func send(cur realm, to address, amount int64) {
341	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
342	bk.SendCoins(self, to, chain.Coins{chain.NewCoin(Denom, amount)})
343}
344
345func itoa(n int64) string {
346	return strconv.FormatInt(n, 10)
347}