pilot.gno
9.78 Kb · 320 lines
1// Package pilot is a realm-driven account: one realm holds the funds and the
2// identity, a key pilots it, and its powers are separate realms installed
3// afterwards without ever redeploying it. The gno answer to a Gnosis Safe
4// with modules.
5//
6// An account realm keeps a *Pilot private and hands modules a narrow
7// [Account] handle. Everything privileged stays on *Pilot, which is never
8// returned, so a module can only reach the two methods it needs.
9//
10// Two ways to delegate, and they differ in exactly one property:
11//
12// - A [Purse] is revocable. Every method on it re-enters the declaring
13// package and re-checks the live roster and budget, so Revoke and
14// SetBudget take effect immediately, even on a purse a module retained.
15// - A sub-identity token (rlm.Sub) is permanent. It lets the module act as
16// "<account>#<subpath>" toward any other realm, which a purse cannot do,
17// but the module can mint a banker from it and keep it forever. Removing
18// the module does not take that back; only emptying the sub-address does.
19// Grant one only to code you have read.
20//
21// Live instance: r/moul/pilot. Demo module: r/moul/x/pilotdemo.
22package pilot
23
24import (
25 "chain"
26 "chain/banker"
27 "errors"
28 "strings"
29
30 "gno.land/p/nt/avl/v0"
31 "gno.land/p/nt/ufmt/v0"
32)
33
34// Module is implemented by a module realm and installed into an account.
35// The account never imports it: it learns the module only as this interface.
36//
37// Run is threaded (the leading int keeps it out of crossing-function
38// territory, which a /p/ package may not declare). rlm is whatever the
39// account chose to delegate: its own sub-identity token for a trusted
40// module, or a zero value when the module was installed purse-only.
41type Module interface {
42 Name() string
43 Run(_ int, rlm realm, args string) string
44}
45
46// Grant says what a module was given.
47type Grant uint8
48
49const (
50 // GrantPurse is funds only, revocable at any time.
51 GrantPurse Grant = iota
52 // GrantIdentity also lends the account's sub-identity. Permanent.
53 GrantIdentity
54)
55
56func (g Grant) String() string {
57 if g == GrantIdentity {
58 return "identity"
59 }
60 return "purse"
61}
62
63var (
64 ErrNotOwner = errors.New("pilot: not the owner")
65 ErrNotApproved = errors.New("pilot: path not approved")
66 ErrNotInstalled = errors.New("pilot: module not installed")
67 ErrRevoked = errors.New("pilot: module revoked")
68 ErrOverBudget = errors.New("pilot: over budget")
69 ErrStaleRealm = errors.New("pilot: stale realm value")
70 ErrBadName = errors.New("pilot: a path and a subpath are [a-zA-Z0-9._/-] and not empty")
71)
72
73type slot struct {
74 mod Module
75 subpath string
76 grant Grant
77 budget int64
78 spent int64
79 runs int64
80 live bool
81}
82
83// Pilot is the account. The realm that constructs it owns it and must not
84// hand it out; hand out [Pilot.Handle] instead.
85type Pilot struct {
86 owner address
87 host string // the account realm's own pkgpath
88 vault banker.Banker // over host's address, minted once at New
89 slots *avl.Tree // module pkgpath -> *slot
90 handle *Account
91}
92
93// Account is the module-facing half: two methods, both keyed on the calling
94// realm's OWN pkgpath, which a realm cannot forge for another.
95type Account struct {
96 p *Pilot
97}
98
99// New builds an account owned by the caller of the crossing function that
100// reaches it. Call it once, from the account realm, passing that realm's cur.
101func New(_ int, rlm realm) *Pilot {
102 assertCurrent(0, rlm)
103 p := &Pilot{
104 owner: rlm.Previous().Address(),
105 host: rlm.PkgPath(),
106 vault: banker.NewBanker(banker.BankerTypeRealmSend, rlm),
107 slots: avl.NewTree(),
108 }
109 p.handle = &Account{p: p}
110 return p
111}
112
113// assertPlain is why Render can interpolate a path without escaping it: the
114// only two strings a caller ever puts in the roster are checked here, at write
115// time, against the charset a package path and a subpath are built from.
116func assertPlain(s string) {
117 if s == "" {
118 panic(ErrBadName)
119 }
120 for i := 0; i < len(s); i++ {
121 c := s[i]
122 switch {
123 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
124 case c == '.', c == '/', c == '-', c == '_':
125 default:
126 panic(ErrBadName)
127 }
128 }
129}
130
131func assertCurrent(_ int, rlm realm) {
132 if !rlm.IsCurrent() {
133 panic(ErrStaleRealm)
134 }
135}
136
137// AssertOwner panics unless the user behind rlm owns the account. Only the
138// account realm may call this: rlm must be its own live cur, so that
139// rlm.Previous() is the signer and not some intermediary's caller.
140func (p *Pilot) AssertOwner(_ int, rlm realm) {
141 assertCurrent(0, rlm)
142 if rlm.PkgPath() != p.host {
143 panic(ErrNotOwner)
144 }
145 if rlm.Previous().Address() != p.owner {
146 panic(ErrNotOwner)
147 }
148}
149
150// Handle is what an account realm exposes to modules.
151func (p *Pilot) Handle() *Account { return p.handle }
152
153func (p *Pilot) Owner() address { return p.owner }
154func (p *Pilot) Host() string { return p.host }
155
156// Address is the account's main treasury.
157func (p *Pilot) Address() address { return chain.PackageAddress(p.host) }
158
159// SubAddress is one module's own treasury, derivable off-chain by anyone.
160func (p *Pilot) SubAddress(path string) address {
161 s := p.mustSlot(path)
162 return chain.DerivePkgSubAddr(p.host, s.subpath)
163}
164
165func (p *Pilot) mustSlot(path string) *slot {
166 s, ok := p.slots.Get(path).(*slot)
167 if !ok {
168 panic(ErrNotInstalled)
169 }
170 return s
171}
172
173// Approve authorises a package path to install itself later. The code does
174// not have to exist yet, which is the whole point: the account is deployed
175// once and learns new powers afterwards.
176func (p *Pilot) Approve(_ int, rlm realm, path, subpath string, grant Grant, budget int64) {
177 p.AssertOwner(0, rlm)
178 assertPlain(path)
179 assertPlain(subpath)
180 if old, ok := p.slots.Get(path).(*slot); ok {
181 old.subpath = subpath
182 old.grant = grant
183 old.budget = budget
184 old.spent = 0 // a new approval is a new allowance
185 return
186 }
187 p.slots.Set(path, &slot{subpath: subpath, grant: grant, budget: budget})
188}
189
190// SetBudget is the live knob. It applies to a purse a module already holds.
191func (p *Pilot) SetBudget(_ int, rlm realm, path string, budget int64) {
192 p.AssertOwner(0, rlm)
193 p.mustSlot(path).budget = budget
194}
195
196// Revoke stops a module. It takes back its purse immediately; it does NOT
197// take back a sub-identity that was granted, nor any banker minted from one.
198func (p *Pilot) Revoke(_ int, rlm realm, path string) {
199 p.AssertOwner(0, rlm)
200 p.mustSlot(path).live = false
201}
202
203// Fund moves coins from the account's main treasury into a module's
204// sub-treasury. For an identity grant this is the permanent blast radius.
205func (p *Pilot) Fund(_ int, rlm realm, path string, amount int64) {
206 p.AssertOwner(0, rlm)
207 s := p.mustSlot(path)
208 p.vault.SendCoins(p.Address(), chain.DerivePkgSubAddr(p.host, s.subpath),
209 chain.NewCoins(chain.NewCoin("ugnot", amount)))
210}
211
212// Exec drives an installed module. rlm must be the account realm's own live
213// cur: an identity grant mints its sub-token from it, which only the account
214// realm's namespace can do.
215func (p *Pilot) Exec(_ int, rlm realm, path, args string) string {
216 p.AssertOwner(0, rlm)
217 s := p.mustSlot(path)
218 if !s.live {
219 panic(ErrRevoked)
220 }
221 s.runs++
222 if s.grant == GrantIdentity {
223 return s.mod.Run(0, rlm.Sub(s.subpath), args)
224 }
225 return s.mod.Run(0, rlm, args)
226}
227
228// Register is called BY the module realm, with its own live cur. The account
229// reads the path from the runtime and never from an argument.
230func (a *Account) Register(_ int, rlm realm, m Module) {
231 assertCurrent(0, rlm)
232 path := rlm.PkgPath()
233 s, ok := a.p.slots.Get(path).(*slot)
234 if !ok {
235 panic(ErrNotApproved)
236 }
237 s.mod = m
238 s.live = true
239}
240
241// PurseFor hands the calling module its revocable purse.
242func (a *Account) PurseFor(_ int, rlm realm) *Purse {
243 assertCurrent(0, rlm)
244 path := rlm.PkgPath()
245 if _, ok := a.p.slots.Get(path).(*slot); !ok {
246 panic(ErrNotApproved)
247 }
248 return &Purse{p: a.p, path: path}
249}
250
251// Purse is a capability this package declares, so every use of it runs here
252// and is re-checked against live state. That is what makes it revocable,
253// where a banker minted from a lent realm token is not.
254type Purse struct {
255 p *Pilot
256 path string
257}
258
259// Pay spends from the account's main treasury, against the module's budget.
260func (u *Purse) Pay(to address, amount int64) {
261 s, ok := u.p.slots.Get(u.path).(*slot)
262 if !ok {
263 panic(ErrNotInstalled)
264 }
265 if !s.live {
266 panic(ErrRevoked)
267 }
268 if amount <= 0 || amount > s.budget-s.spent {
269 panic(ErrOverBudget)
270 }
271 s.spent += amount
272 u.p.vault.SendCoins(u.p.Address(), to, chain.NewCoins(chain.NewCoin("ugnot", amount)))
273}
274
275// Left is what this module may still spend.
276func (u *Purse) Left() int64 {
277 s, ok := u.p.slots.Get(u.path).(*slot)
278 if !ok || !s.live {
279 return 0
280 }
281 return s.budget - s.spent
282}
283
284// Render is the account page. The account realm forwards its Render here.
285func (p *Pilot) Render(path string) string {
286 var sb strings.Builder
287 sb.WriteString("# " + p.host + "\n\n")
288 sb.WriteString("| | |\n|---|---|\n")
289 sb.WriteString("| owner | " + p.owner.String() + " |\n")
290 sb.WriteString("| treasury | " + p.Address().String() + " |\n")
291 sb.WriteString(ufmt.Sprintf("| balance | %d ugnot |\n\n", p.balance(p.Address())))
292
293 if p.slots.Size() == 0 {
294 sb.WriteString("No power installed yet.\n")
295 return sb.String()
296 }
297
298 sb.WriteString("## powers\n\n")
299 sb.WriteString("| path | grant | identity | state | left | runs | sub-treasury |\n")
300 sb.WriteString("|---|---|---|---|---|---|---|\n")
301 p.slots.Iterate("", "", func(key string, v any) bool {
302 s := v.(*slot)
303 state := "approved"
304 if s.live {
305 state = "installed"
306 } else if s.mod != nil {
307 state = "revoked"
308 }
309 sub := chain.DerivePkgSubAddr(p.host, s.subpath)
310 sb.WriteString(ufmt.Sprintf("| `%s` | %s | `%s#%s` | %s | %d | %d | %d ugnot |\n",
311 key, s.grant.String(), p.host, s.subpath, state,
312 s.budget-s.spent, s.runs, p.balance(sub)))
313 return false
314 })
315 return sb.String()
316}
317
318func (p *Pilot) balance(addr address) int64 {
319 return banker.NewReadonlyBanker().GetCoin(addr, "ugnot")
320}