💸 Paid HTTP for Agents: Shipping an x402 RLUSD Service on XRPL Testnet with payment-skill + n-payment
🧰 payment-skill is a one-line install that drops a payment skill into any AI-host (Claude Code, Kiro, Cursor, Gemini CLI, …). It wraps the n-payment SDK, so every host gets the same 39 tools.
🛰️ With
n-payment@0.29.1(just published) you can stand up an RLUSD-priced paid endpoint on XRPL testnet in ~30 lines, paid by a singlefetchWithPayment(url)call from the buyer.🤝 Settlement runs through T54’s hosted x402 facilitator (
xrpl-facilitator-testnet.t54.ai) — no custodial wallet, no API keys, no private servers.✅ End-to-end proof: two on-chain
tesSUCCESSsettlements on XRPL testnet, validated, with the buyer’s RLUSD balance moving 4.96 → 4.94 RLUSD across two paid calls.📦 npm —
n-payment@0.29.1→ https://www.npmjs.com/package/n-payment
🏛️ The architecture in 5 lines
payment-skill— Node CLI + MCP/Skill bundle that auto-detects your AI host and writes the right skill/MCP file (~/.kiro/skills/n-payment/SKILL.md,~/.claude/skills/…, etc.).n-payment— Chain-agnostic agentic-payment SDK. OnefetchWithPayment(url)call detects HTTP 402 and signs the right payment for the right chain.createPaywall(...)— Express middleware factory insiden-paymentthat turns any route into a 402-gated endpoint.T54 XRPL x402 facilitator — Hosted service that does
verify+settlefor XRPL Payment blobs. The merchant never holds the buyer’s keys.XRPL testnet RLUSD — Issuer
rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV, currency 40-hex524C555344000000000000000000000000000000, 6 decimals.
🛠️ The merchant — 30 lines to a paid endpoint
import express from 'express';
import { createPaywall } from 'n-payment';
const app = express();
app.use(express.json());
app.use(
createPaywall({
routes: {
'GET /weather/forecast': {
price: '0.01', // 0.01 RLUSD
description: 'Hourly weather forecast',
xrpl: {
payTo: 'r…receiverClassicAddress',
network: 'xrpl:1', // testnet
asset: 'RLUSD',
// facilitatorUrl omitted → xrpl-facilitator-testnet.t54.ai
},
},
},
// v0.29 magic: provide a seed and the FIRST 402 auto-creates the
// receiver's RLUSD trustline (TrustSet) before the challenge fires.
xrpl: { seed: process.env.XRPL_MERCHANT_SEED },
}),
);
app.get('/weather/forecast', (_req, res) => res.json({ forecast: [] }));
app.listen(8765);What the middleware does on every request, in order:
📋 Match the route — bail through to
next()for unguarded paths.🔐 Trustline preflight —
ensureTrustline()(5-min TTL cache + per-key mutex) checksaccount_linesfor the receiver. Missing + signer set → submit one-timeTrustSet. Missing + no signer → fail fast 503XRPL_MERCHANT_NO_TRUSTLINE.🧾 Issue the challenge —
PAYMENT-REQUIREDheader (base64-JSON) carriespayTo,amount, asset 40-hex,extra.sourceTag = 804681468, freshinvoiceId.🔍 Verify the retry — when the buyer comes back with
PAYMENT-SIGNATURE, the merchant calls T54’s/verify, then/settle. Both pass →PAYMENT-RESPONSEheader + 200 with the protected body.
💳 The buyer — one line to spend
import { createPaymentClient } from 'n-payment';
const client = createPaymentClient({
chains: ['xrpl-testnet'],
ows: { wallet: 'demo-buyer' }, // OWS-managed key, optional
xrpl: { seed: process.env.XRPL_BUYER_SEED, autoSwap: true },
});
const res = await client.fetchWithPayment('http://merchant/weather/forecast');
console.log(await res.json());What happens behind that single call:
1️⃣ Plain GET → service responds 402 with the canonical
PAYMENT-REQUIREDenvelope.2️⃣ Adapter parses the challenge, ensures the buyer’s RLUSD trustline + balance (auto-swap from XRP via the testnet AMM if short — pool currently ~527 K XRP / 325 K RLUSD).
3️⃣ Builds a canonical XRPL
Payment—Amount = SendMax(same currency + issuer + value),SourceTag = 804681468,Memos[0].MemoData = HEX(UTF-8(invoiceId)),LastLedgerSequence = current + 20.4️⃣ Signs locally with the buyer’s seed, base64-encodes the envelope (now including
payload.invoiceIdper v0.29.1), retries withPAYMENT-SIGNATURE.5️⃣ Receives 200 +
PAYMENT-RESPONSEcarrying the on-chain tx hash, returns the resource body to your code.
🔗 The wire format (x402 v2 — T54 spec)
📨
PAYMENT-REQUIRED— server → client, base64-JSON challenge.✍️
PAYMENT-SIGNATURE— client → server, base64-JSON{ x402Version: 2, accepted, payload: { signedTxBlob, invoiceId } }.🧾
PAYMENT-RESPONSE— server → client, base64-JSON{ success, transaction, network, payer }.🪪 RLUSD asset on the wire is the 40-hex code
524C555344000000000000000000000000000000. The 5-char ASCII form is rejected byxrpl.jsv4 +rippled.🔁 Replay protection:
MemoData = HEX(UTF-8(invoiceId))+ boundedLastLedgerSequence ±20.🌐 CAIP-2 network IDs:
xrpl:0mainnet,xrpl:1testnet,xrpl:2devnet.
🐛 Three bugs we shipped fixed in n-payment@0.29.1
While running this end-to-end against live T54, three latent issues in n-payment@0.29.0 blocked every real RLUSD round-trip. They’re all gone in v0.29.1:
🅰️
RLUSD_CURRENCYwas'RLUSD'(5 ASCII chars). xrpl.js v4 + rippled refused it on every wire-boundCurrencyfield. Constant flipped to the canonical 40-hex code; new publicRLUSD_SYMBOLexported for human display.🅱️
PAYMENT-SIGNATURE.payloadlackedinvoiceId. T54’s reference Python client and verifier both read invoice id frompayload.invoiceId, not fromaccepted.extra.invoiceId. Type widened, buyer envelope updated.🅲️
buildXrplRlusdPaymentTxomittedSendMax. T54’s IOU policy requiresSendMax = same { currency, issuer, value }so the Payment is locked to a same-asset direct transfer. Now always emitted.
pnpm test 672/674 green (1 pre-existing morph test, 1 skipped). Full CHANGELOG entry →
🧪 Proof of work (XRPL testnet)
Two end-to-end runs, both validated on-chain:
🅰️ Run 1 — locally-packed v0.29.1 tarball
tx:
C911C3887A36CB2DDBBE12900C560411DB032B014426838D28419FADB1F71222engine:
tesSUCCESS,validated: truebuyer 4.96 → 4.95 RLUSD, recipient
rLDHyWBfv6H5WyAe1Tw1cwYAjhrz8bcgzW0 → 0.01 RLUSDT54
/verify431 ms,/settle4923 ms
🅱️ Run 2 — registry-installed
n-payment@0.29.1(final proof)tx:
4C225B36A32F5154F99DDD0CBC0A9C3CDE99BD55F2620D2D6A579865B51B1F48engine:
tesSUCCESS,validated: truebuyer 4.95 → 4.94 RLUSD, recipient
rKMg4SM4e9sRKfRRGQmrqdJph5K6AuDSPu0 → 0.01 RLUSDT54
/verify410 ms,/settle5006 ms
Buyer wallet (constant across both runs):
seed:
sEdTcYPTN1p8nCUF7WF24WGrYS1gMAc(ed25519 testnet seed; demo-only)classic address:
rsKXtyeXkRbENiDV1AdMG1Why6ihdPY1Phexplorer: https://testnet.xrpl.org/accounts/rsKXtyeXkRbENiDV1AdMG1Why6ihdPY1Ph
trustline tx:
D2A621C21EE399608BC26D05E53CA70CD7EE3603BA53E1EC8FEA304F606241DA
T54 verify response (Run 2, captured):
{
"isValid": true,
"invalidReason": null,
"payer": "rsKXtyeXkRbENiDV1AdMG1Why6ihdPY1Ph"
}T54 settle response (Run 2, captured):
{
"success": true,
"transaction": "4C225B36A32F5154F99DDD0CBC0A9C3CDE99BD55F2620D2D6A579865B51B1F48",
"network": "xrpl:1",
"payer": "rsKXtyeXkRbENiDV1AdMG1Why6ihdPY1Ph",
"errorReason": null
}PAYMENT-REQUIRED challenge body (decoded, Run 2):
{
"scheme": "exact",
"network": "xrpl:1",
"asset": "524C555344000000000000000000000000000000",
"payTo": "rKMg4SM4e9sRKfRRGQmrqdJph5K6AuDSPu",
"amount": "0.01",
"maxTimeoutSeconds": 600,
"extra": {
"sourceTag": 804681468,
"issuer": "rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV",
"invoiceId": "747f7f06-1995-4fa5-af0e-8f2d4d289a25"
}
}🚀 Repro it locally
📦 Install:
npx -y github:phamdat721101/payment-skillnpm i n-payment@0.29.1(peer)
🌧️ Drip XRPL testnet XRP at https://faucet.altnet.rippletest.net/accounts
🧰 Open the repo:
scripts/setup-rlusd-trustline.mjs— bootstrap an RLUSD trustline on a walletscripts/t54-direct-rlusd-demo.mjs— buyer ↔ T54 facilitator direct, no Expressscripts/rlusd-x402-service-demo.mjs— full Express paywall +fetchWithPaymentbuyer
⏱️ Each run is ~15-20 s end-to-end (XRPL ledger close + faucet + T54 round-trip).
📎 Links
📦 npm —
n-payment@0.29.1→ https://www.npmjs.com/package/n-payment📦 github —
payment-skill→ https://github.com/phamdat721101/payment-skill🛰️ T54 XRPL x402 facilitator → https://xrpl-x402.t54.ai/docs
📜 xrpl.org agentic payments x402 → https://xrpl.org/docs/agents/agentic-payments-x402
🪪 RLUSD on XRPL → https://docs.ripple.com/products/stablecoin/developer-resources/rlusd-on-the-xrpl

