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}