⚠️ Disclaimer: This code is provided as is, it's a prototype. It has not been audited, there is no guarantee that it will be maintained, and it should be considered for test and educational purposes only — not for Mainnet or any deployment that handles real value. Forking and adapting it is encouraged; running it unmodified against live funds is not.
A template/demo where a merchant periodically issues a permissioned Multi-Purpose Token (MPT) representing a Real World Asset (RWA), and an autonomous agent — given a single instruction and an API key — bootstraps everything it needs (wallet, funding, trust lines, MPT authorization, on-chain swap) and acquires those tokens by paying through the Machine Payments Protocol (MPP).
The headline is the autonomy of the setup, not the sophistication of the purchase. The agent starts with one sentence:
"Acquire every RWA token available from the merchant whose service endpoint is
<MERCHANT_URL>."
Everything else — creating a wallet, funding it, discovering issuances, acquiring the payment currency, opting in, paying, taking delivery — it figures out. The private key never leaves Open Wallet Standard (OWS).
This project is an independent consumer of xrpl-mpp-sdk
and @open-wallet-standard/core — it
does not fork or vendor either.
┌────────────────────────── operator setup ──────────────────────────┐
│ merchant (issuer + MPP server) │
│ • self-funds, creates permissioned RWA MPT (tfMPTRequireAuth) │
│ • sets RLUSD trust line, serves /catalog + /rwa/:id (MPP 402) │
│ • on paid 402: MPTokenAuthorize(payer) then issues the MPT │
└──────────────────────────────────────────────────────────────────────┘
▲ │ 402 challenge / delivery
MPP credential │ ▼
┌──────────────────────────── agent autonomy ───────────────────────────────────────┐
│ Claude Agent SDK loop ──drives──► tools (two modes: rails | minimal) │
│ discover ─► fund ─► opt-in (MPTokenAuthorize) ─► trust (RLUSD) ─► │
│ swap (XRP→RLUSD OfferCreate) ─► pay (MPP push mode) ─► confirm receipt │
│ │ │
│ every tx ──► OWS vault (holds key, enforces policy) ──► XRPL │
└──────────────────────────────────────────────────────────────────────────────────┘
- Operator setup = the merchant. It is bootstrapped (funded, issuance created) with no manual steps when the server starts.
- Agent autonomy = the buyer. It is given only the seller's service endpoint (a URL) and an API key — not the merchant's ledger address. It reads the endpoint's catalog to find the resources on offer and learns each purchase's payment recipient, amount, and currency from the resource's HTTP 402 challenge when it pays. No wallet, funding, trust line, authorization, or swap is pre-provisioned.
The agent's key is generated inside OWS and never leaves it. Every transaction the
agent issues — activation, MPTokenAuthorize opt-in, TrustSet, the XRP→RLUSD
OfferCreate, and the MPP payment — is signed by OWS. There are two signing paths,
both keeping the key in the vault:
- Native (
NativeOwsSigner, the default for every ordinary write): OWS 1.4.2signAndSendaccepts the policy-bound token, injects theSigningPubKeyitself, signs, and broadcasts. The agent never needs the public key. - Channel (
OwsXrplSigner, payment-channel mode only): here the public key is needed as a value — for thePaymentChannelCreate.PublicKeyfield and to verify off-ledger claims — and OWS does not expose it. So this path recovers the secp256k1 key from asignHashsignature (ECDSA, matching the OWS address), sets it asSigningPubKey, signs the tx's signing hash viasignHash, and broadcasts the assembled blob itself via xrpl.js.
The MPP payment is done in push mode: OWS signs the on-chain Payment, then the tx hash is handed to the SDK-powered merchant via an mppx credential — so the key stays in OWS while the merchant still verifies the payment.
The agent signs with a policy-bound OWS API token, so the OWS policy is enforced at the signing boundary — a misbehaving (or model-driven) agent cannot get a non-compliant transaction signed:
- XRPL only + a time-bounded API token (declarative rules).
- Per-transaction spend cap of
MAX_SPENDXRP on irreversible native-XRP outflow, via an OWS executable policy (packages/agent/policy/max-spend.mjs) that decodes the tx and denies overflow on aPayment(Amount/SendMax) or anOfferCreate(native TakerGets). APaymentChannelCreatedeposit is a recoverable lock (not a terminal spend), so it is not gated — the channel capacity bounds the streamed spend instead.
MAX_SPEND is enforced solely by the OWS policy at signing, in both modes — the app
never gates spend in code (it only reads the cap to provision funding and to inform the
model). (OWS has no cumulative/rolling limit yet, so the cap is per-transaction; OWS does
expose a spending.daily_total, so a daily cap is a possible add.)
Verify it yourself:
MAX_SPEND=10 pnpm check:policyopens a fresh capped wallet, funds it above the cap, and tries to sign over-cap txs via the policy-bound token (the agent's only signing path). Over-capPayment/OfferCreateare denied; an under-cap one is allowed. The executable reads the tx attransaction.raw_hex— using the wrong field name silently fails open, so this probe and the unit test pin that contract.
This section is the developer-level detail behind the two subsections above: where the key lives, what the agent can actually touch, and how signing is brokered.
OWS stores everything under a vault directory (OWS_VAULT_PATH, default ~/.ows),
created with 700 permissions, in three folders:
<vault>/
wallets/<wallet-id>.json # the encrypted key material
keys/<key-id>.json # policy-bound API tokens
policies/<policy-id>.json # the rules a token is evaluated against
The wallet is a BIP39 mnemonic (key_type: "mnemonic") derived into one account per
chain via BIP44 paths; the XRPL account is m/44'/144'/0'/0/0. The mnemonic is sealed in
the wallet file's crypto block and is never written in cleartext:
- KDF:
scrypt(n=65536, r=8, p=1) stretches the owner passphrase (OWS_PASSPHRASE) + a per-wallet salt into a 256-bit key. - Cipher:
aes-256-gcm(withiv+auth_tag) encrypts the mnemonic. GCM is authenticated — any tampering with the ciphertext is detected on decrypt.
Without the owner passphrase, the ciphertext is inert — so the passphrase strength is the
at-rest protection. OWS itself does not enforce it: createWallet happily "encrypts"
the owner copy with an empty/trivial passphrase (scrypt over a zero-entropy secret = no real
protection). This template therefore rejects weak/empty OWS_PASSPHRASE at wallet creation
(requireStrongOwnerPassphrase), and the demo generates a strong random one.
The agent never receives the owner passphrase. It is handed a policy-bound API token
minted by createApiKey(...). In keys/<id>.json:
- the raw token is not stored, only its
token_hash(SHA-256); wallet_secretsholds a copy of the key re-encrypted with a key derived from the token (hkdf-sha256, infoows-api-key-v1). That is what lets the token unlock its own copy of the key to sign — no passphrase needed;- the token carries its scopes:
wallet_ids,policy_ids, andexpires_at.
So the token is a narrow, revocable, time-bounded capability — not the key, and not the passphrase.
A policy (policies/<id>.json) combines two layers, with action: "deny" as the
default:
- Declarative rules — here
allowed_chains(xrpl:mainnetonly) andexpires_at(the token's right to sign lapses after 30 days). - An executable policy —
executablepoints at an external program (packages/agent/policy/max-spend.mjs). On every signing request OWS pipes aPolicyContextJSON to its stdin (the decoded tx, the policyconfig, …) and reads{ allow, reason }from stdout. Ours decodes the tx, sums the XRP outflow, and denies when it exceedsconfig.maxSpendXrp.
The policy binds because the agent signs with the token (which references
policy_ids), not with the passphrase. That is the "enforced at the signing boundary"
guarantee: even a model-driven agent cannot get an out-of-policy transaction signed.
ensureAgentWallet (packages/agent/src/tools/wallet.ts) wires this up: create wallet →
create policy → mint token → sign with the token.
chain_idsusesxrpl:mainneteven on testnet: OWS's XRPL chain id is network-agnostic (XRPL addresses are the same across networks). Testnet vs mainnet is decided by the RPC URL, not by OWS.
A signed XRPL transaction must carry SigningPubKey (the signer's public key) alongside
TxnSignature; rippled checks the signature against that key and the key against the
Account. OWS 1.4.2 can inject that SigningPubKey itself — so most flows never touch the
public key — but it still does not expose the key as a value, which payment channels
need. Hence two signers:
NativeOwsSigner (default — packages/agent/src/signer/native-ows-signer.ts). Used by
every ordinary write (opt-in, trustline, swap, MPP payment):
- autofill the tx via xrpl.js (
Account/Sequence/Fee/LastLedgerSequence), without aSigningPubKey(OWS rejects one on an unsigned tx and does not autofill those fields); - hand the encoded hex to OWS
signAndSendover the HTTP JSON-RPC endpoint — OWS injects theSigningPubKey, signs (token ⇒ policy-enforced), and broadcasts, returning the tx hash; - wait for validation via the WebSocket client.
OwsXrplSigner (channel mode only — packages/agent/src/signer/ows-xrpl-signer.ts).
Payment channels need the public key as a value — for PaymentChannelCreate.PublicKey
and to verify off-ledger claims — and OWS won't expose it, so this path:
- asks OWS to
signHasha known hash (this does not export the key); - recovers the secp256k1 public key from that signature via ECDSA recovery — try both recovery bits and keep the candidate whose derived XRPL address matches the OWS account;
- sets it as
SigningPubKey, autofills, then signs the tx's signing hash (sha512half(encodeForSigning(tx))) viasignHashand broadcasts the assembled blob ourselves via xrpl.js. The same path also yields the channelopenblob without broadcasting (the merchant submits it), and the recovered pubkey is reused as the channel public key + to verify claims.
Both share the XrplSubmitSigner interface (address() + signAndSubmit()), so the tools
don't care which one they get; both strip NetworkID (ids ≤ 1024 must omit it) and serialize
signing through a mutex (the account sequence is not concurrency-safe).
Why two paths (OWS 1.4.2): the SigningPubKey-injection fix made
signTransaction/signAndSendaccept the policy-bound token and inject the pubkey themselves (OWS ≤1.3.2 rejected the token withInvalidSecretKey, which is why everything used to go throughsignHash). That fix lets ordinary writes drop the recovery entirely. But OWS still does not expose the account public key (getWallet/AccountInfo), which is what channel mode needs — so the ECDSA recovery survives only there. The clean upstream fix would be exposing the pubkey (or an external-signer constructor,Wallet.fromSigner), which would remove the recovery from channel mode too.
| The agent has | The agent never has |
|---|---|
| the merchant endpoint URL + its own API key | the merchant's ledger address (it learns it from the 402) |
| a policy-bound OWS token (XRPL-only, expiring, spend-capped) | the OWS owner passphrase |
OWS-brokered signing (native signAndSend, or signHash in channel mode) |
the private key / mnemonic (it stays encrypted in the vault) |
| its own XRPL address (+ the recovered public key in channel mode) | any way to export the key (exportWallet/seed paths are blocked by a test) |
The agent's code depends on xrpl.js for all ledger reads — withClient
(packages/shared/src/xrpl.ts) opens an xrpl.js Client over WebSocket
(XRPL_RPC_URL) and issues rippled queries (account_info, account_objects,
account_lines, book_offers, tx). Writes are signed inside OWS — ordinary writes via
signAndSend (OWS broadcasts), channel writes via signHash (we broadcast the assembled
blob over the same xrpl.js WebSocket client) — the key never leaves the vault either way.
What the model can query depends on the mode: in rails the read happens inside
domain tools (get_status, discover_issuances, quote_resource, …) and the model only
sees shaped results; in minimal a thin xrpl_query tool forwards {command, params}
to client.request — full read access, but still executed by our code, never by the model.
"The agent has xrpl.js" is about the code, not the model. The model does not run
code — it cannot import xrpl or call Wallet.generate(). It can only emit tool calls
against a fixed allow-list, and:
- No tool generates, holds, or signs with a local key. The only signing primitive
(
xrpl_sign_submitin minimal; the domain verbs in rails) routes exclusively through the OWS signer. The model can build a transaction JSON, but the sole way it reaches the ledger is OWS — there is no local-key path to choose. xrpl_queryis read-only (client.request, never a locally-signedsubmit); xrpl.js is reachable only from our code.- The surface is locked:
allowedToolsis an explicit allow-list andsettingSources: []means no host MCP servers / skills / tools are loaded. - A build-time guard (
packages/agent/src/signer/isolation.test.ts) fails CI if anyone adds a local-key path (fromSeed,fromFaucet,exportWallet, …) to the agent code.
So the guarantee is structural, not prompt-based: the agent is genuinely autonomous, but its only on-chain write path is OWS — itself bounded by the policy.
packages/
shared/ # config (networks, assets), env, xrpl helpers, logger, SDK re-exports
merchant/ # RWA issuer + MPP charge server + delivery (bootstrap, server, issuer)
src/channel-server.ts # channel-mode merchant (MPP `channel` intent, XRP)
agent/
src/signer/ # OWS signing bridge (see common.ts for the shared interface)
common.ts # XrplSubmitSigner interface + shared options/helpers
native-ows-signer.ts # default: OWS signAndSend (no pubkey needed)
ows-xrpl-signer.ts # channel-only: signHash + pubkey recovery, self-broadcast
ows-channel-signer.ts # OWS-signed PayChannel claims (channel mode)
src/tools/ # rails: discovery, funding, swap, trustline, mpp, wallet, channel
src/loop.ts # rails agent (high-level domain tools)
src/minimal.ts # minimal agent (generic primitives only)
src/channel.ts # payment-channel buyer (streaming via vouchers)
src/pipeline.ts # deterministic fallback (keyless / CI)
policy/max-spend.mjs # OWS executable spend-cap policy
scripts/ # check-testnet, check-channel, check-policy, demo, vendor-sdk
vendor/ # locally built xrpl-mpp-sdk tarball (gitignored; regenerated by CI)
Two charge-mode agents (rails / minimal, below) acquire RWAs with one on-chain payment each; a third channel-mode variant streams purchases over an XRP payment channel (see Streaming over a payment channel).
Everything the buyer agent does lives in packages/agent/. Read it in this order:
| File | Role |
|---|---|
src/index.ts |
CLI entrypoint (pnpm agent). Builds the context, then runs the model loop (if ANTHROPIC_API_KEY) or the deterministic pipeline. |
src/minimal.ts |
The minimal entrypoint (pnpm agent:minimal) — its own tool set of generic primitives + the model loop. |
src/context.ts |
buildAgentContext() — loads env, resolves the network, ensures the OWS wallet, and assembles the shared deps bundle + the goal. The one place setup happens. |
src/pipeline.ts |
AcquireDeps type + runAcquisition() — the deterministic acquisition (no model): quote → opt-in → trust+swap → pay → confirm. The rails tools wrap these same steps. |
src/loop.ts |
The rails agent: wraps the domain functions as model tools (tool()), defines the system prompt, runs query(). |
src/state.ts |
AgentStore (persisted under .data/): wallet id, address, policy id, the OWS token, maxSpendXrp, and the acquired set. |
src/signer/common.ts |
The shared XrplSubmitSigner interface (address() + signAndSubmit()) + options/helpers. Tools depend on this, not a concrete signer. |
src/signer/native-ows-signer.ts |
The default signer: OWS 1.4.2 signAndSend (OWS injects SigningPubKey + broadcasts). Every ordinary write goes through signer.signAndSubmit(tx, { label }). |
src/signer/ows-xrpl-signer.ts |
The channel-mode signer: pubkey recovery + signHash + self-broadcast via xrpl.js. Also exposes publicKey()/signToBlob()/signDigest() that channels need. |
src/tools/wallet.ts |
ensureAgentWallet() — creates the OWS wallet + policy + token (or reuses the stored one). Its SignerKind arg picks the signer: native (default) or channel. |
src/tools/discovery.ts |
Read the seller catalog + on-ledger cross-check → the list of acquirable issuances. |
src/tools/funding.ts |
Reserve sizing + faucet funding. |
src/tools/trustline.ts |
ensureIouTrustline() (TrustSet) + optInToMpt() (holder MPTokenAuthorize). |
src/tools/swap.ts |
ensureIouBalance() — XRP→IOU OfferCreate, sized from the live book quote. |
src/tools/mpp.ts |
quoteResource() (read a 402) + payViaMpp() (push-mode pay + credential). |
src/channel.ts |
Channel-mode buyer (pnpm agent:channel): open a PayChannel, stream voucher purchases, close. |
src/tools/channel.ts |
Channel ops: open / sign voucher / close (used by the driver + check:channel). |
src/signer/ows-channel-signer.ts |
OWS-signed PayChannel claims (signClaim) — byte-identical to the SDK, verified by verifyPaymentChannelClaim. |
policy/max-spend.mjs |
The OWS executable spend-cap policy (runs at signing). |
src/*.test.ts, src/**/*.test.ts |
Unit tests (key-isolation guard, policy cap, swap math, discovery, funding). |
Flow: index.ts → buildAgentContext() (context.ts) → runAgentLoop (loop.ts) or
runAcquisition (pipeline.ts) → the tools/* functions → signer → OWS → XRPL. Reads use
xrpl.js over WebSocket; writes go through OWS.
- Write the domain logic as a plain function in
src/tools/<name>.ts. Take what you need (signer,network,log, …); for a write, build the tx and callsigner.signAndSubmit(tx, { label })so it is OWS-signed and policy-bounded — never sign any other way. - Expose it as a model tool in
src/loop.ts→buildTools()with the SDKtool()helper:Then addconst myTool = tool( 'my_action', // name the model calls 'One clear sentence on what it does and when to use it.', { someArg: z.string() }, // strict zod schema for the args async ({ someArg }) => { const result = await myAction(deps.signer, deps.network, someArg, deps.log) return ok(result) // ok() wraps JSON as a tool result }, )
myToolto the arraybuildTools()returns.allowedToolsis derived automatically (tools.map(t => mcp__${SERVER}__${t.name})), so there is nothing else to wire. - Tell the model about it if ordering matters: add a line to
SYSTEM_PROMPTinloop.ts. - Mirror it in the pipeline (
pipeline.ts) if you want the keyless/deterministic path to cover it too, and add a unit test undersrc/tools/.
Schema gotcha: keep zod schemas explicit and flat. The SDK strips unknown keys from a loose
z.any()object, so pass complex payloads as a JSON string arg (this is why the minimal tools taketxJson/paramsJson) and parse inside the handler.
Same tool() pattern, but in the tools array in src/minimal.ts. Minimal tools are
deliberately generic (xrpl_query, xrpl_sign_submit, faucet, http_get,
mpp_quote, mpp_settle) — the model composes them itself. Add a primitive only if it is a
genuinely new capability, not a domain shortcut (that belongs in rails).
pnpm sdk:vendor # build the xrpl-mpp-sdk tarball (it is not yet on npm)
pnpm install
cp .env.example .env # set ANTHROPIC_API_KEY (optional), OWS_PASSPHRASE, etc.
pnpm check:testnet # verify the XRP/RLUSD AMM route is reachable
pnpm demo # boot merchant + agent, acquire end-to-endpnpm demo boots the merchant, points the agent at it, and runs the autonomous
acquisition. With ANTHROPIC_API_KEY set, a Claude model drives the tool-use loop;
without one it runs the same tools through a deterministic pipeline (handy for CI).
The same goal, the same OWS-custodied wallet and policy, but two ends of the autonomy ⇄ reliability spectrum. Both keep the key in OWS and are bounded by the same OWS policy (XRPL-only, expiry, per-tx MAX_SPEND cap).
# against a running merchant (MERCHANT_URL defaults to http://localhost:8787):
pnpm agent # rails — high-level domain tools
pnpm agent:minimal # minimal — generic primitives, the model builds the txs itselfBoth read ANTHROPIC_API_KEY, OWS_PASSPHRASE, and MERCHANT_URL from .env. Without
ANTHROPIC_API_KEY, pnpm agent falls back to the deterministic pipeline.
-
Rails (
packages/agent/src/loop.ts) exposes 9 domain verbs (discover_issuances,quote_resource,opt_in_mpt,ensure_trustline,swap_for_currency,pay_via_mpp,confirm_receipt, …). The how of each on-chain action — tx construction, AMM quoting, reserve sizing, idempotency, wait-for-validation — lives in code. The model only orchestrates: it decides the sequence, wires the 402 data through, loops, and stops. -
Minimal (
packages/agent/src/minimal.ts) exposes only generic primitives —xrpl_query(read),xrpl_sign_submit(sign any tx via OWS),faucet,http_get,mpp_quote,mpp_settle. There is no bespoke opt-in/trustline/swap/discovery code: the model reads the ledger, builds the XRPL transactions itself (as JSON), works out the ordering, and self-corrects from errors. OWS is the only hard guardrail.
Two pieces have no generic/CLI equivalent and exist in both modes:
- The OWS signing bridge (
packages/agent/src/signer/) —NativeOwsSignerhands the autofilled tx to OWSsignAndSend(OWS injects the pubkey + broadcasts); channel mode'sOwsXrplSignerrecovers the pubkey, signs the tx hash viasignHash, and broadcasts the blob via xrpl.js. - The MPP credential glue (
Challenge.fromResponse+Credential.serialize) — the 402/credential envelope can't be reconstructed from raw HTTP.
| Rails | Minimal | |
|---|---|---|
| Tools exposed | 9 domain verbs | 6 generic primitives |
| Who builds the transactions | the code | the model (JSON) |
| Discovers the flow | recipe in the prompt | the model derives + adapts |
| Reliability | deterministic, first try | works, but stumbles then self-corrects |
| Tool calls for one purchase | ~11 | ~26 (more reads, retries, reasoning) |
| Guardrails | OWS policy (sole enforcer) | OWS policy (sole enforcer) |
| Observed | clean acquisition | self-fixed a book_offers query, acquired multiple issuances end-to-end, 0 OWS denials |
Both were validated live on testnet. The minimal agent genuinely "figures it out" — but it costs more model turns and demands very robust primitives (e.g. transactions are passed as JSON strings, and tools return clear errors the model can read and recover from). The rails agent trades that autonomy for determinism, lower cost, and testability. Pick by how much you trust the model to assemble protocol-correct transactions vs. how much you want pinned in code; OWS catches the dangerous (out-of-policy) either way — not the incorrect.
A different payment shape: instead of one on-chain payment per purchase, the agent locks
XRP in an XRPL Payment Channel once and then streams purchases as off-ledger
cumulative vouchers (claims) — pay-per-token micropayments, MPP channel intent.
It stays merchant-driven (the merchant proposes the channel in a 402) and XRP-only (payment channels carry XRP, so there is no RLUSD swap/trustline here):
- The merchant issues nothing up front;
/catalogcarries a hint to/subscribe. - The agent ventures to
/subscribe, gets a 402channeloffer, and opens a PayChannel (e.g. 50 XRP) — thePaymentChannelCreateis OWS-signed and sent as the MPPopencredential (the merchant submits it). - The merchant then starts issuing RWA MPTs. The agent opts in and pays each with a
cumulative
voucher(an OWS-signed claim) until it nears the channel capacity, then closes (tfClose). The merchant redeems the latest voucher (closeFromStore).
If the agent disconnects without closing, the merchant's SDK auto-close sweeper
claims the latest voucher on-chain once the channel has been idle for CHANNEL_IDLE_MS
(it scans every CHANNEL_SWEEP_MS) — so the merchant still collects what it earned. To see
it, kill the agent mid-run and watch for AUTO-CLOSE: idle channel claimed on-chain by merchant. CHANNEL_IDLE_MS must stay above the gap between vouchers (default 120s):
each round does an on-chain opt-in + delivery + validation, so a too-low idle window would
let the sweeper fire mid-stream and claim a stale, too-low cumulative while the agent is
still buying.
Settle delay & unspent funds. Closing is funder-initiated (tfClose) and not instant:
it starts a SettleDelay (24h here). During that window the merchant can still submit its
latest voucher to collect everything it earned; only after it does any unclaimed XRP
return to the agent — it's the agent's escrowed capital, and the merchant is only ever owed
what it holds a signed voucher for (handing it un-vouchered funds would be the unsafe
direction). Until someone closes, the channel stays open indefinitely and the merchant can
claim at any time. A signed voucher is also durable: it stays valid and re-submittable for
the whole open window, so a merchant with a persistent store can redeem it after a restart —
in this demo the merchant's voucher store is in-memory, so it does not survive a restart.
The key never leaves OWS: claims are signed via signHash (a PayChannel claim is a
secp256k1 signature over sha512half(encodeForSigningClaim({channel, amount}))), and the
channel public key is the recovered OWS key. Verification reuses the SDK's
xrpl-mpp-sdk/channel/server method; only the client signing is reproduced for OWS custody.
This is the one mode that uses OwsXrplSigner (the signHash/recovery signer): it needs
the public key as a value for the channel + claims, which OWS doesn't expose. Every other
flow uses the native signAndSend signer instead (buildAgentContext({ signerKind: 'channel' })
selects it).
# terminal 1 — channel-mode merchant (XRP pricing):
PAYMENT_CURRENCY=XRP RWA_PRICE=10 pnpm merchant:channel
# terminal 2 — channel-mode buyer (opens a CHANNEL_XRP channel, streams, closes):
CHANNEL_XRP=50 pnpm agent:channel
pnpm check:channel # isolated live check: OWS opens a channel + signs a verifiable voucherGuardrail note: the per-tx
MAX_SPENDpolicy gates irreversible XRP outflow (Payment/OfferCreate), not thePaymentChannelCreatedeposit — that is a recoverable lock, and the streamed spend is bounded by the channel capacity the operator sets (CHANNEL_XRP; a voucher above it is unredeemable). Vouchers are off-ledger claims (signed viasignHash), which the per-tx policy does not see — the channel capacity is their bound.
| Variable | Purpose |
|---|---|
NETWORK |
testnet (only) |
XRPL_RPC_URL / XRPL_HTTP_RPC_URL |
optional WS / HTTP RPC overrides |
MERCHANT_SEED |
operator-held; if empty the merchant generates + faucet-funds one |
MERCHANT_PORT |
merchant HTTP port |
MERCHANT_URL |
the seller endpoint the agent is given (its only merchant locator; default http://localhost:8787) |
RWA_PRICE, RWA_AVAILABLE_UNITS, RWA_ASSET_SCALE, RWA_METADATA |
RWA issuance + pricing |
MPP_SECRET_KEY |
mppx server secret (merchant) |
PAYMENT_CURRENCY |
what the merchant charges: RLUSD | XRP |
ANTHROPIC_API_KEY, AGENT_MODEL |
model loop (default model sonnet); omit key → deterministic pipeline |
MAX_SPEND, AGENT_MAX_ITERATIONS |
per-tx XRP cap (enforced by the OWS policy at signing) + loop bound |
OWS_WALLET_NAME, OWS_PASSPHRASE, OWS_VAULT_PATH |
OWS wallet name, owner passphrase, vault dir |
SWAP_SLIPPAGE_BPS |
swap slippage bound |
CHANNEL_XRP |
channel-mode buyer: XRP the agent locks in the PayChannel (default 50) |
CHANNEL_IDLE_MS, CHANNEL_SWEEP_MS |
channel-mode merchant: auto-close an idle channel after this long / scan interval (defaults 120000 / 10000). CHANNEL_IDLE_MS must exceed the gap between vouchers or a live run is swept mid-stream |
- Signer integration is the central task. Ordinary writes use OWS 1.4.2
signAndSend(OWS injects the pubkey + broadcasts). Channel mode still recovers the OWS public key (OWS does not expose it), signs the tx hash viasignHash, and broadcasts the blob ourselves — the only path that also yields the unbroadcast channelopenblob; the MPP leg uses push mode. The clean upstream fix is exposing the account public key (OWS 1.4.2 fixed token signing but not pubkey exposure) or an external-signer constructor on the SDKWallet(Wallet.fromSigner), which would let channel mode drop the recovery too. - RLUSD funding on testnet is not scriptable, so the agent self-funds in XRP and swaps to RLUSD on the existing testnet AMM (no operator liquidity setup).
- RLUSD identifiers — merchant vs agent. The merchant charges in RLUSD using the
SDK
RLUSD_TESTNETconstant (40-char hex currency + issuer), kept in sync — anRLUSD_TESTNET_ISSUERenv that disagrees only logs a warning and is ignored. The agent hardcodes nothing: it is currency-agnostic and learns the currency + issuer from each resource's 402 challenge (the SDK constant appears agent-side only in tests andcheck-testnet). - Account reserves are sized explicitly (base + owner per trust line / MPT + swap
- fees) before funding.
xrpl-mpp-sdkis not yet on npm. It is consumed via a singlepnpmoverride pointing at a locally built tarball; going live on npm is a one-line change.- Testnet only. No local/Docker sandbox: everything runs against XRPL testnet.
Atomic delivery-versus-payment (escrow/crypto-conditions); mainnet / real-value RLUSD; multi-agent competition; cumulative/rolling spend limits; a separate signer service.
This demo keeps the key out of the agent's reach (no tool can export it or sign with a
local key — see Why an autonomous agent cannot bypass OWS).
But OWS runs in-process (a native NAPI module, not a separate daemon), so the boundary
today is logical, not a separate process or hardware. The plaintext key materializes
transiently in the agent process's memory during signing, and the encrypted vault lives
wherever OWS_VAULT_PATH points. For a real deployment, harden in this order:
- Never bake the vault or secrets into an image. Mount the vault as a volume; pass
OWS_PASSPHRASE/ tokens via a secrets manager or runtime env, never a layer or VCS. - Run with the token, not the passphrase. Create the wallet once (the only step that
needs
OWS_PASSPHRASE), then run the agent with just the vault + the policy-bound API token (.data/agent.<network>.json). The passphrase never enters the run environment; the token is XRPL-only, expiring, and spend-capped. - Tighten the policy for the target. Per-recipient allowlists, a lower
MAX_SPEND, shorter token expiry, and—once OWS supports it—a cumulative/rolling limit instead of a per-transaction one. - Separate the signer from the agent (the real isolation). Move OWS into its own
service/container that owns the vault and exposes only a sign endpoint; the agent
container holds no vault and no key, and calls out to sign. Then a compromise of the
agent process cannot touch key material at all. This is what the upstream
Wallet.fromSigner(external-signer) gap unlocks — until it lands, signing is in-process. A two-service split (agent ↔ OWS signer) is the natural next step. - Defense in depth around the model. Keep
settingSources: []and the explicitallowedToolsallowlist so no extra tools leak in; keep theisolation.test.tsguard in CI so no local-key signing path can be introduced by accident.
In short: today the agent cannot obtain or misuse the key, but the key still shares the agent's process. Production isolation = a separate signer service + secrets management + a policy scoped to the deployment.
Apache-2.0. See LICENSE.