protocol agentproto.shcli cli.agentproto.shpanel /panel
agentproto

AIP-49: WALLET — agentwallet/v1 (principal-owned multi-asset wallet)

A principal-owned multi-asset wallet primitive. Assets are declared ERC-20-style (ref/symbol/decimals/standard/peg/ruleSet) and partitioned into ERC-1410 tranches; value lives in an append-only event journal whose fold is the balance. A restriction lattice makes laundering structurally impossible and conversions no-arbitrage; an authorize policy engine separates passive (cost-of-existing) from active (chosen) spend and bounds delegation by lineage-min. All settlement rails (Stripe/PawaPay/x402/on-chain) are adapters behind a per-asset SettlementPort.

FieldValue
AIP49
TitleWALLET — agentwallet/v1 (principal-owned multi-asset wallet)
StatusDraft
TypeSchema
Domainwallet.sh
RequiresAIP-1, AIP-2
Resources./resources/aip-49ASSET.schema.json
Prior artERC-20 (fungible token), ERC-1400 / ERC-1410 (partially-fungible / partitioned token)

Abstract

This AIP defines a principal-owned, multi-asset wallet primitive and the defineAsset(...) signature that declares the assets it holds.

A wallet belongs to a principal (user / org / team / operator / agent — any addressable identity). It holds one or more assets, each declared ERC-20-style (ref, symbol, decimals, standard, optional peg, and a ruleSet). Each asset is divided into partitions — ERC-1410 tranches that share an asset's value but carry their own restriction, expiry, and spendable categories. Value lives as lots inside partitions; the lots are a projection (fold) of an append-only event journal that is the sole source of truth.

Three laws hold structurally rather than by runtime check:

  1. No laundering. Restrictions form a join-semilattice under union; conversion accumulates restrictions at the meet, never strips them.
  2. No arbitrage. The convert graph admits no cycle whose rate product exceeds 1.
  3. Bounded delegation. A delegate's effective allowance is the element-wise minimum down its delegation chain (lineage-min) — it can only narrow.

Spend carries an intent: passive (the metered cost of an agent existing / thinking — gated by balance + an operating cap) versus active (an economic action the agent chose — gated by a discretionary envelope). This is the safety boundary: an agent may operate freely without being able to move value out.

Motivation

A single mutable balance integer cannot express what agentic products already need, and bolting features onto it produces drift:

  1. Multiple assets, one wallet. Loyalty points, paid credits, currency balances, and (runtime) host- or chain-issued tokens are different assets with different pegs, networks, and rules — not one number. ERC-20-style declaration gives each a precise identity; decimals alone retires the recurring ×100 unit-bug class.

  2. Feature- and cohort-fenced value. "Image-only trial credits", "promo credits that expire in 21 days" are partitions (tranches), and "image credit can't pay for text" is a missing capability on a partition, not an if.

  3. Provenance and reconciliation. A regulator-grade ledger must replay: every spent unit traces to the grant — and ultimately the external payment — that funded it. An append-only journal whose fold equals the balance makes drift detectable and stops it.

  4. Agent safety. The dangerous capability is not spending but moving value out. Separating passive operation from active, discretionary, bounded settlement is the load-bearing guardrail against drift and prompt injection.

  5. Pluggable settlement. Stripe, PawaPay, x402, and on-chain rails are interchangeable adapters behind one port, chosen per asset — not branches in the wallet.

Design principles

  1. Asset ⊃ partition ⊃ lot — no taxonomy above the asset. There is no "asset class". The standard field (internal | iso4217 | erc20 | spl) is the only discriminator, and it routes to a settlement/valuation adapter polymorphically — never if standard === ….

  2. The journal is truth; balance is a fold. No cell is mutated. Lots and balances are replays of the event log. The reconciliation oracle asserts fold(events) == stored lots == balance continuously.

  3. Two edges, never conflated. coalesce merges lots WITHIN an asset (value-preserving, free); convert crosses BETWEEN assets (peg/forex, no-arbitrage). Only convert may cross a peg.

  4. Restrictions only accumulate. The lattice meet is union; conversion is burn + mint at the meet, so dust stays restricted and laundering is self-defeating.

  5. Spend intent is first-class. passive vs active is the authorization boundary; the allowance budgets the two separately (operating vs discretionary).

  6. Delegation narrows, never widens. Effective envelope = lineage-min over the delegation chain.

  7. Purity via ports. The primitive performs zero I/O. Storage, settlement, rate, and clock are injected (@agentproto/wallet/ports); hosts supply concrete adapters.

  8. Settlement is two-phase. Outbound value rides reserve → pay → capture | release, so internal value is never burned before the external leg confirms.

