--- name: rozo-checkout description: > Use when an agent needs to pay OpenRouter (or any Coinbase invoice) with the crypto it already holds: USDT/USDC on Solana, BNB Chain, Ethereum, Polygon or Arbitrum, USDC on Base or Stellar, BTC over Lightning, or a native coin (ETH on Ethereum, Base or Arbitrum, BNB, SOL, POL), with no Coinbase account. A bridge creates a one-time deposit order for that coin, then a funder wallet settles the Coinbase invoice. Triggers on a payments.coinbase.com/payment-links/pl_* (or payment-sessions/paymentSession_*) URL, on "rozo-checkout" / "pay this link with bitcoin", or when a user asks to top up OpenRouter credits with crypto and has no link yet (OpenRouter's crypto credits API returns 410 Gone). Also pays a Bitrefill invoice created with "USDC on Base": triggers on "pay bitrefill", "bitrefill invoice", or an invoice id plus a Base USDC address and amount from Bitrefill. Supports Coinbase payment links and Bitrefill invoices only (not Stripe). Also pays any x402 API (HTTP 402, PAYMENT-REQUIRED) from a prepaid Rozo balance: triggers on "x402", "402 Payment Required", "pay this x402 endpoint"; the payment leg is USDC on Base only (Solana payment leg is coming later). metadata: version: 1.2.0 # Declared capabilities — what this skill can actually do, so reviewers and # scanners do not have to reverse-engineer it from the bundle. permissions: network_endpoints: - apiserver.mpprouter.dev # quote the Coinbase link, create/track the order - intentapiv4.rozo.ai # bridge order status (payment-api) - intentapi.rozo.ai # wallet balance / payment-option assistance - "per-chain public RPCs" # Mode B only: broadcast the signed transfer - "apiserver.mpprouter.dev/v1/x402/*" # x402 payer: keys, topup, sign, balance - "the x402 URL the caller passes" # x402 pay: the paid request goes there directly environment_variables: read_only: - ROZO_CHECKOUT_EVM_KEY # Mode B (--send) only - ROZO_CHECKOUT_SOL_KEY # Mode B (--send) only - ROZO_CHECKOUT_EVM_KEYSTORE # Mode B (--send) only - ROZO_CHECKOUT_KEYSTORE_PASSPHRASE - "ROZO_CHECKOUT_RPC_" - ROZO_CHECKOUT_STATE_DIR - ROZO_CHECKOUT_MPP_BASE # endpoint overrides (testing/self-host) - ROZO_CHECKOUT_INTENTS_BASE - ROZO_CHECKOUT_INTENT_API - ROZO_CHECKOUT_UTM_SOURCE # optional channel label for reporting - ROZO_CHECKOUT_X402_KEY # x402 agent key (else ~/.rozo-checkout/x402-key) - ROZO_CHECKOUT_X402_BASE # x402 endpoint override (testing) filesystem: - "~/.rozo-checkout/ (state + prefs + optional .env; created by this tool)" - "~/.rozo-checkout/x402-key (x402 agent key, mode 0600, written once by x402 topup)" - ".env in the working directory or a --env-file path (ROZO_CHECKOUT_* keys only)" - "Mode B key files, read-only: ~/.config/solana/id.json, a --keyfile path, or the ROZO_CHECKOUT_EVM_KEYSTORE path" - ".git metadata read-only (index, and gitdir/commondir resolution for worktrees), to refuse git-tracked key files" spending: - "Mode B (--send) signs and broadcasts one ERC-20/SPL or native coin transfer, capped at $1,100," - "only after create-order --confirm recorded a digest-bound confirmation." - "x402 pay asks Rozo to sign one USDC payment from the caller's prepaid balance," - "capped by --max-usd (default $1) locally and by per-key limits at Rozo." subprocess: none default_mode_needs_no_credentials: true --- # Pay a Coinbase Payment Link with Stellar, Solana, BNB Chain, Bitcoin and more ## What this is A Coinbase Payment Link only accepts **USDC on Base**. This skill lets the caller pay one with something else: | You hold | Chain | |---|---| | USDT or USDC | Solana, BNB Chain, Ethereum, Polygon, Arbitrum | | USDC | Base, Stellar | | BTC | Lightning (BOLT11) — any wallet that pays an invoice works, including Cashu/ecash wallets that melt to Lightning | | ETH (native) | Ethereum, Base, Arbitrum | | BNB, SOL, POL (native) | BNB Chain, Solana, Polygon | Say exactly which one: "USDT **on** Solana" and "SOL" (the native coin) are different orders. On-chain BTC and Tron are **not** supported. ZEC on Zcash (Beta): send the user to https://checkout.rozo.ai/zec, this CLI does not create ZEC orders. **Native coins have extra rules.** Offer one only when the quote lists it for this invoice (`quote.js` reports `nativeOffered`; `create-order.js` refuses anything else with `NATIVE_NOT_OFFERED` before creating an order). Native orders are limited to $2,000 per invoice (`NATIVE_AMOUNT_TOO_HIGH`), and native ETH on Ethereum needs at least $1 (`NATIVE_AMOUNT_TOO_LOW`). A native order is **exact-out**, like Lightning: `deposit.amount` is the exact coin amount to send, and its price is locked only until `deposit.quoteExpiresAt` (about an hour; the order itself stays open longer, but a payment after the lock is valued at the arrival price and can fall short). Tell the user the deadline is the price lock, that the coin amount includes an FX spread on top of the service fee, that their wallet needs a little more of the same coin for gas, and that from an exchange the amount that arrives must be the full amount. Never send, and never tell anyone to send, after the lock. Native coins are not available for Bitrefill invoices (`NATIVE_NOT_AVAILABLE`). How it works: the router quotes the Coinbase link, creates a one-time bridge order with a deposit address for the coin you chose, and once your deposit lands, a funder wallet pays the Coinbase invoice. **There is no discount** — `callerPays` equals the invoice amount. If a response ever shows a discount, or `callerPays` differs from the invoice, stop and explain; do not proceed. ## Bitrefill invoice Supported targets are **Coinbase payment links** and **Bitrefill invoices**. Stripe checkout is not supported by this skill. 1. The user (or their agent, via Bitrefill's site or MCP) creates a Bitrefill invoice and chooses **USDC on Base** as the payment method. Bitrefill shows an invoice id, a Base receiving address (`0x…`), an exact USDC amount and an expiry. 2. Run, with the coin the user actually holds: ```bash npx @rozoai/checkout pay --bitrefill-invoice --to <0x…> --amount \ --expires-at --with usdc-stellar # or --from stellar-usdc, usdt-solana, … ``` There is no quote step: mpprouter creates a Rozo exactOut order delivering exactly that USDC amount to the Bitrefill address (the skill never writes to payment-api directly). The deposit you send is that amount plus bridge fees. 3. Rails, all hard aborts: the address must be a 0x EVM address, the amount a positive USDC decimal (≤ 6 dp), `--expires-at` is required (the invoice expiry, ≤ 30 min out); at least 5 minutes must remain to create an order and 2 minutes to show the deposit or send (the invoice expiry is the only clock used), and neither the Bitrefill address nor the deposit address may be on the compromised-address list. The router's response and the live order must echo the same invoice id, address and amount (`BITREFILL_ECHO_MISMATCH` otherwise) before any deposit is shown or any send happens. Re-running for the same invoice resumes its order (router `DUPLICATE_INVOICE`). 4. Same binding confirmation and `--send` rules as a Coinbase link. Status: `rozo-checkout status ` reports `settled` once the Rozo payout to the Bitrefill address completes (`payment_payout_completed`, or `payment_completed` with a payout tx hash); there is no Coinbase stage. Works without a local record (provider read from the order). Router errors to relay as-is: `BITREFILL_DISABLED`, `INVALID_ADDRESS`, `INVALID_AMOUNT`, `AMOUNT_OUT_OF_RANGE`, `INVOICE_EXPIRING`, `BLOCKED_ADDRESS`, `UNSUPPORTED_SOURCE`, `INVALID_INPUT` (400), and `BITREFILL_NOT_CONFIGURED` / `RETRY_LATER` (503: nothing was created, retry later). ## OpenRouter top-up — when there is no link yet OpenRouter has no public API for creating crypto credit checkouts (the old `POST /api/v1/credits/coinbase` returns HTTP 410 Gone). The link can only be created inside the user's own logged-in OpenRouter session. Two ways to get it: ### Default — the user creates it (works everywhere) Give these steps and wait for the URL: ``` 1. Open https://openrouter.ai/settings/credits 2. Enable "Use crypto". 3. Enter the credit amount and click "Purchase". 4. Copy the resulting payments.coinbase.com/payment-sessions/paymentSession_* URL and paste it here. ``` Then continue with the normal flow at Step 1 (quote). ### Accelerator — only when browser control already exists If, and **only** if, this session *already* has a working browser-control capability **and** that browser is *already* signed in to OpenRouter, you may offer to click through yourself. **Never tell the user to install a browser, an extension, or a plugin to unlock this** — when the capability is absent, give the default steps immediately and say nothing about automation. Ask first, in words that separate invoice creation from payment: > I can open your logged-in OpenRouter Credits page and generate the $N > invoice there — that creates an unpaid checkout and moves no money. > Continue? On yes: navigate to `https://openrouter.ai/settings/credits`, enable `Use crypto`, enter the amount, click `Purchase`, and capture the resulting `paymentSession_*` URL. Targeted actions only — **no screenshots and no full-page DOM dumps**. On a CAPTCHA, a login wall, or an unexpected page layout, stop the automation immediately and fall back to the default steps; do not wait for the user to log in and then resume driving the browser — the accelerator only ever operates on a session that was authenticated before it started. ### Verify before offering payment Whichever path produced the URL, run Step 1 (quote) on it and fail closed unless **all** of these hold: the merchant is exactly `OpenRouter, Inc`; the status is payable; the invoice total is consistent with the requested credit amount **after OpenRouter's ~5% crypto fee** — accept when `credit ≤ invoice ≤ credit × 1.10` (e.g. a $5.00 credit yields a $5.25 invoice; $1.00 yields $1.05), and stop and show both numbers when the invoice is below the credit or more than 10% above it; and the checkout is unexpired. Do not enforce a minimum amount yourself — a $1.00 credit checkout has been created successfully in practice. Pass the user's amount through and surface OpenRouter's own validation error if one appears. **Consent to create the invoice is never consent to pay it.** Payment continues at Step 2 of the normal flow with its own confirmation thresholds, unchanged. ## Runtime Run every command from the repository root (the directory containing `SKILL.md`, `scripts/`, `package.json`). If the harness sets `${CLAUDE_PLUGIN_ROOT}`, use that. ```bash cd "$SKILL_ROOT" node scripts/dist/quote.js --url "" ``` Each script prints **one JSON object on stdout**. Exit codes: | Code | Meaning | |---|---| | `0` | success | | `1` | refused or failed — read `error.code` | | `2` | usage error | | `3` | submitted but not confirmed in the wait window (money may be in flight) | `success` keeps its historical meaning: the script produced a clean answer. It is **not** "the invoice is paid". `status.js` and `receipt.js` also return `schemaVersion`, `paymentOutcome` and `nextAction`; read those. `receipt.js` uses its own exit codes, listed in Step 6. The bundles in `scripts/dist/` are self-contained; `npm install` is only needed to rebuild them (`npm run build`). ## Environment variables **Mode A — the default path — needs no key, no environment variable and no configuration at all.** The variables below exist only for Mode B (`--send`), where this machine signs on the user's behalf. Never ask a user to set one unless they have explicitly asked for Mode B. The recommended way to have an agent send the deposit is not to configure hot-wallet keys here. In order of preference: first, the default keyless path — the user pays from their own wallet. Second, for Stellar sources, the separate `stellar-agent-wallet` skill (ClawHub: `shawnmuggle/stellar-agentic-wallet`) pays the deposit address and memo via its `send-raw` command, with its own file-based key handling and confirmation prompts — no `ROZO_CHECKOUT_*` variable is involved. Only for unattended EVM/Solana automation does Mode B apply, and then with a dedicated hot wallet holding a low balance. Mode B takes its signing key from the first of these that exists: `--keyfile `; then `~/.config/solana/id.json` for Solana or `ROZO_CHECKOUT_EVM_KEYSTORE` for EVM; then a raw key in the environment. The environment variables below may also be set in a `.env` — either in the working directory or at `~/.rozo-checkout/.env`, whichever is found first, or an explicit `--env-file `. Only `ROZO_CHECKOUT_*` keys are read from it, and the real environment wins over the file. Note that the working directory is the skill's own directory whenever you follow the run commands above, so `~/.rozo-checkout/.env` is the right place for a user's own settings. A generic `~/.env` is deliberately NOT read: it belongs to the user and usually holds unrelated credentials. If theirs lives elsewhere, pass `--env-file ` rather than asking them to move it. | Variable | Used by | Notes | |---|---|---| | `ROZO_CHECKOUT_EVM_KEYSTORE` | `send-evm.js` | **Mode B only.** Path to an encrypted V3 JSON keystore. Preferred over a raw key. | | `ROZO_CHECKOUT_KEYSTORE_PASSPHRASE` | `send-evm.js` | **Mode B only.** Keystore passphrase for unattended runs. On a terminal it is prompted instead. | | `ROZO_CHECKOUT_EVM_KEY` | `send-evm.js` | **Mode B only.** Raw 32-byte hex private key; the `0x` prefix is optional. For unattended automation. | | `ROZO_CHECKOUT_SOL_KEY` | `send-sol.js` | **Mode B only.** Raw base58 secret key or JSON byte array. For unattended automation; `~/.config/solana/id.json` is used first when present. | | `ROZO_CHECKOUT_RPC_` | send scripts | optional RPC override, e.g. `ROZO_CHECKOUT_RPC_8453` | | `ROZO_CHECKOUT_STATE_DIR` | all | optional; defaults to `$HOME/.rozo-checkout/state` | | `ROZO_CHECKOUT_ANON_ID` | create scripts | optional; `off` stops sending the anonymous `install_id` and `account_hash` (see below) | | `OPENROUTER_API_KEY` | create scripts | **read, never sent.** If already exported, only `sha256("rozo-acct-v1:" + key)` goes out as `attribution.account_hash`. Never ask a user to set it for this. | **Anonymous attribution.** Every order carries `attribution.install_id`, a random UUID v4 created on first run and stored in `~/.rozo-checkout/prefs.json` (0600), so repeat orders from one install count as one payer. When `OPENROUTER_API_KEY` is in the environment, `attribution.account_hash` (lowercase hex sha256 of `"rozo-acct-v1:"` + the key) is added; the raw key is never sent, logged or stored, and an OpenRouter account id is never hashed (too guessable). Neither field identifies the user or grants anything. To reset the id, delete `~/.rozo-checkout/prefs.json`; to opt out, set `ROZO_CHECKOUT_ANON_ID=off`. Keys are read from the environment **only**. Never pass a key as a command-line argument, never print one, never write one into a file, a prompt or a commit message. If the shell does not already export the key, ask the user to export it themselves — do not go hunting through `.env` files. The send scripts refuse to run if a `.env` in the working directory is tracked by git. ## Confirmation thresholds Read the USD amount from the invoice (`invoice.amount`). | Amount | Confirmation | Narration | |---|---|---| | `≤ $1.00` | none — proceed | silent, report the result | | `≤ $10.00` | none — proceed | one narrating line | | `> $10.00` | **one explicit yes/no** | full summary, wait for "yes" | The binding confirmation happens at **step 4 (final confirm)**, never before — the pre-create preview cannot bind because the deposit address, the exact deposit amount and the order expiry do not exist yet. This is **enforced in code, not just documented**. `create-order.js` withholds the full deposit address, memo and BOLT11 until it is re-run with `--confirm`, and `--confirm` writes a confirmation record bound to a digest of those exact instructions. The send scripts refuse to run without both `--send` and a confirmation record whose digest still matches the live deposit data. ## The flow ### Step 1 — quote (read-only, costs nothing) ```bash node scripts/dist/quote.js --url "https://payments.coinbase.com/payment-links/pl_01..." ``` Reports merchant, invoice amount, `callerPays`, the Coinbase expiry and the supported source list. Exit 1 with `LINK_NO_LONGER_PAYABLE` means the link is used or expired — ask the merchant for a fresh one and stop. The `quoteReceipt` in the output lives about **60 seconds**. Do not save it; `create-order.js` takes its own fresh quote. ### Step 2 — choose the source and preview Ask the caller which coin and chain they want to pay with, or use what they already told you. Narrate the plan: invoice amount, merchant, chosen chain/token, `callerPays` (equal to the invoice — no discount). **This is not the binding confirmation.** **Optional contact email.** If a user is present, you MAY ask once, in the same message: "Optional: an email so ROZO can reach you if the payment needs attention?" Pass it as `--email ` in Step 3. It is never required. If the user declines, ignores the question, or no human is present (an unattended agent run), proceed without it and do not ask again. Never invent an address and never reuse one the user did not give for this purpose. An invalid address fails with `INVALID_EMAIL` before any order exists; fix it or drop the flag. ### Step 3 — create the order ```bash node scripts/dist/create-order.js --url "" --chain 900 --token USDT # optional contact email (Step 2): node scripts/dist/create-order.js --url "" --chain 900 --token USDT --email you@example.com ``` `--email` works the same on `create-bitrefill-order.js` and on `rozo-checkout pay`. The output reports `contactEmailProvided` (the address is never echoed) and a `support` block with ROZO's contact channels. An order that already existed for this link keeps whatever email it was created with, so `contactEmailProvided` is `false` for a reused order even when you passed `--email`. (On the standalone `--confirm` run the order is always reused, so read this field from the first run; `rozo-checkout pay` does that for you.) Chain ids: `1` Ethereum · `56` BNB Chain · `137` Polygon · `8453` Base · `42161` Arbitrum · `900` Solana · `1500` Stellar · `lightning` Bitcoin Lightning. Native coins use the same ids with `--token ETH` (1, 8453, 42161), `BNB` (56), `POL` (137) or `SOL` (900); decimals are 18 for ETH, BNB and POL and 9 for SOL. Creating an order moves no money and costs nothing if left unfunded. In one run it creates the order, verifies it against the quote, fetches the authoritative deposit instructions, runs the reuse guard, validates that the deposit instructions are complete, checks the expiry margins, checks the deposit address against the compromised-address list, and re-checks that the Coinbase link is still payable. **Without `--confirm` the full deposit address, memo and BOLT11 are withheld** (`deposit: null`, `depositWithheld: true`). You get everything you need to ask the user — masked address, exact amount, both expiries, the reused flag — and nothing that could be paid by accident. Any non-zero exit here means **do not fund the order**. See Troubleshooting. ### Step 4 — final confirm (binding) Present, from the `display` and `expiry` blocks of the run above: ``` About to pay: Merchant: {merchant} Invoice: {invoice.amount} USD (you pay the full amount, no discount) Send: {display.amount} on {display.chain} To: {display.payToMasked} <- masked, always Order ends: {expiry.effectiveDeadlineIso} ({expiry.minutesOfSlack} min of slack) Reused existing order: {reused} The deposit amount can exceed the invoice: it includes the bridge and network fees. Wrong token, wrong network or wrong amount is usually unrecoverable. Send exactly once — a second send to this one-time address is not guaranteed to be credited. Confirm? (yes/no) ``` Only after an explicit yes (per the threshold table), re-run the **exact same command with `--confirm`**: ```bash node scripts/dist/create-order.js --url "" --chain 900 --token USDT --confirm ``` That reuses the same order, re-runs every check, releases the full `deposit` block, and records the confirmation. Use the masked address in prose. The **full** address, memo and BOLT11 string live only in the machine-readable `deposit` object — hand that block over verbatim when the user needs to copy it, and never re-type an address by hand. Then pick a mode. > **"Mode A" and "Mode B" are internal vocabulary. Never say them to the > user.** They are how this document names a branch you take; they carry no > meaning for someone who just wants an invoice paid. Say who sends the money > and what the user has to do — nothing else about the mechanism. > > | Do not say | Say | > |---|---| > | "Stellar is Mode A only, no `--send`" | "I can't send Stellar for you — here's the address and memo to pay from your wallet" (or: "I'll pay it with your `stellar-agent-wallet`") | > | "Mode B needs `ROZO_CHECKOUT_EVM_KEY`" | "To have me pay it directly I'd need a hot-wallet key configured; otherwise pay from your own wallet and I'll watch for it" | > | "`CAP_PER_TX`: over the Mode B limit" | "That's over the $1,100 I'll sign for automatically — pay this one from your own wallet, which has no limit" | > > Whichever branch you take, the user should end up reading the same four > things: the amount, the destination, the memo if there is one, and what they > personally need to do next. **Mode A (default) — the user pays from their own wallet. No key, no env var, no setup.** Give them the `deposit` block. For Lightning, `deposit.lnInvoice` is the BOLT11 string to scan or paste, and `deposit.amount` is in **satoshis** (`deposit.isSats` is true) — never call it "X BTC". **Mode B (`--send`, optional) — this machine pays from a hot wallet.** Only when the user has asked for it. Only EVM chains and Solana — there is no `--send` for Stellar or Lightning; those are Mode A only. Only after the confirmation above. This is the only path that needs a key. On Solana it uses the `~/.config/solana/id.json` that `solana-keygen` already wrote; on EVM an encrypted V3 keystore whose passphrase is prompted (never a flag). Either can be named with `--keyfile`. A raw key in the environment still works for unattended automation: ```bash # EVM (Ethereum, BNB Chain, Polygon, Base, Arbitrum; tokens and native ETH/BNB/POL) ROZO_CHECKOUT_EVM_KEY=... node scripts/dist/send-evm.js --rozo-payment-id --send # Solana (USDC/USDT and native SOL) ROZO_CHECKOUT_SOL_KEY=... node scripts/dist/send-sol.js --rozo-payment-id --send # --dry-run shows exactly what would be signed and signs nothing (no --send needed) ``` `--send` is mandatory; without it the script exits with `SEND_NOT_OPTED_IN` and does nothing. Mode B re-runs every check live before signing, re-proves payability as the last step before broadcast, verifies the RPC really is on the intended chain, verifies the token's on-chain decimals, signs before broadcasting so the transaction hash is known in advance, and records the send locally **before** broadcasting so the same order can never be paid twice. A native coin is sent as a plain transfer of the exact amount (EVM: `value` with no calldata; Solana: a System Program transfer to a plain wallet). The balance check covers the amount plus the network fee in the same coin, and the price lock is re-checked as the last gate: past it the send stops with `QUOTE_EXPIRED`. The lock the user confirmed binds even if the backend later reports a later one. One limit: a single payment may not exceed **$1,100**. There is no override flag. A larger invoice is paid via Mode A, which needs no key and has no limit. ### Step 5 — poll ```bash node scripts/dist/status.js --rozo-payment-id --watch --timeout 600 ``` Without `--timeout`, `--watch` polls for 600 s, or for a Lightning order as long as the invoice can still be paid (at most 60 minutes). An explicit `--timeout` always wins. Always pass `--rozo-payment-id` when you have it. A link-only query cannot reach the authoritative pay-in view (the router does not resolve the id from a link alone), so the money-detected rule cannot be enforced; the script says so via `authoritativeView: false` and exits non-zero rather than guessing. States: `awaiting_deposit` → `payin_detected` → `payin_confirmed` → `bridging` → `paying_coinbase` → `settled`. Other terminal or exceptional states: `expired_unfunded`, `underpaid`, `stuck_after_payment`, and `unknown` (the backend could not be read — **not** evidence that nothing was paid). `expired_unfunded` on a Coinbase link does not spend the link. First check the user's wallet: if anything was sent to the old deposit, or a Lightning payment is still pending, do not pay again; contact support with `linkId` and `rozoPaymentId` (the router cannot see a pending Lightning payment). If nothing was sent, re-run the same `pay` on the same link with the same `--with`: the router creates a new order for the link once it has proved every earlier order unfunded. Then follow the error code, if any: - `PAYMENT_EXPIRED` with `retryable: true` (`confirmed: false`): the old order cannot be verified yet; wait a few minutes and retry. - `PAYMENT_EXPIRED` with `confirmed: true`, or `LINK_USED_OR_EXPIRED`: the link cannot be paid any more; ask the merchant (OpenRouter) for a new link. - `ORDER_ALREADY_ACTIVE`: a payment was found on an earlier order; do not pay again, contact support. The extra fields are in `error.details.body`. `settled` is only reported on real settlement evidence. The bridge reaching `payment_completed` is not that evidence and shows as `paying_coinbase`. Alongside `state`, status returns `paymentOutcome` (one of `awaiting_payment`, `processing`, `settled`, `expired_unfunded`, `needs_attention`, `unknown`) and `nextAction` (`type` plus `canRetryQuery`, `canCreateOrder`, `canSend`). Only `nextAction.canSend: true` permits sending money. It is never true once money is detected, a send is recorded on this machine (or its record cannot be read), or the pay-in view is partial. It also requires `sendWindow.ok`: the same deadline-plus-margin and "link still payable" checks the send scripts run. When it is false, `nextAction.reason` carries the gate's code (`EXPIRY_MARGIN`, `LINK_NO_LONGER_PAYABLE`, ...). ### Step 6 — report ```bash node scripts/dist/receipt.js --rozo-payment-id # or: npx @rozoai/checkout receipt --json ``` One read, nothing created or sent. It answers three questions separately: | Layer | `confirmed` means | |---|---| | `sourcePayment` | Rozo saw the pay-in confirmed. A wallet's "sent" is shown as `localSend` and is not proof Rozo received it. | | `merchantSettlement` | Coinbase itself reports the invoice paid: v3 `PAYMENT_SESSION_STATUS_CAPTURE_SUCCEEDED`, v1 `usageCount >= maxUsage`. The router reporting `paid` alone stays `pending`. For Bitrefill, the USDC payout landed. | | `serviceDelivery` | Always `unknown`. Rozo cannot see OpenRouter credits or Bitrefill goods; say "invoice paid, credits not independently verified". | Exit codes: `0` settled · `3` still in flight or unknown · `1` expired unpaid or needs a human · `2` usage. **Resuming an unpaid order.** If the user created an order and did not pay (closed the session, lost the address), do NOT create a new one. Run: ```bash node scripts/dist/resume.js # masked summary + pay page node scripts/dist/resume.js --confirm # full deposit block, after the user's yes # or: npx @rozoai/checkout resume --json [--yes] ``` Read `resumable` and `outcome`. `awaiting_deposit` re-shows the order (same withholding and binding-confirmation rule as Step 4). `paid` / `paid_in_progress` (exit 0) means do not pay again; `expired` (exit 1) means follow `error.message`; `sent_locally` / `needs_attention` / `unknown` mean do not pay. resume only reads `invoice-status` and `payments/`. Report only after `receipt` exits 0: ``` ✓ Paid {invoice.amount} USD to {merchant} with {token} on {chain}. linkId: {linkId} rozoPaymentId: {rozoPaymentId} pay-in tx: {payin.txHash} ``` ## The money-detected rule **Once any pay-in exists** (`payin.txHash`, `payin.confirmedAt` or a non-zero `amountReceived`), for the rest of the conversation: - never describe the order as a plain failure - never advise paying again, topping up, or "trying a different chain" - never create a new order for the same Coinbase link - preserve `linkId`, `rozoPaymentId` and every tx hash in your reply If the state is `stuck_after_payment` or `underpaid`, escalate for manual reconciliation with this wording: > Your payment arrived on chain but the invoice has not been settled. I am not > going to retry, because retrying could take a second payment. I have kept > every identifier below and this needs a human to reconcile it. > > linkId: `{linkId}` · rozoPaymentId: `{rozoPaymentId}` · pay-in tx: > `{payin.txHash}` · state: `{state}` Then stop and hand off. Do not run any send script again. ## x402: pay any x402 API An x402 endpoint answers `402 Payment Required` with a list of payment requirements (`accepts`). This skill pays it from a **prepaid Rozo balance**, so the agent can fund once with whatever coin it holds and then pay many small x402 calls without a wallet of its own. ```bash # 1. Fund the balance once, with any supported coin (prints a one-time deposit address) npx @rozoai/checkout x402 topup 20 --with usdt-solana # 2. Call a paid endpoint: reads the 402, Rozo signs, the request is replayed with PAYMENT-SIGNATURE npx @rozoai/checkout x402 pay https://api.example.com/v1/search --method POST --body '{"q":"..."}' # 3. Check what is left npx @rozoai/checkout x402 balance ``` Coverage, kept separate on purpose: | Leg | What works | |---|---| | Topup (funding the balance) | USDT on Solana, BNB Chain, Ethereum, Polygon, Arbitrum; USDC on Solana, BNB Chain, Ethereum, Polygon, Arbitrum, Base, Stellar. USDC and USDT only; any other coin gets `X402_TOPUP_SOURCE_UNSUPPORTED`. Native coins and sats pay OpenRouter through `pay` (ROZO Checkout) instead | | Payment (what the seller receives) | USDC on Base (`eip155:8453`), x402 scheme `exact` only. No native coins, no USDT on this leg. Solana payment leg is coming later: an endpoint that only offers Solana USDC returns `X402_UNSUPPORTED` | How `x402 pay` works, and what it guarantees: 1. It sends your request from **this machine** straight to the endpoint. Rozo never sees the request body, your headers, or the API keys you send to that service; it only sees the one `accepts` entry it is asked to sign. 2. On a 402 it parses the challenge (x402 v2 `PAYMENT-REQUIRED` header, or a v1 JSON body), keeps only exact USDC on Base, refuses anything above `--max-usd` (default 1.00) and any `payTo` on the compromised-wallet list. 3. It generates one `idempotencyKey` (UUID) and calls `POST /v1/x402/sign`. Transient failures are retried with the **same** key, so a timeout after Rozo already debited the balance returns the same signature instead of charging twice. The key is printed; pass it back with `--idempotency-key` if a run is interrupted. 4. It replays the original request with `PAYMENT-SIGNATURE` (v1: `X-PAYMENT`) and prints the response body on stdout; the payment summary and the settlement tx go to stderr (or all of it in `--json`). Agent rules: - First run `x402 topup` creates an agent key (`ak_…`), stores it in `~/.rozo-checkout/x402-key` (mode 0600) and never prints it in full. That key owns the balance; do not paste it anywhere. `ROZO_CHECKOUT_X402_KEY` overrides the file. - Rozo enforces per-key limits (per payment and per day). Set `--max-usd` to the most the user agreed to pay for this one call. - `X402_PAYER_DISABLED` ("x402 payer not enabled") means the payer is off for this key right now. Nothing was charged; tell the user and stop. - `X402_PAYMENT_REJECTED` or a non-2xx after paying: do not retry with a new key. Run `x402 balance` and report the `idempotencyKey`. - `X402_UNSUPPORTED`: the endpoint wants something other than USDC on Base (Solana payment leg is coming later). Say so; do not try to bridge per call. - Withdrawing a balance back to a wallet is not self-serve yet: email hi@rozo.ai with the masked key and the amount. The codes above (`X402_UNSUPPORTED`, `X402_PAYMENT_REJECTED`, plus `X402_OVER_BUDGET`, `X402_BAD_CHALLENGE` and similar) are raised by the CLI itself. The CLI reports every server 503 except `X402_RETRY`, `X402_PAYER_MODE_CHANGED` and `X402_LEDGER_UNAVAILABLE` as `X402_PAYER_DISABLED`, with the server code in `error.details.serverCode`; on `/sign` it retries 429, 500, 502, 504 and those three 503 codes itself, with the same `idempotencyKey`. ### Server error codes What `https://apiserver.mpprouter.dev/v1/x402/*` itself returns, for agents calling the HTTP API directly or reading `serverCode`. Every error body looks like `{"ok":false,"code":"X402_...","error":{"code":"X402_...","message":"..."}}`, sometimes with extra top level fields such as `limitUsd` or `balanceUsd`. The balance is debited only by the ledger commit inside `/sign`, after signing; every error raised before that commit charges nothing. If `/sign` answers `X402_LEDGER_UNAVAILABLE` or `X402_INTERNAL` the commit may have landed: retry with the same `idempotencyKey`, and a payment that did land comes back as the stored signature (`"replay": true`), never as a second charge. On `/topup` no deposit address is shown on any error, so only pay an address from a 200 response. | Endpoint | HTTP | Code | Meaning | Charged? | What to do | | --- | --- | --- | --- | --- | --- | | `/balance`, `/topup`, `/sign` | 401 | `X402_KEY_INVALID` | Agent key missing, malformed or unknown. | No | Send `Authorization: Bearer ak_...`. Do not mint a new key to get around it: the balance belongs to the old key. | | `/balance`, `/topup`, `/sign` | 403 | `X402_KEY_SUSPENDED` | The key is not active. | No | Stop. Email hi@rozo.ai. | | any | 503 | `X402_PAYER_DISABLED` | The payer is switched off. | No | Stop and tell the user. | | any | 503 | `X402_PAYER_NOT_CONFIGURED` | The payer is not set up on this deployment. | No | Stop. | | any | 503 | `X402_LEDGER_UNAVAILABLE` | The balance ledger is unreachable. | No. On `/sign` the outcome can be unknown, see below the table. | Retry shortly with the same `idempotencyKey`. | | any | 500 | `X402_INTERNAL` | Unexpected server error. | No. On `/sign` the outcome can be unknown, see below the table. | Retry with the same `idempotencyKey` a few times, then stop and report it. | | any | 405 | `METHOD_NOT_ALLOWED` | Wrong HTTP method. | No | `keys`, `topup` and `sign` are POST; `balance` is GET. | | `/keys` | 429 | `X402_KEY_RATE_LIMITED` | Too many keys created from this IP this hour. | No | Reuse the key you have, or retry later. | | `/keys` | 503 | `X402_RETRY` | Key collision. | No | Retry. | | `/topup` | 400 | `X402_INVALID_REQUEST` | Body is not JSON, or `amount` is not a USD amount with at most 2 decimals. | No | Fix the request. | | `/topup` | 400 | `X402_TOPUP_AMOUNT_OUT_OF_RANGE` | A top up must be between $5 and $500. | No | Pick an amount in range. | | `/topup` | 400 | `X402_TOPUP_SOURCE_REQUIRED` | `chain` and `token` are missing. The response lists accepted chains. | No | Say which coin you pay with. | | `/topup` | 400 | `X402_UNSUPPORTED_CHAIN` | Unknown chain. | No | Use a CAIP-2 id or a name such as `solana`, `base`, `ethereum`, `bsc`, `polygon`, `arbitrum`, `stellar`. | | `/topup` | 400 | `X402_TOPUP_SOURCE_UNSUPPORTED` | That coin is not accepted for top ups. May carry a `supported` list. | No | Use USDC or USDT. | | `/topup` | 502 | `INTENTS_API_FAILED` | The top up order could not be created. | No, no address was shown | Retry later. | | `/topup` | 503 | `X402_TOPUP_MISCONFIGURED`, `X402_TOPUP_NOT_REGISTERED` | The order was not usable, so the deposit address was withheld. | No, no address was shown | Retry later; email hi@rozo.ai if it repeats. | | `/topup` | 503 | `X402_PAYER_SHADOW`, `X402_TOPUP_NOT_CONFIGURED`, `X402_SIGNER_NOT_CONFIGURED` | Top ups are closed right now. | No | Stop. | | `/sign` | 400 | `X402_INVALID_REQUEST` | Body is not JSON, or `budget` is not a positive USD amount. | No | Fix the request. | | `/sign` | 400 | `X402_IDEMPOTENCY_KEY_REQUIRED` | `idempotencyKey` missing or invalid: 8 to 128 chars of `[A-Za-z0-9_.:-]`. | No | Send one key per 402 challenge. | | `/sign` | 400 | `X402_UNSUPPORTED_VERSION` | Only `x402Version` 2 is accepted. | No | Send the v2 requirement. | | `/sign` | 400 | `X402_UNSUPPORTED_NETWORK`, `X402_UNSUPPORTED_SCHEME`, `X402_UNSUPPORTED_ASSET`, `X402_INVALID_REQUIREMENT`, `X402_SIGN_UNSUPPORTED` | The `accepts` entry is malformed or asks for something not covered (only `exact` USDC is signed). | No | Do not retry the same entry. Tell the user. | | `/sign` | 402 | `X402_BUDGET_EXCEEDED` | The challenge asks more than your `budget`. | No | Stop and ask the user. | | `/sign` | 402 | `X402_PER_TX_LIMIT_EXCEEDED` | Above this key's single-payment limit (`limitUsd`). | No | Stop and ask the user. | | `/sign` | 402 | `X402_INSUFFICIENT_BALANCE` | Balance too low (`balanceUsd`). | No | Top up, wait for the credit, then pay again. | | `/sign` | 403 | `X402_PAYTO_NOT_ALLOWED` | `payTo` is not on this key's allowlist. | No | Stop. | | `/sign` | 403 | `X402_PAYTO_BLOCKED` | This `payTo` is blocked. | No | Stop. Do not retry. | | `/sign` | 409 | `X402_IDEMPOTENCY_CONFLICT` | This `idempotencyKey` was already used for a different requirement. | No new charge | Use a new key only for a new 402 challenge. | | `/sign` | 410 | `X402_CREDENTIAL_REFUNDED` | The stored signature expired unused and its amount went back to the balance. | Refunded | Fetch a new 402 and sign it with a new key. | | `/sign` | 429 | `X402_DAILY_LIMIT_EXCEEDED` | This key's daily limit would be exceeded (`limitUsd`, `spentTodayUsd`). | No | Wait for the daily window to reset, or ask hi@rozo.ai for a higher limit. | | `/sign` | 429 | `X402_GLOBAL_DAILY_CAP_REACHED`, `X402_SIGNER_DAILY_CAP_REACHED` | A platform-wide daily cap was reached. | No | Retry after 00:00 UTC. | | `/sign` | 429 | `X402_SIGNER_CAP_REACHED` | The signing service refused this amount. | No | Try a smaller payment or later. | | `/sign` | 503 | `X402_RETRY`, `X402_PAYER_MODE_CHANGED` | Transient. | No | Retry with the same `idempotencyKey`. | | `/sign` | 503 | `X402_PAYER_SHADOW` | The payer is not live for this key. A fresh request is checked and recorded but not signed. With `"replay": true` it refers to an earlier payment under this `idempotencyKey`. | No for a fresh request. A replay may refer to an earlier charged payment. | Stop. On a replay check `/balance` and never pay again with a new key. | | `/sign` | 503 | `X402_SIGNER_NOT_CONFIGURED`, `X402_SIGNER_DISABLED` | Signing for this network is off right now. | No | Stop. | A 200 with `"replay": true` is the stored signature for an `idempotencyKey` that was already paid. It is not a second charge. Raw HTTP, for agents without a shell (Bearer = the agent key): `POST https://apiserver.mpprouter.dev/v1/x402/topup {amount, token, chain}` and `POST https://apiserver.mpprouter.dev/v1/x402/sign {accepts, budget, idempotencyKey}` → the `PAYMENT-SIGNATURE` value. Keys: `POST /v1/x402/keys`; balance: `GET /v1/x402/balance`. ## Getting help If something goes wrong (an order is stuck, underpaid, expired after funds arrived, or the state is `unknown`), tell the user how to reach ROZO and give them the identifiers above: - Email: hi@rozo.ai - X: https://x.com/ROZOai - Discord: https://discord.gg/EfWejgTbuU The same channels appear in the `support` block of `create-order` output and of any `status` result that is not a clean answer. ## Troubleshooting | `error.code` | What happened | What to do | |---|---|---| | `SEND_NOT_OPTED_IN` | a send script was run without `--send` | intentional; add `--send` only after the user has confirmed | | `NOT_CONFIRMED` | no confirmation record for this order | run `create-order.js ... --confirm` after the user says yes | | `CONFIRMATION_STALE` | the live deposit details changed since the confirmation | re-confirm with fresh details; never send against the old ones | | `LINK_NO_LONGER_PAYABLE` | the Coinbase link is used, expired, settled, or was consumed by someone else between quote and now | ask the merchant for a fresh link; do not fund anything | | `LINK_PAYABILITY_UNKNOWN` | the Coinbase state was incomplete, so payability could not be proved | treat as not payable; retry, and do not fund on a guess | | `DEPOSIT_INCOMPLETE` | no positive amount, or a Lightning order with no BOLT11 yet | wait and re-run; nothing is payable yet | | `DEPOSIT_MEMO_REQUIRED` | a Stellar order arrived without its memo | do not send; Stellar routes on a shared hub address plus the memo, so without it the payment is lost | | `LOCK_TIMEOUT` | another rozo-checkout process holds the send lock | wait for it; never bypass by clearing the lock mid-flight | | `TRACKED_DOTENV_UNVERIFIABLE` | env files exist but git could not prove they are untracked | fix git, or run from a directory with no env files | | `TX_REVERTED` / `TX_FAILED` | the transfer landed and failed | no funds moved, but the order stays locked; investigate before retrying | | `ORDER_ALREADY_ACTIVE` | the router found an order for this link that is past awaiting payment, or a payment on an earlier order | **money-detected rule**: do not pay again, poll `status` or contact support | | `PAYMENT_EXPIRED` | an earlier order for this link expired; `retryable: true` means it cannot be proved unfunded yet, `confirmed: true` means every re-order slot is used | `retryable`: check the wallet, wait a few minutes, retry. `confirmed`: ask the merchant for a fresh link | | `LINK_USED_OR_EXPIRED` | the Coinbase link itself is paid or expired | ask the merchant for a fresh link; do not fund anything | | `ORDER_ALREADY_FUNDED` | an order for this link already shows money | **money-detected rule** — do not pay again, escalate | | `REUSED_SOURCE_MISMATCH` | an existing order expects a different chain/token than the caller chose | let it expire, or pay the chain the order actually expects after re-confirming with the user | | `CREATE_DRIFT` | the created order disagrees with the quote (merchant, amount or link) | do not fund it; let it expire unfunded and report the drift | | `NO_DISCOUNT_VIOLATION` | the server reported a discount, or `callerPays ≠ invoice` | stop; this flow must charge the full invoice | | `EXPIRY_MARGIN` / `EXPIRED` | not enough time left to fund, bridge and settle | start over with a fresh link | | `QUOTE_EXPIRED` / `QUOTE_EXPIRY_MARGIN` | a native coin order's price lock has ended, or is inside the safety margin | do not send; the unfunded order costs nothing. Once it has expired, run pay again for a fresh price, or pay with a stablecoin | | `NATIVE_NOT_OFFERED` | the quote does not list that native coin for this invoice | nothing was created; offer a stablecoin or a native coin from `nativeOffered` | | `NATIVE_AMOUNT_TOO_HIGH` / `NATIVE_AMOUNT_TOO_LOW` | the invoice is outside the native bounds ($2,000 max; native ETH on Ethereum $1 min) | nothing was created; pay with another coin | | `NATIVE_TOKEN_ADDRESS_UNEXPECTED` | a native order names a token contract or mint | do not sign; report it | | `DEPOSIT_ACCOUNT_NOT_WALLET` / `DEPOSIT_BELOW_RENT` | (native SOL) the deposit address is not a plain wallet, or a new one would stay below rent | do not sign; pay by hand only after checking with the operator | | `BOLT11_TOO_SHORT` | the Lightning invoice has under 10 minutes left | see "Lightning invoice too short" below | | `BLACKLIST_HIT` | the deposit address or the sender is on the compromised-address list | send nothing; report it to the operator immediately | | `BLACKLIST_UNAVAILABLE` | the vendored list is missing, malformed or its digest does not match | the skill fails closed by design; do not work around it | | `ALREADY_SENT` | a send is already recorded for this order | do not send again; poll `status.js` and check the chain | | `DEPOSIT_CHANGED` | the live deposit details differ from what was confirmed | abort; re-run `create-order.js` and re-confirm | | `BROADCAST_AMBIGUOUS` | the RPC errored but a transaction may be in flight | do **not** resend; check the sender on an explorer, then poll | | `RPC_CHAIN_MISMATCH` | the RPC is not on the chain the order settles on | fix `ROZO_CHECKOUT_RPC_`; never sign against it | | `INSUFFICIENT_GAS` | the sending wallet cannot pay the network fee (ETH, BNB, POL or SOL; on Base this includes the L1 data fee). For a native coin payment, `required` is the payment plus the fee. `error.details` has network, fee coin, balance, required and shortfall | nothing was signed and the order is untouched; add the fee coin to that wallet and re-run, or pay with another coin | | `GAS_CHECK_UNAVAILABLE` | the fee or balance could not be read from the RPC | **not** evidence of a shortfall; retry, pass `--rpc `, or pay by hand (Mode A) | | `DECIMALS_MISMATCH` | the token's on-chain decimals disagree with expectations | do not sign — the amount could be off by orders of magnitude | | `CAP_PER_TX` | above the $1,100 per-payment limit for automated sending | no override exists; have the user pay from their own wallet (Mode A), which has no limit | | `UNSUPPORTED_SOURCE` | that coin/chain pair is not accepted | offer the table at the top of this file (the server's own list omits Lightning) | | `NO_KEY_SOURCE` | no keyfile, no `~/.config/solana/id.json`, no key env var | tell the user the three options; do not pick one for them. The message lists the `.env` paths that were searched — if theirs is somewhere else, pass `--env-file ` rather than moving their file | | `KEYFILE_PERMISSIONS` | the key file is readable by other users | have them run `chmod 600 ` | | `TRACKED_KEYFILE` | the key file is tracked by git | have them untrack it before signing with it | | `ENV_FILE_PERMISSIONS` | the `.env` is readable by other users | have them run `chmod 600 .env` | | `BAD_ENV_FILE` | a line in the `.env` is not `KEY=VALUE` | the error names the line number only; do not ask them to paste the line | | `KEYSTORE_BAD_PASSPHRASE` | wrong keystore passphrase | let them retry; never echo or store what they typed | | `KEYSTORE_PASSPHRASE_REQUIRED` | a keystore needs a passphrase but there is no terminal | set `ROZO_CHECKOUT_KEYSTORE_PASSPHRASE` for unattended runs | | `MISSING_KEY` | the key env var is not exported | ask the user to export it in their own shell | | `TRACKED_DOTENV` | a `.env` in this directory is tracked by git | untrack it before using hot-wallet keys here | | `EXPIRY_UNPARSABLE` | a deadline is missing or unreadable | abort; never treat an unknown deadline as "probably fine" | ### Lightning invoice too short Re-running `create-order.js` does **not** mint a fresh BOLT11: while the existing order is unexpired the router reuses it, invoice and all. The options are to wait for the order to expire and then create a new one, or to ask the merchant for a fresh Coinbase link. Do not pay a BOLT11 with under ten minutes left. ### The user pasted a `commerce.coinbase.com/pay/{uuid}` URL That is a different, legacy protocol. Say so and stop. ### The user wants to pay in Base USDC Not a special case. Base is a supported source like any other — chain `8453`, token `USDC` — so run the normal flow and say nothing about modes, bridges or alternative tools. Same steps, same confirmation, same deposit block, same polling. Do not send the user off to another skill for this. One consistent path is worth more than a theoretical shortcut, and the shortcut is not obviously cheaper anyway: a same-chain order does not bridge. ### Underpaid Report the shortfall and escalate. Do **not** send a top-up to the same deposit address — a second payment to a one-time address is not guaranteed to be credited.