Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}