Specification

Asset declaration

An AssetDeclaration is the registry-keyed (ref) unit of value. ref is a unique UPPER_SNAKE string (convention <ORIGIN>_<SYMBOL>, or a bare ISO-4217 code for fiat). Full field schema in resources/aip-49/draft/ASSET.schema.json.

asset {
  ref         APP_CREDITS | APP_POINTS | USD | ERC20_USDT | SPL_USDC
  name · symbol
  decimals    minor-unit precision (2 = centi, 6 = USDC, 18 = most ERC-20)
  standard    internal | iso4217 | erc20 | spl     (→ adapter, not a taxonomy)
  chain?      ethereum | solana                     (on-chain standards)
  peg?        { vs, source }                         (→ RatePort)
  ruleSet     { settleOut, spendableOn, convertEdges, transfer, custody? }
  partitions? [ PartitionSpec, … ]                    (the partition catalog)
}

Partitions are co-located on the asset (a partition always belongs to one asset), so the asset registry IS the partition catalog: a shared common set plus per-app extend.

defineAsset(...) MUST: validate ref against ^[A-Z0-9][A-Z0-9_]{1,79}$, validate the declaration against the schema, reject a convert edge whose to equals the asset's own ref, and return a frozen handle.

Partition + policy

A partition is a tranche within an asset, identified <asset>:<tranche>. Lots in one partition coalesce freely. A partition carries a policy — the unified lifecycle runtime — instead of special-casing restriction and expiry as separate fields:

policy = [ rule, … ]            an ordered, JSON-serialisable list
rule =
  | { kind: "spendOn",  categories }                  narrows payable categories
  | { kind: "restrict", tags }                        stamps lattice tags (lineage)
  | { kind: "expire",   at: TimeSpec }                hard cliff → spendable 0
  | { kind: "decay",    curve, ratePerDay }           spendable = remaining × factor(t)
  | { kind: "vest",     cliff?, durationMs }           unlocks over time
  | { kind: "transfer", scope }                        soulbound | account | open
TimeSpec = { kind: "afterGrant", ms } | { kind: "absolute", at }

evaluatePolicy(policy, ctx) folds the rules into { spendable, eligible, restriction, nextTransitionAt }: quantity factors compose multiplicatively, eligibility is AND-ed, restriction tags fold via the lattice union, and nextTransitionAt is the earliest instant the spendable amount changes on its own (the sweep schedules on it). Relative TimeSpecs anchor on the lot's grant instant, so two grants into the same partition carry independent clocks. The legacy shorthand (restriction, narrowed spendableOn, defaultTtlMs) remains valid as a fast path for the common rules.

This maps directly on-chain: a policy is the off-chain twin of an ERC-1400 transfer-restriction module plus rebasing math; a relative-expiry partition is realised on-chain by stamping the deadline into the bytes32 partition id.

Journal + lots

Verbs (event_kind): mint · burn · reserve · capture · release · convert_in · convert_out · transfer. A convert emits a correlated convert_out + convert_in pair atomically; a hold is reserve then capture (settle) or release (return). fold(events, now) projects lots { original, remaining, reserved, expiresAt?, status }; the net position for an asset = Σ(remaining − reserved) over unexpired lots (the spendableBalance accessor). Exhausted lots are NOT skipped: a normal one contributes 0, but a drawn-down short lot carries a negative residual that is a real liability. Once floors exist this is no longer the same number as what a spend may draw — see Net position vs spend capacity.

Lot floors (short positions)

Amendment — codifies shipped behaviour. Declared and folded in packages/wallet/src/fold.ts (WalletEvent.floor, Lot.floor, floorOf) and honoured by spend in packages/wallet/src/coalescing.ts.

A mint / convert_in event MAY carry floor?: number | null, which becomes the lot's lower bound for remaining. Three cases:

floorMeaning
absent (default)0 — a normal lot; it cannot go short.
a negative numberA bounded short positionremaining may be drawn down to the floor (e.g. borrowing down to −500).
nullAn unbounded short position — no lower bound.

floorOf collapses the three cases to the numeric bound. A lot is exhausted once remaining <= floor — a partially drawn short lot stays active, and an unbounded one never exhausts via draws. Per-lot spendable availability is max(0, remaining − reserved − floor), so a short lot's negative headroom is spendable.

