Agent wallets

An agent wallet is a separate XRP Ledger account. The user keeps its master key in the extension, and the ledger caps any loss at the account's balance.

Custody

  • A sub-account, not a delegate. The agent pays from its own XRPL account, funded by the user. Its master key is derived from the user's wallet and stays in the extension's vault.
  • Derivation. m/44'/144'/(1000+n)'/0/0 on Mainnet and m/44'/1'/(1000+n)'/0/0 on test networks, n = 0 to 99. The wallet's backup therefore recovers agent wallets too, and Mainnet and test agents never share a key or an account.
  • Remote server: the extension signs each approved payment with the agent wallet's master key, inside the vault. The server never holds a key.
  • Desktop agent: a fresh random ed25519 key, generated inside the signer, is set as the account's RegularKey. It is never derived from the user's seed, exported, imported or reused after revocation. A regular key cannot disable the master (tecNEED_MASTER_KEY), so the user can always take the account back.

Why this topology: it is the only capped one x402's XRPL exact scheme accepts. The spec rejects a transaction with Delegate, and the reference facilitator rejects multi-signed ones (Signers).

Funding and reserves

  1. The user's main account pays the new account: 1 XRP base reserve, 0.2 XRP per trust line, a fee float (default 0.5 XRP, about 40,000 payments) and the XRP budget.
  2. If RLUSD is enabled, the new account adds a trust line to Ripple's issuer and receives the RLUSD budget.
  3. For the desktop agent, the account's RegularKey is set to the agent's key. This is a hard stop in the extension: a full screen, typing AGENT, a 10-second wait and the password.

Top-ups are started only in the extension, with the vault unlocked, refilling to the budget at most once per period after a drift check. An agent can only ask (openwallet_request_topup, desktop).

Remote connections

  • The AI app registers with the server by OAuth 2.1 (PKCE S256, RFC 8414 and RFC 9728 metadata, dynamic client registration, exact redirect-URI match).
  • The authorise page shows the client's declared name, the scopes and an 8-character connection code (10 minutes, single use). It never asks for a code, a password or a sign-in.
  • In the extension, the user matches the code, picks the agent wallet and limits, and approves with Touch ID or the password. The extension signs a connection_grant (prefix OWGRANT\0) and posts it, device-signed.
  • Access tokens last 1 hour; refresh tokens rotate, last 30 days and are revoked on reuse. The server stores token hashes only. Revoking a connection in the extension invalidates its tokens at once.

Remote payments, step by step

  1. The server fetches the URL under the SSRF rules and parses the 402: x402 v2, XRPL exact, XRP or verified RLUSD, on the agent wallet's network.
  2. It pre-checks the price against the connection's grant (asset, per-payment and daily limits from the ledger, merchant host). Over a limit: over_limit, and no request is made.
  3. It creates a pending payment request that expires within min(maxTimeoutSeconds, 10 min) and notifies the user's extensions. They poll every 15 s while unlocked, every 15 minutes otherwise, and show a system notification.
  4. The extension shows the AI app's name, the merchant host, the URL, the amount and asset, the agent wallet, the remaining daily budget and the resource description as plain text.
  5. Independent verification. The extension fetches the URL itself (same method and body hash) and compares the 402 with the server's verbatim copy: scheme, network, payTo, asset and issuer, amount, destinationTag, sourceTag, maxTimeoutSeconds, assetTransferMethod, paymentFlow, every other extra key, and the URL, method, body hash and resource.url. Any difference: requirement_mismatch, and nothing is signed. extra.invoiceId and extra.nonce identify one 402 response, so they are not compared: the extension signs the requirement it fetched, with its own invoice id.
  6. After Touch ID or the password, the extension enforces the grant again and signs with the agent wallet's key. It posts the payment payload, device-signed. The server retries the request with it unchanged and returns the resource.

The first payment to a new merchant origin triggers Chrome's own permission prompt for that one origin, which the extension needs to fetch the price itself.

Where the desktop agent's key lives

On macOS, the ed25519 seed is wrapped to a Secure Enclave key and kept in the data-protection Keychain, readable only by the signer's own signed code, on that Mac only. It is unwrapped for one signature, then zeroised. The signer is a Developer ID-signed, notarised app with the hardened runtime.

Pairing the desktop agent

owpair1.<base64url(JSON)>.<base64url(sig)>
JSON = { "v":1, "network":"xrpl:0", "agent_key":"ED<64 hex>", "label":"…≤48 chars",
         "host":"…≤48", "created_at":"RFC3339", "nonce":"<16B hex>" }
sig  = ed25519(agent_key, "OWPAIR\0" ‖ JCS(JSON))       // proof of possession
SAS-1 = five BIP-39 words from SHA-256("OWSAS\0" ‖ request_bytes)

owgrant1.<base64url(JCS grant)>.<base64url(sig)>        // signed by the wallet's grant key
                                                        // over "OWGRANT\0" ‖ JCS(grant)

