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.
| Field | Value |
|---|---|
| AIP | 49 |
| Title | WALLET — agentwallet/v1 (principal-owned multi-asset wallet) |
| Status | Draft |
| Type | Schema |
| Domain | wallet.sh |
| Requires | AIP-1, AIP-2 |
| Resources | ./resources/aip-49 — ASSET.schema.json |
| Prior art | ERC-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:
- No laundering. Restrictions form a join-semilattice under union;
conversion accumulates restrictions at the
meet, never strips them. - No arbitrage. The convert graph admits no cycle whose rate product exceeds 1.
- 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:
-
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;
decimalsalone retires the recurring ×100 unit-bug class. -
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. -
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.
-
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.
-
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
-
Asset ⊃ partition ⊃ lot — no taxonomy above the asset. There is no "asset class". The
standardfield (internal | iso4217 | erc20 | spl) is the only discriminator, and it routes to a settlement/valuation adapter polymorphically — neverif standard === …. -
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 == balancecontinuously. -
Two edges, never conflated.
coalescemerges lots WITHIN an asset (value-preserving, free);convertcrosses BETWEEN assets (peg/forex, no-arbitrage). Onlyconvertmay cross a peg. -
Restrictions only accumulate. The lattice
meetis union; conversion isburn + mintat the meet, so dust stays restricted and laundering is self-defeating. -
Spend intent is first-class.
passivevsactiveis the authorization boundary; the allowance budgets the two separately (operatingvsdiscretionary). -
Delegation narrows, never widens. Effective envelope = lineage-min over the delegation chain.
-
Purity via ports. The primitive performs zero I/O. Storage, settlement, rate, and clock are injected (
@agentproto/wallet/ports); hosts supply concrete adapters. -
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 inpackages/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:
floor | Meaning |
|---|---|
| absent (default) | 0 — a normal lot; it cannot go short. |
| a negative number | A bounded short position — remaining may be drawn down to the floor (e.g. borrowing down to −500). |
null | An 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:
| Formula | Range | Answers | |
|---|---|---|---|
Net position (spendableBalance) | Σ(remaining − reserved) over unexpired lots | may be negative | what does this principal hold? |
| Spend capacity (per-lot, folded by coalescing) | Σ max(0, remaining − reserved − floor) over eligible lots | never negative | what 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
refis the shared key so call sites are stable. - Agent-to-agent netting is supported by the event substrate but unspecified here.
Resources
ASSET.schema.json— asset declaration frontmatter / inline schema.
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 →
AIP-48: MULTI_AGENT_RUNTIME — agentruntimes/v1 (composable multi-agent execution kernel)
A declarative manifest format and reference kernel for multi-agent runtimes — seven swappable ports (participants, substrate, dispatcher, lifecycle, state, effectors, provisioning) composed under a single manifest. One declarative kit produces both a private local-journal swarm and a Guilde-bridged conversation participant, with no kernel changes. Sibling to AIP-9 OPERATOR (which describes one participant) — AIP-48 describes how participants execute together.
AUTH.md — agentauth/v1 (agentic server authentication manifest)
Defines the auth-provider doctype that lets CLI tools and agents authenticate to API servers via a standardized discovery chain and pluggable flow engines, aligned to the WorkOS auth.md open standard.