The kit is deliberately asset-agnostic about what a short position means — a postpaid overdraft settled monthly, a platform credit line, a clearing counter — that meaning is the host's decision, carried only in the floor it mints. The primitive enforces the arithmetic (bounds, exhaustion, spend ordering), never the semantics.

Net position vs spend capacity

Floors split one number into two, and an implementation MUST NOT substitute either for the other:

FormulaRangeAnswers
Net position (spendableBalance)Σ(remainingreserved) over unexpired lotsmay be negativewhat does this principal hold?
Spend capacity (per-lot, folded by coalescing)Σ max(0, remaining − reserved − floor) over eligible lotsnever negativewhat may this principal draw right now?

Before floors the two coincided, so the distinction had no observable consequence. With a short lot they diverge by the undrawn headroom: a lot with floor: −500, remaining: 0, reserved: 0 has net position 0 and spend capacity 500. A pre-flight check that gates a spend on the net position will refuse a spend the coalescer would have funded; a balance surfaced to a user as "available" that reports the net position will understate it by the credit line. Settlement rails reconcile against the net position — it is the liability. Spend admission uses the spend capacity.

An unbounded short lot (floor: null) has floorOf → −∞, hence spend capacity Infinity. This is intended, and it is why spend capacity MUST be evaluated per lot against a concrete requested amount rather than aggregated for display.

Coalescing (intra-asset spend)

A spend names asset + category + amount. Eligible lots are those whose asset matches and whose (partition-narrowed) spendableOn covers the category. Default order: owned-first — short (floor < 0) lots last — then restricted-first (spend constrained value before fungible), then expiring-first, then oldest. The ordering consequence is normative: borrowing from a short lot happens only once every real holding is drained, so general credit is spent before the account goes negative. An asset with spendableOn: [] (e.g. loyalty points) has NO eligible spend lot — it can only convert.

Convert (inter-asset) + no-arbitrage

convert burns the source and mints the destination at the lattice meet: out.restriction → in.restriction = meet(source, edge.addsRestriction). Amounts scale across decimals. A conformant implementation MUST provide a verifyNoArbitrage check (negative-cycle detection over -log(rate)) and gate it in CI: no cycle of convert edges may have a rate product > 1.

ConvertEdge.cap — reserved, not yet enforced. A convert edge MAY declare cap?: { amount, windowSec } (declared in packages/wallet/src/asset.ts — anti-inflation throughput limiting, load-bearing for host-mint). This field is reserved and NOT yet enforced: the reference convert path (packages/wallet/src/convert.ts) reads only to, rate, and addsRestriction. (The cap the authorize engine bounds is a different field — envelope.operating.cap.) Tracked code gap: https://github.com/agentproto/ts/issues/1203. Until that gap closes, hosts MUST NOT rely on cap being enforced and MUST apply their own throughput limiting on convert volume.

Authorize + delegation

authorize(request) returns { ok } or { ok: false, reason }. passive is gated by balance and the operating cap; active additionally requires settleOut to be permitted and is bounded by the discretionary envelope (amount cap, rate/window, counterparty allow-list, asset allow-list). Delegation chains resolve by minEnvelope (lineage-min): caps take the minimum, allow-lists intersect.

Ports

StoragePort (load/commit journal + lots, atomic AND serialized per account; spendableBalance accessor), SettlementPort (charge inbound / pay outbound, per asset), RatePort (universal rate oracle), ClockPort. All I/O is confined to these.

Reference implementation

@agentproto/wallet — pure (zero runtime/filesystem/HTTP), property-tested: restriction-lattice (semilattice axioms), fold (replay invariance + hold cycle), coalescing (owned-first with short lots last, restricted-/expiring-first, ineligibility of points), convert (decimals scaling, restriction accumulation, no-arbitrage cycle detection), authorize (intent gating, lineage-min). Exposes defineAsset via the AIP-56 createDoctype scaffold.

Known limitations

  • Held multi-currency (B) and on-chain-authority custody are declared in the model but not part of the reference cloud pipeline; convert-on-entry and custodial-mirror are the day-1 defaults.
  • Runtime/DB-backed asset declarations (host-minted tokens, arbitrary deposited ERC-20s) extend the config-first registry with a DB loader; the ref is the shared key so call sites are stable.
  • Agent-to-agent netting is supported by the event substrate but unspecified here.

Resources

Resources

Supporting artifacts for AIP-49. Links open the file on GitHub — markdown and JSON render natively in GitHub's viewer. Browse the full resource tree →