The comparison words close the one attack pairing has: swapping the agent's public key so the user sets the attacker's key on the account. Before it reports paired, the signer reads the ledger and checks that the account's RegularKey is its own, the master is enabled and there is no signer list.

To raise a limit, the extension issues a new grant with a higher seq, applied with openwallet-agent grant apply <token>. Tightening works locally (openwallet-agent limits --per-day RLUSD=2). Loosening locally is impossible.

The grant

The grant is signed by the wallet and enforced wherever the payment is signed. Defaults the extension proposes: per payment 10 RLUSD or 20 XRP, per day 50 RLUSD or 100 XRP, merchants approved on first payment, 30-day expiry. One agent wallet holds at most 500 RLUSD or 1,000 XRP. With the desktop agent, payments below 0.10 RLUSD or 0.2 XRP to pinned merchants go through without a prompt.

{
  "v": 1, "grant_id": "g_01J…", "seq": 1,
  "network": "xrpl:0",
  "agent_wallet": "r…",                 // the sub-account
  "agent_key": "ED…",
  "label": "Claude Code on MacBook",
  "assets": [
    { "asset": "XRP" },
    { "asset": "524C555344000000000000000000000000000000", "issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", "display": "RLUSD" }
  ],
  "limits": {                           // strings in each asset's x402 unit: drops, or the token value
    "XRP":   { "per_payment": "20000000", "per_period": "100000000", "auto_approve_below": "200000" },
    "RLUSD": { "per_payment": "10",       "per_period": "50",        "auto_approve_below": "0.10" }
  },
  "period": "P1D",                      // rolling 24 hours
  "lifetime": null,
  "fees": { "max_fee_drops": 1000, "per_period_drops": 200000 },
  "merchants": { "mode": "approve_first", "pins": [] },
  "approval": { "channel": "os" },
  "tickets": { "allowed": false, "max": 0 },
  "signature_lifetime_ledgers": 20,     // hard ceiling, about 80 seconds
  "flows": ["authorization", "upfront"],
  "mcp_relay": { "allowed": false, "per_payment": { "RLUSD": "0.10" } },
  "expires_at": "2026-12-31T00:00:00Z",
  "dev_mode": false,                    // must be true for xrpl:1 and xrpl:2
  "issued_at": "2026-09-30T10:00:00Z",
  "grant_pub": "ED…"
}

What the desktop agent checks on every payment

It refuses at the first failure, with a typed error code:

  1. State: paired, not paused or revoked, grant not expired, within rate limits. Three declined approvals in an hour, or any hard refusal, pauses the agent and notifies the user.
  2. Network: the ledger server's network_id equals the grant's network.
  3. Challenge source: it fetched the 402 itself, and resource.url has the same origin as the request (or is mcp://tool/<the tool called>).
  4. Entry: exact on the grant's network, an asset and issuer in the grant, a parseable amount, fees not sponsored, a known payment flow.
  5. Merchant: pinned, or approved by the user on first payment. A changed payTo for a known site needs approval again.
  6. Amount: within the per-payment, period and lifetime limits, the caller's max_amount and the spendable balance.
  7. Destination: exists, gets a tag if it requires one, does not require deposit authorisation, holds the trust line.
  8. Build and check: the core builds the payment and checks the decoded bytes against the facilitator's rules. It expires within 20 ledgers.
  9. One payment at a time per account until it settles or expires.
  10. Approval, if needed, then sign, recording a pending hold before the request leaves the machine.
  11. Send, then read the ledger. The hold is released only when the ledger shows the outcome.

Spent-this-period is counted from the ledger (delivered_amount of validated payments), not from a local counter.

Approvals

Remote serverDesktop agent
WhereThe OpenWallet extension, on the user's deviceThe macOS prompt: Touch ID, Apple Watch or the login password
Which paymentsEvery paymentFirst payment to a merchant, a changed payTo, or an amount above the silent threshold
ShowsApp name, merchant, URL, amount, agent wallet, remaining budgetAmount, merchant, address, network and an 8-character code
Times outThe tool call waits 60 s, then returns pending; the request lives up to 10 min120 s

Anything above a limit, or an asset or network outside the grant, is refused outright; only a new grant from the extension changes that. Approvals inside the chat (MCP elicitation) are not offered, because an MCP client or its hooks can answer them for the user.

Watching and stopping

The extension reads each agent account on unlock, every 15 minutes while the browser runs, and before any top-up. It expects its paired RegularKey (desktop) or none (remote), the master enabled, no signer list, no delegation, only the grant's trust lines, no offers, checks, escrows or channels, and only payments going out. Anything else raises a notification with Stop agent and blocks top-ups.

Stopping disconnects the app or removes the RegularKey (with a high fee so it lands first where possible), removes any signer list, returns RLUSD and XRP to the user's main account, and can delete the account to recover its 1 XRP reserve once it is 256 ledgers old.