vault.gno
7.25 Kb · 255 lines
1package data
2
3import (
4 "chain"
5 "chain/banker"
6 "strconv"
7 "strings"
8
9 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
10 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
11 bptree "gno.land/p/nt/bptree/v0"
12)
13
14// The vault holds the coins of promo budgets, at this realm's address, so
15// they never move across a release. It lives in unexported state, not in a
16// collection, so no writer can forge a balance. An account ("catalog/<id>")
17// has a funder, a balance and holds: amounts reserved until a UTC day, which
18// lapse by themselves after it. A role's writer credits, holds, releases,
19// pays from holds and refunds; the funder alone withdraws what is not held,
20// whatever the writer, a pause or a renounced owner. Holds lapse on the
21// vault's clock (VaultDay), which a pause stops.
22//
23// Invariant: the realm's ugnot >= VaultTotal. A credit is checked against
24// the coins already received; every payout lowers the total first.
25
26const (
27 denom = "ugnot"
28 daySecs = 24 * 3600
29 maxHoldDays = 31 // a hold lapses at most this many days ahead
30 maxAcct = 64
31)
32
33var (
34 vault = bptree.NewBPTree32() // account -> store.Rec(funder, balance, holds)
35 vaultTotal int64
36)
37
38type account struct {
39 funder address
40 balance int64 // held for the account, holds included
41 holds string // "day:ugnot,..." reserved until the end of day
42}
43
44func getAcct(name string) account {
45 if len(name) > maxAcct || !strings.Contains(name, "/") {
46 panic("data: an account is <role>/<name>, at most 64 bytes")
47 }
48 v := vault.Get(name)
49 if v == nil {
50 return account{}
51 }
52 f := store.Fields(v.(string))
53 return account{address(f[0]), atoi(f[1]), f[2]}
54}
55
56func putAcct(name string, a account) {
57 vault.Set(name, store.Rec(a.funder.String(), strconv.FormatInt(a.balance, 10), a.holds))
58}
59
60func atoi(s string) int64 {
61 n, err := strconv.ParseInt(s, 10, 64)
62 if err != nil {
63 panic("data: bad number")
64 }
65 return n
66}
67
68// VaultDay is the UTC day of the vault's clock, which stands still while
69// any role is paused: a pause never makes a hold lapse, it lapses that much
70// later. The clock lags real time by every past pause, so a hold made
71// after one also lasts that much longer.
72func VaultDay() int64 {
73 if frozeAt != 0 {
74 return (frozeAt - frozen) / daySecs
75 }
76 return (now() - frozen) / daySecs
77}
78
79func today() int64 { return VaultDay() }
80
81// held reads the holds still live today, and the one lapsing on day e.
82func held(list string, e int64) (live, atE int64) {
83 t := today()
84 for _, p := range strings.Split(list, ",") {
85 d, v, ok := strings.Cut(p, ":")
86 if ok && atoi(d) >= t {
87 live += atoi(v)
88 if atoi(d) == e {
89 atE += atoi(v)
90 }
91 }
92 }
93 return live, atE
94}
95
96// hold adds delta to the hold lapsing on day e and drops lapsed ones.
97func hold(list string, e, delta int64) string {
98 t := today()
99 var out []string
100 for _, p := range strings.Split(list, ",") {
101 d, v, ok := strings.Cut(p, ":")
102 if !ok || atoi(d) < t {
103 continue
104 }
105 n := atoi(v)
106 if atoi(d) == e {
107 n, delta = n+delta, 0
108 }
109 if n > 0 {
110 out = append(out, d+":"+strconv.FormatInt(n, 10))
111 }
112 }
113 if delta > 0 {
114 out = append(out, strconv.FormatInt(e, 10)+":"+strconv.FormatInt(delta, 10))
115 }
116 return strings.Join(out, ",")
117}
118
119func (a account) free() int64 {
120 live, _ := held(a.holds, -1)
121 return a.balance - live
122}
123
124func positive(amt int64) {
125 if amt <= 0 {
126 panic("data: the amount must be positive")
127 }
128}
129
130// send pays out of the vault: the total goes down before the coins leave.
131func send(cur realm, acct string, to address, amt int64, event string) {
132 vaultTotal -= amt
133 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(cur.Address(), to, chain.Coins{{denom, amt}})
134 chain.Emit(event, "account", acct, "to", to.String(), "amount", strconv.FormatInt(amt, 10))
135}
136
137// VaultCredit adds amt, which the writer has just sent to this realm, to an
138// account funded by funder. A new funder needs an empty account
139// (VaultRefund first).
140func VaultCredit(cur realm, acct string, funder address, amt int64) {
141 gate(cur, acct)
142 positive(amt)
143 if !role.Canonical(funder) || IsGnoRadio(funder) {
144 panic("data: a funder is an account outside GnoRadio")
145 }
146 a := getAcct(acct)
147 if a.balance > 0 && a.funder != funder {
148 panic("data: refund the previous funder first")
149 }
150 // GetCoin >= vaultTotal holds, so the subtraction cannot wrap (an addition
151 // could, with a huge amt).
152 if banker.NewReadonlyBanker().GetCoin(cur.Address(), denom)-vaultTotal < amt {
153 panic("data: send the coins before crediting them")
154 }
155 if a.balance == 0 {
156 a.holds = ""
157 }
158 a.funder, a.balance = funder, a.balance+amt
159 vaultTotal += amt
160 putAcct(acct, a)
161 chain.Emit("VaultCredited", "account", acct, "funder", funder.String(), "amount", strconv.FormatInt(amt, 10))
162}
163
164// VaultHold reserves amt of the free balance until the end of day on the
165// vault's clock (from VaultDay to 31 days from now).
166func VaultHold(cur realm, acct string, amt, day int64) {
167 gate(cur, acct)
168 positive(amt)
169 if day < today() || day > now()/daySecs+maxHoldDays {
170 panic("data: a hold lapses within 31 days")
171 }
172 a := getAcct(acct)
173 if a.free() < amt {
174 panic("data: not enough free in this account")
175 }
176 a.holds = hold(a.holds, day, amt)
177 putAcct(acct, a)
178}
179
180// VaultRelease gives back up to amt of the hold lapsing on day and returns
181// what it released (less when part of it lapsed or was refunded).
182func VaultRelease(cur realm, acct string, amt, day int64) int64 {
183 gate(cur, acct)
184 positive(amt)
185 a := getAcct(acct)
186 if _, atE := held(a.holds, day); atE < amt {
187 amt = atE
188 }
189 if amt > 0 {
190 a.holds = hold(a.holds, day, -amt)
191 putAcct(acct, a)
192 }
193 return amt
194}
195
196// VaultPay pays amt from the live hold lapsing on day to an account outside
197// GnoRadio.
198func VaultPay(cur realm, acct string, to address, amt, day int64) {
199 gate(cur, acct)
200 positive(amt)
201 if !role.Canonical(to) || IsGnoRadio(to) {
202 panic("data: pay an account outside GnoRadio")
203 }
204 a := getAcct(acct)
205 if _, atE := held(a.holds, day); atE < amt {
206 panic("data: no such hold")
207 }
208 a.holds, a.balance = hold(a.holds, day, -amt), a.balance-amt
209 putAcct(acct, a)
210 send(cur, acct, to, amt, "VaultPaid")
211}
212
213// VaultRefund pays the whole balance back to the funder and drops the holds
214// (the profile changed hands). It returns the amount.
215func VaultRefund(cur realm, acct string) int64 {
216 gate(cur, acct)
217 a := getAcct(acct)
218 amt := a.balance
219 if amt == 0 {
220 return 0
221 }
222 a.balance, a.holds = 0, ""
223 putAcct(acct, a)
224 send(cur, acct, a.funder, amt, "VaultRefunded")
225 return amt
226}
227
228// VaultWithdraw pays the caller what is free in an account they funded. It
229// works whoever the writer is, while paused and after Renounce.
230func VaultWithdraw(cur realm, acct string) {
231 noPayment()
232 a := getAcct(acct)
233 free := a.free()
234 if a.funder != cur.Previous().Address() || free <= 0 {
235 panic("data: nothing of yours to withdraw here")
236 }
237 a.balance -= free
238 putAcct(acct, a)
239 send(cur, acct, a.funder, free, "VaultWithdrawn")
240}
241
242// VaultInfo is an account's funder, balance and free part (not held).
243func VaultInfo(acct string) (funder address, balance, free int64) {
244 a := getAcct(acct)
245 return a.funder, a.balance, a.free()
246}
247
248// VaultHeld is the live hold of an account lapsing on day.
249func VaultHeld(acct string, day int64) int64 {
250 _, atE := held(getAcct(acct).holds, day)
251 return atE
252}
253
254// VaultTotal is every account's balance: what this realm holds for them.
255func VaultTotal() int64 { return vaultTotal }