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

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}