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

README.md

3.74 Kb · 109 lines

Capability Wallet — don't give your agent a wallet, give it a capability

The default way to let an agent spend or act on-chain is to hand it a private key and hope its prompt is safe. That is a category error. A key is all your authority; a prompt is a suggestion. The moment the agent is confused, jailbroken, or just wrong, it has the full key.

Three things get conflated and shouldn't:

  • identity — "this is agent Percy"
  • authorization — "Percy may call Assign on r/moul/issues"
  • approval — "Percy may do it at most twice, for ≤ 5 GNOT each, before block H, and I can revoke it instantly"

Identity is a passport. Authorization is a role. Approval is a capability: a narrow, bounded, expiring, revocable grant. This realm issues the third kind.

The grant

 1type Capability struct {
 2	Granter       address
 3	Principal     address // the ONLY address that may exercise it
 4	TargetRealm   string  // scoped to one realm
 5	Function      string  // scoped to one function
 6	MaxCoins      int64   // per-exercise ceiling
 7	ValidUntil    int64   // absolute block height, 0 = never
 8	RemainingUses uint32  // a use counter
 9	Revoked       bool    // a kill switch
10	Uses          []Use   // an audit trail
11}

Issuing one is a single call:

1// Percy may call r/moul/issues.Assign, ≤5 GNOT, twice, no expiry.
2id := capwallet.Grant(cross(cur), percyAddr,
3	"gno.land/r/moul/issues", "Assign",
4	5_000_000, // per-use ceiling (ugnot)
5	0,         // no expiry
6	2,         // two uses
7	"issue IDs 100-200")

Two consumers, two shapes

A target realm gates an action with a read-only, side-effect-free check:

1if !capwallet.Authorized(id, caller, coins) {
2	panic("not authorized")
3}

The agent consumes a use when it acts. Every bound is enforced at once — right principal, not revoked, not expired, uses remaining, under the ceiling — and the exercise is logged:

1capwallet.Exercise(cross(cur), id, 3_000_000) // 1 use left
2capwallet.Exercise(cross(cur), id, 1_000_000) // exhausted

And the granter kills it the instant something looks wrong:

1capwallet.Revoke(cross(cur), id) // Authorized() now returns false for everyone

Why this is a real safety primitive

The blast radius of a compromised agent is exactly the union of its live capabilities — not its whole wallet. You can reason about "what is the worst this agent can do right now" by reading a table, and shrink it to zero with one transaction. That is not something a system prompt can give you.

Browse the wallet at the realm root; each capability's bounds and full exercise log live at :<id>.

1gno test .

Limitations

  • ArgsPolicy is a human-readable note, not an enforced predicate. Real argument-level constraints ("only issue IDs 100–200") need the target realm to check them, or a richer on-chain policy language. This demo scopes to realm + function + coins + uses + expiry, which is already most of the value.
  • There's no delegation graph (a capability that can mint narrower capabilities). That's a natural extension, not a starting point.

Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

gno.land/r/moul/agents/capwallet/v0 dependency graph

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.