1// Package pilot is a realm-driven account: one realm holds the funds and the2// identity, a key pilots it, and its powers are separate realms installed3// afterwards without ever redeploying it. The gno answer to a Gnosis Safe4// with modules.5//6// An account realm keeps a *Pilot private and hands modules a narrow7// [Account] handle. Everything privileged stays on *Pilot, which is never8// 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 declaring13// package and re-checks the live roster and budget, so Revoke and14// 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 as16// "<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. Removing18// 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 pilot2324import (25 "chain"26 "chain/banker"27 "errors"28 "strings"2930 "gno.land/p/nt/avl/v0"31 "gno.land/p/nt/ufmt/v0"32)3334// 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-function38// territory, which a /p/ package may not declare). rlm is whatever the39// account chose to delegate: its own sub-identity token for a trusted40// module, or a zero value when the module was installed purse-only.41type Module interface {42 Name() string43 Run(_ int, rlm realm, args string) string44}4546// Grant says what a module was given.47type Grant uint84849const (50 // GrantPurse is funds only, revocable at any time.51 GrantPurse Grant = iota52 // GrantIdentity also lends the account's sub-identity. Permanent.53 GrantIdentity54)5556func (g Grant) String() string {57 if g == GrantIdentity {58 return "identity"59 }60 return "purse"61}6263var (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)7273type slot struct {74 mod Module75 subpath string76 grant Grant77 budget int6478 spent int6479 runs int6480 live bool81}8283// Pilot is the account. The realm that constructs it owns it and must not84// hand it out; hand out [Pilot.Handle] instead.85type Pilot struct {86 owner address87 host string // the account realm's own pkgpath88 vault banker.Banker // over host's address, minted once at New89 slots *avl.Tree // module pkgpath -> *slot90 handle *Account91}9293// Account is the module-facing half: two methods, both keyed on the calling94// realm's OWN pkgpath, which a realm cannot forge for another.95type Account struct {96 p *Pilot97}9899// New builds an account owned by the caller of the crossing function that100// 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 p111}112113// assertPlain is why Render can interpolate a path without escaping it: the114// only two strings a caller ever puts in the roster are checked here, at write115// 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}130131func assertCurrent(_ int, rlm realm) {132 if !rlm.IsCurrent() {133 panic(ErrStaleRealm)134 }135}136137// AssertOwner panics unless the user behind rlm owns the account. Only the138// account realm may call this: rlm must be its own live cur, so that139// 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}149150// Handle is what an account realm exposes to modules.151func (p *Pilot) Handle() *Account { return p.handle }152153func (p *Pilot) Owner() address { return p.owner }154func (p *Pilot) Host() string { return p.host }155156// Address is the account's main treasury.157func (p *Pilot) Address() address { return chain.PackageAddress(p.host) }158159// 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}164165func (p *Pilot) mustSlot(path string) *slot {166 s, ok := p.slots.Get(path).(*slot)167 if !ok {168 panic(ErrNotInstalled)169 }170 return s171}172173// Approve authorises a package path to install itself later. The code does174// not have to exist yet, which is the whole point: the account is deployed175// 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 = subpath182 old.grant = grant183 old.budget = budget184 old.spent = 0 // a new approval is a new allowance185 return186 }187 p.slots.Set(path, &slot{subpath: subpath, grant: grant, budget: budget})188}189190// 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 = budget194}195196// Revoke stops a module. It takes back its purse immediately; it does NOT197// 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 = false201}202203// Fund moves coins from the account's main treasury into a module's204// 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}211212// Exec drives an installed module. rlm must be the account realm's own live213// cur: an identity grant mints its sub-token from it, which only the account214// 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}227228// Register is called BY the module realm, with its own live cur. The account229// 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 = m238 s.live = true239}240241// 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}250251// Purse is a capability this package declares, so every use of it runs here252// 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 *Pilot256 path string257}258259// 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 += amount272 u.p.vault.SendCoins(u.p.Address(), to, chain.NewCoins(chain.NewCoin("ugnot", amount)))273}274275// 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 0280 }281 return s.budget - s.spent282}283284// Render is the account page. The account realm forwards its Render here.285func (p *Pilot) Render(path string) string {286 var sb strings.Builder287 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())))292293 if p.slots.Size() == 0 {294 sb.WriteString("No power installed yet.\n")295 return sb.String()296 }297298 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 false314 })315 return sb.String()316}317318func (p *Pilot) balance(addr address) int64 {319 return banker.NewReadonlyBanker().GetCoin(addr, "ugnot")320}321Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.