wallet.gno
10.44 Kb · 468 lines
1// Package agentwallet is a spend-limited wallet bound to an Agent ID.
2//
3// The owner funds it and sets the policy. A different address, the agent,
4// is the only caller that can move those funds. Swap, exchange, liquidity,
5// and stake stay inside this realm. They are not a public exchange.
6package agentwallet
7
8import (
9 "chain"
10 "chain/banker"
11 "chain/runtime/unsafe"
12 "strconv"
13
14 std "gno.land/p/g1y0n2geu0rmdrm9u30c5fmk3ykkl2enw9n9yr2k/agentid"
15 "gno.land/p/nt/avl/v0"
16 book "gno.land/r/g1y0n2geu0rmdrm9u30c5fmk3ykkl2enw9n9yr2k/agentbook"
17)
18
19const denomUgnot = "ugnot"
20
21type wallet struct {
22 owner address
23 agent address
24 name string
25 armed bool
26 txCap int64
27 allowance int64
28 actions []string
29 names []string
30 free int64
31 exchange int64
32 staked int64
33 units int64
34 shares int64
35}
36
37var (
38 wallets avl.Tree // owner.String() -> *wallet
39 byAgent avl.Tree // agent.String() -> owner.String()
40 poolUgnot int64
41 poolUnits int64
42 poolShares int64
43)
44
45// Open binds a fresh agent address to the caller's Agent ID.
46// The agent address must be a different key from the owner.
47func Open(cur realm, agent address) {
48 if !cur.Previous().IsUserCall() {
49 panic("direct wallet call required")
50 }
51 if len(unsafe.OriginSend()) != 0 {
52 panic("no payment")
53 }
54 caller := cur.Previous().Address()
55 name := book.NameOf(caller)
56 if name == "" {
57 panic("claim a name")
58 }
59 if !agent.IsValid() {
60 panic("invalid caller")
61 }
62 if agent == caller {
63 panic("same key")
64 }
65 if wallets.Has(caller.String()) || byAgent.Has(agent.String()) {
66 panic("already open")
67 }
68 wallets.Set(caller.String(), &wallet{owner: caller, agent: agent, name: name})
69 byAgent.Set(agent.String(), caller.String())
70 chain.Emit("Open", "owner", caller.String(), "agent", agent.String(), "name", name)
71}
72
73// Fund adds the ugnot attached to this call to the owner's agent wallet.
74func Fund(cur realm) {
75 if !cur.Previous().IsUserCall() {
76 panic("direct wallet call required")
77 }
78 sent := unsafe.OriginSend()
79 if len(sent) != 1 || sent[0].Denom != denomUgnot || sent[0].Amount <= 0 {
80 panic("send ugnot")
81 }
82 w := mustOwner(cur.Previous().Address())
83 amt := sent[0].Amount
84 banker_ := banker.NewBanker(banker.BankerTypeRealmSend, cur)
85 if banker_.GetCoins(cur.Address()).AmountOf(denomUgnot) < amt {
86 panic("ugnot not received")
87 }
88 w.free += amt
89 chain.Emit("Fund", "owner", w.owner.String(), "ugnot", strconv.FormatInt(amt, 10))
90}
91
92// SetPolicy replaces the per-action cap, the remaining allowance, and the allowlists.
93// actions and names are comma-separated. The owner calls this.
94func SetPolicy(cur realm, txCap int64, allowance int64, actions string, names string) {
95 if !cur.Previous().IsUserCall() {
96 panic("direct wallet call required")
97 }
98 if len(unsafe.OriginSend()) != 0 {
99 panic("no payment")
100 }
101 if txCap < 0 {
102 panic("over cap")
103 }
104 if allowance < 0 {
105 panic("over allowance")
106 }
107 w := mustOwner(cur.Previous().Address())
108 w.txCap = txCap
109 w.allowance = allowance
110 w.actions = cleanActions(splitCSV(actions))
111 w.names = cleanNames(splitCSV(names))
112 chain.Emit("Policy", "owner", w.owner.String())
113}
114
115// Arm lets the agent address submit actions.
116func Arm(cur realm) {
117 setArmed(cur, true)
118}
119
120// Disarm stops the agent address until the owner arms it again.
121func Disarm(cur realm) {
122 setArmed(cur, false)
123}
124
125func setArmed(cur realm, armed bool) {
126 if !cur.Previous().IsUserCall() {
127 panic("direct wallet call required")
128 }
129 if len(unsafe.OriginSend()) != 0 {
130 panic("no payment")
131 }
132 w := mustOwner(cur.Previous().Address())
133 w.armed = armed
134}
135
136// Act moves funds already inside the agent wallet.
137// The caller must be the bound agent address, not the owner.
138func Act(cur realm, action string, dest string, amount int64) {
139 if !cur.Previous().IsUserCall() {
140 panic("direct wallet call required")
141 }
142 if len(unsafe.OriginSend()) != 0 {
143 panic("no payment")
144 }
145 w := walletByAgent(cur.Previous().Address())
146 if w == nil {
147 panic("agent only")
148 }
149 if !w.armed {
150 panic("disarmed")
151 }
152 if !allowed(w.actions, groupOf(action)) {
153 panic("action denied")
154 }
155 switch action {
156 case "transfer":
157 transfer(cur, w, dest, amount)
158 case "exchange-in":
159 takeFree(w, amount)
160 w.exchange += amount
161 case "exchange-out":
162 moveBack(w, &w.exchange, amount)
163 case "stake":
164 takeFree(w, amount)
165 w.staked += amount
166 case "unstake":
167 moveBack(w, &w.staked, amount)
168 case "swap-buy":
169 swapBuy(w, amount)
170 case "swap-sell":
171 swapSell(w, amount)
172 case "liquidity-add":
173 addLiquidity(w, amount)
174 case "liquidity-remove":
175 removeLiquidity(w, amount, true)
176 default:
177 panic("action denied")
178 }
179 chain.Emit("Act", "agent", w.agent.String(), "action", action, "ugnot", strconv.FormatInt(amount, 10))
180}
181
182// Recover returns the owner's agent funds to the owner and disarms the agent.
183func Recover(cur realm) {
184 if !cur.Previous().IsUserCall() {
185 panic("direct wallet call required")
186 }
187 if len(unsafe.OriginSend()) != 0 {
188 panic("no payment")
189 }
190 w := mustOwner(cur.Previous().Address())
191 w.free += w.exchange + w.staked
192 w.exchange = 0
193 w.staked = 0
194 if w.shares > 0 {
195 removeLiquidity(w, w.shares, false)
196 }
197 if w.units > 0 && poolUgnot > 0 && poolUnits > 0 {
198 out := quoteOut(poolUnits, poolUgnot, w.units)
199 if out > 0 {
200 poolUnits += w.units
201 poolUgnot -= out
202 w.free += out
203 }
204 }
205 w.units = 0
206 amt := w.free
207 w.free = 0
208 w.armed = false
209 if amt > 0 {
210 banker_ := banker.NewBanker(banker.BankerTypeRealmSend, cur)
211 if banker_.GetCoins(cur.Address()).AmountOf(denomUgnot) < amt {
212 panic("ugnot not received")
213 }
214 banker_.SendCoins(cur.Address(), w.owner, chain.NewCoins(chain.NewCoin(denomUgnot, amt)))
215 }
216 chain.Emit("Recover", "owner", w.owner.String(), "ugnot", strconv.FormatInt(amt, 10))
217}
218
219// Free returns the spendable ugnot inside the owner's agent wallet.
220func Free(owner address) int64 {
221 w := ownerWallet(owner)
222 if w == nil {
223 return 0
224 }
225 return w.free
226}
227
228func transfer(cur realm, w *wallet, dest string, amount int64) {
229 if !allowed(w.names, dest) {
230 panic("not allowed")
231 }
232 to, ok := book.Resolve(dest)
233 if !ok {
234 panic("unknown name")
235 }
236 takeFree(w, amount)
237 banker_ := banker.NewBanker(banker.BankerTypeRealmSend, cur)
238 if banker_.GetCoins(cur.Address()).AmountOf(denomUgnot) < amount {
239 panic("ugnot not received")
240 }
241 banker_.SendCoins(cur.Address(), to, chain.NewCoins(chain.NewCoin(denomUgnot, amount)))
242}
243
244func swapBuy(w *wallet, amount int64) {
245 out := quoteOut(poolUgnot, poolUnits, amount)
246 if out <= 0 {
247 panic("no output")
248 }
249 takeFree(w, amount)
250 poolUgnot += amount
251 poolUnits -= out
252 w.units += out
253}
254
255func swapSell(w *wallet, amount int64) {
256 out := quoteOut(poolUnits, poolUgnot, amount)
257 if out <= 0 {
258 panic("no output")
259 }
260 if out > w.txCap {
261 panic("over cap")
262 }
263 if w.units < amount {
264 panic("insufficient")
265 }
266 w.units -= amount
267 poolUnits += amount
268 poolUgnot -= out
269 w.free += out
270}
271
272func addLiquidity(w *wallet, amount int64) {
273 if poolShares != 0 {
274 if poolUgnot <= 0 {
275 panic("no output")
276 }
277 sharesOut := amount * poolShares / poolUgnot
278 unitsNeeded := amount * poolUnits / poolUgnot
279 if sharesOut <= 0 || unitsNeeded <= 0 {
280 panic("no output")
281 }
282 if w.units < unitsNeeded {
283 panic("insufficient")
284 }
285 takeFree(w, amount)
286 w.units -= unitsNeeded
287 w.shares += sharesOut
288 poolUgnot += amount
289 poolUnits += unitsNeeded
290 poolShares += sharesOut
291 return
292 }
293 takeFree(w, amount)
294 poolUgnot += amount
295 poolUnits += amount
296 poolShares += amount
297 w.shares += amount
298}
299
300func removeLiquidity(w *wallet, shares int64, capped bool) {
301 if shares <= 0 || shares > w.shares || poolShares <= 0 {
302 panic("insufficient")
303 }
304 if shares == poolShares {
305 if capped && poolUgnot > w.txCap {
306 panic("over cap")
307 }
308 w.free += poolUgnot
309 poolUgnot = 0
310 poolUnits = 0
311 poolShares = 0
312 w.shares = 0
313 return
314 }
315 ugnotOut := shares * poolUgnot / poolShares
316 unitsOut := shares * poolUnits / poolShares
317 if ugnotOut <= 0 {
318 panic("no output")
319 }
320 if capped && ugnotOut > w.txCap {
321 panic("over cap")
322 }
323 w.shares -= shares
324 poolShares -= shares
325 poolUgnot -= ugnotOut
326 poolUnits -= unitsOut
327 w.free += ugnotOut
328 w.units += unitsOut
329}
330
331func takeFree(w *wallet, amount int64) {
332 if amount <= 0 {
333 panic("send ugnot")
334 }
335 if amount > w.txCap {
336 panic("over cap")
337 }
338 if amount > w.allowance {
339 panic("over allowance")
340 }
341 if amount > w.free {
342 panic("insufficient")
343 }
344 w.free -= amount
345 w.allowance -= amount
346}
347
348func moveBack(w *wallet, bucket *int64, amount int64) {
349 if amount <= 0 {
350 panic("send ugnot")
351 }
352 if amount > w.txCap {
353 panic("over cap")
354 }
355 if *bucket < amount {
356 panic("insufficient")
357 }
358 *bucket -= amount
359 w.free += amount
360}
361
362func quoteOut(reserveIn int64, reserveOut int64, amountIn int64) int64 {
363 if amountIn <= 0 || reserveIn <= 0 || reserveOut <= 0 {
364 return 0
365 }
366 if reserveOut > int64(9223372036854775807)/amountIn {
367 panic("overflow")
368 }
369 return reserveOut * amountIn / (reserveIn + amountIn)
370}
371
372func mustOwner(caller address) *wallet {
373 w := ownerWallet(caller)
374 if w == nil {
375 panic("no wallet")
376 }
377 return w
378}
379
380func ownerWallet(caller address) *wallet {
381 raw := wallets.Get(caller.String())
382 if raw == nil {
383 return nil
384 }
385 return raw.(*wallet)
386}
387
388func walletByAgent(caller address) *wallet {
389 raw := byAgent.Get(caller.String())
390 if raw == nil {
391 return nil
392 }
393 return ownerWallet(address(raw.(string)))
394}
395
396func groupOf(action string) string {
397 switch action {
398 case "transfer":
399 return "transfer"
400 case "exchange-in", "exchange-out":
401 return "exchange"
402 case "swap-buy", "swap-sell":
403 return "swap"
404 case "liquidity-add", "liquidity-remove":
405 return "liquidity"
406 case "stake", "unstake":
407 return "stake"
408 default:
409 return ""
410 }
411}
412
413func allowed(list []string, want string) bool {
414 if want == "" {
415 return false
416 }
417 for _, item := range list {
418 if item == want {
419 return true
420 }
421 }
422 return false
423}
424
425func cleanActions(items []string) []string {
426 if len(items) > 8 {
427 panic("invalid action")
428 }
429 out := make([]string, 0, len(items))
430 for _, item := range items {
431 switch item {
432 case "transfer", "exchange", "swap", "liquidity", "stake":
433 out = append(out, item)
434 default:
435 panic("invalid action")
436 }
437 }
438 return out
439}
440
441func cleanNames(items []string) []string {
442 if len(items) > 16 {
443 panic("invalid name")
444 }
445 out := make([]string, 0, len(items))
446 for _, item := range items {
447 if !std.Valid(item) || std.Reserved(item) {
448 panic("invalid name")
449 }
450 out = append(out, item)
451 }
452 return out
453}
454
455func splitCSV(s string) []string {
456 if s == "" {
457 return []string{}
458 }
459 out := []string{}
460 start := 0
461 for i := 0; i < len(s); i++ {
462 if s[i] == ',' {
463 out = append(out, s[start:i])
464 start = i + 1
465 }
466 }
467 return append(out, s[start:])
468}