@selat-ai/selat-pay
v0.12.0
Published
Standalone CLI that pays SELAT Router endpoints over x402 Gateway-batched. Keeps the open-source discovery skill free of heavy deps.
Readme
selat-pay
selat-payis the payment engine for SELAT Agent Payments: a standalone CLI that turns an HTTP 402 into a settled USDC payment — probe, sign, retry — from the user's own self-custodied agent wallet. No API keys, no seed phrase, no gas token.
selat-pay auto-detects the upstream's payment scheme (Circle Gateway-batched x402, erc-3009, MPP) and settles the call through the SELAT Router. It is the engine underneath @selat-ai/selat-cli (selat run / selat skill run), and it works standalone exactly as shown below.
Install
npm install -g @selat-ai/selat-payOr one-off, no install:
npx @selat-ai/selat-pay GET https://upstream/api --chain base --max-amount 0.05How does self-custody work?
Your wallet is yours. selat-pay never holds your keys, your funds, or your credentials.
selat-pay constructs the payment payload (@circle-fin/x402-batching's BatchEvmScheme, protocol-spec EIP-712), then requests the signature through the Circle CLI (circle wallet sign typed-data). The wallet is a Circle Agent Wallet with 2-of-2 MPC key management: the private key exists only as two key shares, both required to sign, and neither share is ever exposed to the agent — or to SELAT. This package is open source precisely so that claim is auditable: every byte that touches the payment path is in bin/selat-pay.mjs.
There is no local-key signing path: every signature — payments and refund claims alike — is produced by the Circle CLI.
Why not circle services pay?
circle services pay cannot complete the SELAT Router round-trip: it runs its own internal probe and does not forward the router's x-selat-quote-id response header on the signed retry. selat-pay performs the probe + retry itself, pinning the quote it pays to the quote it was shown. It also carries the heavier Gateway-batching SDK + viem so that @selat-ai/selat-discovery (the discovery skill) can stay lean — discovery is free and dependency-light; payment is this package's whole job.
Setup
Shell environment or ~/.config/selat-pay/.env (typical on macOS/Linux):
SELAT_ROUTER_URL=https://router.selat.ai
SELAT_AGENT_WALLET_ADDRESS=0xYourAgentWalletAddressA .env in the working directory may still set unrelated keys, but it cannot set payment routing, freeze/budget/history/session paths, CIRCLE_BIN, or XDG_STATE_HOME. Those are taken from the shell and the home config only, so an untrusted repo cannot retarget settlement, disable or inflate freeze/budget, or swap the signing binary.
Paid calls require an armed session budget from selat budget start (the session.json it writes). SELAT_SESSION_BUDGET in the process environment or home config cannot invent a budget — an agent that can set env must not self-arm. --max-amount is required on the paid path and is hard-capped at $1.10; no flag or env raises that ceiling. --probe-only does not need a budget or --max-amount.
Plus the Circle CLI authenticated:
circle wallet login <your-email> --type agentUsage
# Probe only — see the 402 terms without paying
selat-pay GET "https://pro-api.coinmarketcap.com/x402/v3/cryptocurrency/quotes/latest?symbol=eth" \
--chain base --probe-only
# Paid call (GET). --max-amount is required and hard-capped at $1.10.
selat-pay GET "https://upstream/api" \
--chain base --max-amount 0.05
# Paid call (POST with JSON body)
selat-pay POST "https://upstream/api" \
--body '{"foo":"bar"}' \
--chain base --max-amount 0.05
The HTTP method must match the provider's published request schema. If that
schema requires a JSON body and the method cannot carry one (GET / HEAD /
DELETE), selat-pay refuses **before signing** — nothing is charged. Retry as
`POST --body '{...}'` (or PUT/PATCH). GET is never rewritten to POST.
`--skip-schema-check` warns and pays anyway, same as other schema gates.
# Refund claim (SIWx auth via Circle CLI, no payment)
selat-pay refund claim selatx123 --chain base
# Refund query (SIWx auth via Circle CLI, no payment)
selat-pay refund query selatx123 --chain base
# List chains the SDK knows about
selat-pay --list-chainsRefunds
selat-pay supports refund claim and status query against the SELAT Router:
selat-pay refund claim <quote-id> --chain base
selat-pay refund query <quote-id> --chain baseNotes:
<quote-id>must be aselatx...id returned by a prior paid call.- Refund commands are authenticated requests (SIWx + dummy Gateway owner probe), not payments.
- Refund commands do not consume session budget and do not require
--max-amount. The $1.10 hard per-call ceiling does not apply to refunds. --router-url,--chain, and the Circle Agent Wallet address (--address/SELAT_AGENT_WALLET_ADDRESS) still apply.
What does it cost?
Two prices, both disclosed in the 402 quote before you pay:
- Same-rail Gateway passthrough (the upstream itself accepts Gateway-batched x402): quoted at par — 0% fee. Verified live against
router.selat.ai: a $0.008 Gateway-batched upstream is quoted at exactly $0.008. - Cross-protocol (Gateway in → MPP / erc-3009 out): SELAT's flat 5% fee. A $0.01 erc-3009 upstream is quoted at $0.0105.
The per-call 402 quote is authoritative — par pricing is the operator's current live behavior, not a protocol guarantee. Run --probe-only to see the quote before paying, and cross-check it against the upstream's own accept and the discovery catalog's payments[].amountUsd.
--probe-only also surfaces every extension the router attaches to its 402, under quote.extensions in the probe JSON (and a [selat-pay] extensions= stderr line). The one to check before paying is selatTransactabilityIndex — the router's endpoint-health record (last paid status, windowed captured-payment stats), present whenever the router has data for the endpoint. It reports payment-layer observables only — whether payments captured and what HTTP status came back — never the content of the merchant's response. A lastPaid.status of 4xx/5xx or a low successRate across several paidNum is a signal to pick a different endpoint instead of paying to find out; absence of the extension means no data, not bad health.
Flow
selat-pay probes the upstream directly first to detect its payment scheme, then settles every paid call through the SELAT Router — including upstreams that themselves accept Gateway-batched x402. The router settles the upstream leg (same-rail Gateway passthrough, or cross-protocol translation to MPP/erc-3009) and records the call, so SELAT captures per-call settlement data on all rails:
selat-pay Upstream SELAT Router Agent Wallet (MPC)
│ probe │ │ │
├──────────────► │ │ │
│ ◄── 402 (GatewayWalletBatched / erc-3009 / tempo-native) │
│ │
│ probe ${routerUrl}/proxy?target=upstream │
├─────────────────────────────────► │ │
│ │ probe upstream │
│ ├──────────────► … │
│ ◄── 402 (Gateway-batched) + x-selat-quote-id │
│ │
│ build EIP-712 + sign ───────────────────────────────────► │
│ ◄──────── signature ───────────────────────────────────── │
│ │
│ retry with x-selat-quote-id + Payment-Signature │
├─────────────────────────────────► │ │
│ │ settle outbound leg
│ │ (Gateway passthrough or mpp/erc-3009)
│ ├──────────────► upstream
│ ◄── upstream response ────────────┤ │The upstream's detected scheme picks the router's outbound protocol, not whether the router is used, and routing is automatic — there is no manual override. The stderr line after the detection probe reports mode=routed-x402, mode=routed-mpp, or mode=routed-free; a Gateway-capable upstream takes routed-x402 (same-rail passthrough), otherwise MPP wins when present, else x402. routed-mpp never sends x-selat-prefer-protocol: mpp — the router discovers the MPP challenge on its own bare probe, so it can compose Gateway for MPP-only merchants (e.g. Solana-MPP); only routed-x402 advertises x402. SELAT_ROUTER_URL (or --router-url) is always required.
Outputs
- stdout: upstream response body (parsed JSON if possible, raw text otherwise). Pipe-friendly.
- stderr:
[selat-pay]progress lines (quoteId, price, status). Suppressed from stdout for clean piping.
Example:
selat-pay GET ... 2>/dev/null | jq .Exit codes
0— paid call succeeded with 2xx upstream response, or probe-only completed.1— runtime error (probe failed, cost cap exceeded, upstream non-2xx, network error).2— missing dependency (@circle-fin/x402-batchingnot installed).
Known limits
- One inbound signing scheme. This CLI signs against Circle's
GatewayWalletBatchedscheme only. The router refuses inbound payments in any other scheme today, so the two match; if the router ever accepts erc-3009 inbound, this CLI won't cover that path yet. - Router
payTois not allowlisted. A router 402'sextra.verifyingContractis pinned to the chain's canonical GatewayWallet (chainConfig.gatewayWallet). This package has no in-tree set of trusted facilitator addresses, sopayTois still taken from the challenge. Prefer a router URL from the shell or home config (a cwd.envcannot setSELAT_ROUTER_URL). - No quote attestation yet. The router doesn't attest its 402 quotes with a router key, so this CLI cannot cryptographically pin the operator's claimed upstream base price. Once the router lands EIP-712-attested quotes, this CLI will verify the attestation and surface the quoted-vs-catalog comparison before paying.
Roadmap
- Verify router-attested quotes once the router supports attestation.
- Auto-discover available routers from a registry (Phase 2 federation).
- Surface a fee-vs-catalog-price warning before pay.
- Per-call telemetry (opt-in).
License
Apache-2.0 — see LICENSE. Free to use, modify, and redistribute.
What's open and why: the client side of SELAT — this payment engine, selat-cli, and selat-discovery — is open source, because a payments client asking for trust must be auditable: you can read every line that touches a payment. The SELAT Router (the settlement service this CLI talks to) is a proprietary hosted service and lives in a separate, private repository.
