Overview
Sodi is privacy for money on Robinhood Chain. It is built from parts you can use on their own:
- rx402: an agent pays any x402 API in USDG from a fresh address that nothing on-chain links to its owner, or to its other payments.
- The inference gateway: AI models paid per request from those addresses. No account, no API key, no logs. See the models.
- Stealth payments: a merchant is paid at a new one-time address every time, so its revenue can't be totalled from the chain.
- Private leverage: borrow USDG against a tokenized stock and spend it privately. Built and tested; not deployed yet.
- $SODI: what the operators running all of this put at stake, and what network revenue is burned into. Live: 0xec38…1521
Testnet only, not audited. Robinhood Chain testnet (chain 46630). Don't use this code with real funds.
How a payment works
- Deposit (public). The owner deposits USDG into the privacy pool, built on 0xbow's Privacy Pools contracts, deployed unchanged.
- Approval. The approval service screens the deposit. After 30 minutes it joins the approved set, which private withdrawals prove membership of.
- Open a session (private). The agent proves in zero knowledge that it owns an approved deposit, and withdraws a small budget to a brand-new address. The relayer submits it, so the address never needs gas.
- Pay. The session signs an EIP-3009 transfer for the merchant. Any x402 facilitator settles it; x402 v1 (
X-PAYMENT) and v2 (PAYMENT-SIGNATURE) both work. - Rotate and recycle. After a set number of payments, an hour, or an empty budget, the next payment opens a new session. What's left in old sessions goes back into the pool.
Merchants see an ordinary payment from an address with no history. Nothing links it to the owner, or one session to the next.
Quickstart: give Claude a private wallet
The MCP server isn't published to npm yet, so run it from the repo. It needs Node 20.11 or later.
1. Build it
git clone --recurse-submodules https://github.com/rowanweiss/sodicd sodi && npm installnpm run build -w @sodi/rx402-mcp2. Add it to Claude Code
The quickest setup gives the server a mnemonic, which holds the whole pool. That's fine on testnet; for anything unattended, use a session issuer instead.
claude mcp add sodi \ -e RX402_MNEMONIC="twelve words …" \ -e OWNER_PRIVATE_KEY=0x… \ -- node "$PWD/packages/mcp/dist/index.js"OWNER_PRIVATE_KEY is a testnet wallet holding testnet USDG and a little ETH. It turns on the deposit tool and is used for nothing else.
3. Deposit, then use it
Ask Claude to deposit with rx402_deposit. After the approval service's 30-minute delay, it can pay: rx402_fetch for any x402 URL, ask_model for a model on the gateway, rx402_status for the balance.
Use ask_model with deepseek/deepseek-v4.1-flash to summarise this file in five lines.MCP server
Gives any MCP agent (Claude Code, Claude Desktop, Cursor) a private wallet for x402 APIs and private inference. When a tool call hits 402 Payment Required, it pays from a session address that can't be linked to the owner.
| Tool | What it does |
|---|---|
rx402_fetch | Fetches a URL. If it costs money, pays privately and returns the response. An optional max_price_usdg caps that request. |
ask_model | Asks a model through the inference gateway, paying from a private session. Scans the 402 quote for prompt injection before signing, and checks the delivery digest. |
rx402_status | Private balance in the pool, the current session, and spending limits. |
rx402_open_session | Pre-funds a new session ahead of a burst of paid requests. Normally automatic. |
rx402_recycle | Moves what's left in finished sessions back into the pool, through the relayer. |
rx402_deposit | Moves the owner's USDG into the pool. Public. Only offered when OWNER_PRIVATE_KEY is set. |
Spending limits
An agent can never spend more than:
RX402_MAX_PER_REQUESTon one request (default 0.10 USDG). A per-call cap can only lower it.RX402_MAX_TOTAL_SPENDwhile the server runs (default 5 USDG).- Hosts in
RX402_ALLOWED_HOSTS, if set.
Other clients
{ "mcpServers": { "sodi": { "command": "node", "args": ["/path/to/sodi/packages/mcp/dist/index.js"], "env": { "RX402_ISSUER_URL": "https://issuer.example", "RX402_AGENT_KEY": "0x…", "RX402_MAX_PER_REQUEST": "0.10", "RX402_MAX_TOTAL_SPEND": "5" } } }}Settings
| Variable | Default | |
|---|---|---|
RX402_MNEMONIC | required without an issuer | Seed for the pool secrets. Treat it like a wallet seed. |
RX402_ISSUER_URL | unset | Get sessions from a session issuer; the agent then holds no pool secret. |
RX402_AGENT_KEY | with an issuer | The agent's own key: signs issuer requests, derives session keys. |
RX402_SESSION_BUDGET | 1 | USDG per session |
RX402_STEALTH | true | Pay merchants that offer a stealth address at a one-time address |
RX402_GATEWAY_URL | inference.sodi.network | Gateway for ask_model; off removes the tool |
RX402_GATEWAY_MODEL | deepseek/deepseek-v4.1-flash | Default model for ask_model |
RELAYER_URL | relayer.sodi.network | Run your own to rely on nobody |
ASP_URL | sodi-asp.onrender.com | Approval service |
RX402_STORE | ~/.sodi/rx402.json | Notes and session keys. Keep it private. |
The full list is in the package README.
Claude Code with no API key
The package also builds rx402-proxy, a local proxy Claude Code talks to as if it were the Anthropic API. It forwards each request to the inference gateway, pays the 402 from a private session, and streams the answer back. No Anthropic key or account.
RX402_MNEMONIC="…" node packages/mcp/dist/proxy.jsANTHROPIC_BASE_URL=http://127.0.0.1:8411 ANTHROPIC_API_KEY=unused claude- Requests for a model the gateway doesn't serve, including Claude Code's own
claude-*defaults, go to DeepSeek V4.1 Flash. SetANTHROPIC_MODELto a gateway model to choose. - Only the request body leaves your machine: never the client's key or headers.
- It listens on 127.0.0.1 only, since anything that can reach the port can spend from the wallet.
Claude Code is built for Claude models; with open models some features work less well.
rx402 SDK
@sodi/rx402 is the TypeScript client underneath the MCP server. It's a workspace package in the repo, not on npm yet. client.fetch behaves like fetch: on a 402 it picks the USDG option, opens or rotates a session, signs, and retries.
import { FileStore, IssuerSessions, Rx402Client } from "@sodi/rx402";const store = new FileStore(".rx402/wallet.json");const client = new Rx402Client({ publicClient, // viem, Robinhood Chain testnet asset: USDG, store, policy: { budget: 1_000_000n, // 1 USDG per session maxPayments: 20, // rotate after 20 payments... maxAgeSeconds: 3600, // ...or an hour maxPaymentAmount: 100_000n, // never more than 0.10 USDG a request }, sessions: new IssuerSessions({ url: ISSUER_URL, agentKey, publicClient, asset: USDG, store }),});const { response, payment } = await client.fetch("https://api.example.com/paid-data");Without an issuer, pass mnemonic, the pool addresses, a relayer, a prover and the approval service instead of sessions; the README has the full constructor. Amounts are in USDG base units (6 decimals).
Recycling
const relayer = await HttpRelayer.connect("https://relayer.sodi.network");const outcomes = await client.recycleSessions({ submitter: relayer.recycleSubmitter({ recycler: RECYCLER }),});// [{ session, amount, status: "recycled" | "too-small" | "empty" | "failed", tx? }]The current session, and any that signed a payment in the last 10 minutes, are left alone, so a merchant can still settle. Balances under the pool's minimum deposit (0.01 USDG on testnet) are reported as too-small.
Session issuer
Whoever holds the mnemonic can withdraw the whole pool, and an agent's own caps run in its own code. The issuer moves both somewhere else: on the owner's machine or a server the agent can't reach into.
- The agent holds only an agent key. It can't make a withdrawal proof.
- The issuer holds the mnemonic and funds sessions on request, within a kill switch, a maximum per session and a cap per rolling 24 hours.
- Requests and responses carry only public data.
RX402_MNEMONIC="…" AGENTS=0xAgentAddress DAILY_LIMIT=5 MAX_SESSION_BUDGET=1 \RELAYER_URL=https://relayer.sodi.network ASP_URL=https://sodi-asp.onrender.com \npm start --workspace issuerThen give the agent RX402_ISSUER_URL and RX402_AGENT_KEY. The owner dashboard adds a one-click kill switch, limits and payment history on the agent's machine.
Inference gateway
https://inference.sodi.network. OpenAI- and Anthropic-format inference, paid per request over x402 from a private session. The current models and prices are on the models page.
| Endpoint | |
|---|---|
POST /v1/chat/completions | OpenAI format. Streaming with "stream": true. |
POST /v1/messages | Anthropic Messages format. Streaming with "stream": true. |
GET /v1/models | The models, with per-token prices and the margin. Open to any site. |
GET /health | Whether the gateway is quoting new requests. |
A request without payment gets a 402 with its price. The same request, paid, gets the answer. With rx402 that's one call:
const { response } = await rx402.fetch("https://inference.sodi.network/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "deepseek/deepseek-v4.1-flash", messages: [{ role: "user", content: "Hi" }], max_tokens: 200, }),});The OpenAI and Anthropic SDKs take a custom fetch, so either can point at the gateway with fetch: (url, init) => rx402.fetch(url, init).then((r) => r.response).
Pricing
(input tokens × prompt price + max_tokens × completion price) × (1 + margin)- The price is fixed before anything goes upstream, and assumes every
max_tokensis used. The margin is 10%. - Each model is quoted at a ceiling over its cheapest providers, and requests are routed with that ceiling as the maximum, so the upstream can't charge more than the quote.
- Change becomes credit. After a request the gateway reads what the upstream really charged, and credits the rest to the paying session. Name your session on the unpaid request with
Sodi-Payer: 0x…and the credit comes off the next quote.
Headers
| Header | |
|---|---|
PAYMENT-SIGNATURE | The x402 v2 payment (or X-PAYMENT for v1). |
PAYMENT-RESPONSE | The settlement transaction, on the answer. |
Sodi-Payer | Your session address, on the unpaid request, to spend its credit. |
Sodi-Credit | Credit left for the session. For streams, a : sodi-credit comment. |
Sodi-Delivery-Digest | SHA-256 of the body you received. For streams, a final : sodi-delivery-digest comment. |
Errors
Errors use the OpenAI or Anthropic shape of the endpoint you called. A refusal before the upstream accepts leaves the payment unsettled: you aren't charged.
| Status | When |
|---|---|
| 402 | No payment (the body is the quote), or a payment that couldn't be settled. |
| 400 | Invalid JSON, an unknown model, a field that can't be priced, or max_tokens over 8,192. |
| 403 | The abuse check refused the prompt. |
| 413 | Body over 1 MB. |
| 429 | Over 30 requests a minute from one paying address. |
| 502 | The upstream is unavailable, or broke off after payment. |
| 503 | No provider meets the no-retention rule for that model, prices are unavailable, or the gateway is out of upstream credit. |
What is forwarded
- Only to providers that don't store or train on prompts. If none qualifies, the request fails with 503 rather than falling back.
- None of your headers. The upstream request is built from scratch.
- Nothing is logged, on success or on error.
The model provider still reads the prompt, under Sodi's account. What it can't learn is which agent or owner sent it. Run your own gateway if you don't want to trust ours.
API keys
A prepaid key, for code that can't pay per request: any OpenAI or Anthropic SDK works by changing its base URL. Create and top up a key on the models page, or through the API below.
const client = new OpenAI({ baseURL: "https://inference.sodi.network/v1", apiKey: process.env.SODI_KEY });const client = new Anthropic({ baseURL: "https://inference.sodi.network", apiKey: process.env.SODI_KEY });| Endpoint | |
|---|---|
POST /v1/keys/topup?amount=2.5 | With your key as Bearer. Without a payment: a 402 for that amount in USDG. Paid over x402, from any wallet or an rx402 session: the new balance. A key exists once its first top-up settles. |
GET /v1/keys/me | Balance, total spent, request count, and the day it was created and last used. |
- You make the key:
sk-sodi-and 32 random bytes in base64url. The gateway keeps only its SHA-256 fingerprint, so a lost key can't be recovered. - Each request reserves its quote, then is charged what the upstream cost plus 10%; the rest goes straight back. A refusal before the upstream accepts returns all of it. The new balance is in
Sodi-Balance, or a: sodi-balancecomment for streams. - Top-ups from 0.10 USDG, up to 100 USDG per key. Credit is for inference only and can't be withdrawn.
- Errors:
401 invalid_api_key,402 insufficient_balance(withbalanceandquote),429per key.
A key is convenient, not unlinkable: the gateway knows every request made with one key comes from the same customer. It stores no prompts, no per-request rows and no payer addresses. Top up from an rx402 session and nothing on-chain ties the key to your wallet. For requests nobody can link, use x402 without a key.
Stealth payments for merchants
Every x402 payment lands at a fresh one-time address that only you can find and spend. Your public address never receives anything, so nobody can total your revenue. ERC-5564 and ERC-6538, using the canonical contracts on Robinhood Chain.
Add a stealth object to your requirement's extra. payTo stays your normal address, so clients that don't know the extension still pay you.
{ "scheme": "exact", "network": "eip155:46630", "asset": "0x7E955252E15c84f5768B83c41a71F9eba181802F", "payTo": "0x…merchant", "amount": "50000", "extra": { "name": "Global Dollar", "version": "1", "stealth": { "scheme": "erc5564:1", "metaAddress": "st:eth:0x02…03…", "settler": "0xb29990E4C28737ab9B0aFae0c308ae9D025AEB31" } }}import { StealthMerchant, StealthReceiver, deriveStealthKeys, viewingOnly } from "@sodi/stealth";// Once: derive keys from your wallet and publish the meta-address.const keys = await deriveStealthKeys(merchantWallet);// Your server holds the viewing key only: it can accept payments, never spend them.const receiver = new StealthReceiver(viewingOnly(keys), SETTLER);const requirement = receiver.offer(baseRequirement);const verified = await receiver.verify(payment, { asset, amount, chainId, domain });await receiver.settle(facilitatorWallet, publicClient, verified);// Offline, with the spending key: find and sweep, no gas at the stealth addresses.const merchant = new StealthMerchant(keys, SETTLER);const found = await merchant.scan(publicClient, { fromBlock, tokens: [USDG] });await merchant.sweep({ publicClient, gasPayer, payments: found, token, domain, to });- A standard facilitator rejects any
tothat isn'tpayTo, so verify stealth payments yourself, as above, or through one that understands the extension. - Whoever pays settlement gas is visible. Settle through a facilitator many merchants share, not your own wallet.
- Sweeping many addresses to one place at once links them again. Sweep at different times, or into the pool.
Relayer
https://relayer.sodi.network. Submits zero-knowledge withdrawals so a session address never needs gas. It can't steal or redirect funds: the recipient, fee and fee recipient are fixed by the proof. On testnet it charges 0.5% and relays at least 0.01 USDG.
| Endpoint | |
|---|---|
GET /quote | { chainId, entrypoint, feeRecipient, relayFeeBPS, minWithdrawal }. Build withdrawals with these terms. |
POST /relay | { withdrawal, proof, scope } → { txHash } |
POST /fund | Funds a yield-pool session through its SessionFunder. |
POST /recycle | Submits a signed recycler authorization. |
GET /health |
Every request is simulated before it's sent, so an invalid proof costs nothing. 30 requests a minute per IP.
Approval service
https://sodi-asp.onrender.com. Screens every deposit into the pool and publishes the approved set on-chain. A private withdrawal must prove its deposit is in the latest set. Deposits wait 30 minutes, so an operator can deny one before it's approved; the service scans every 15 seconds.
| Endpoint | Returns |
|---|---|
GET /health | Status, deposits screened, last scanned block, latest root |
GET /association-set | The latest published set: root, IPFS CID and labels |
GET /association-set/:cid | A specific published set |
GET /deposits/:label | One deposit's status: pending, approved or denied |
Contracts
Robinhood Chain testnet, chain id 46630. RPC https://rpc.testnet.chain.robinhood.com, explorer explorer.testnet.chain.robinhood.com.
| Contract | Address |
|---|---|
| Entrypoint | 0xaCa6…69Ce |
| USDG pool | 0x8cdd…BF76 |
| USDG | 0x7E95…802F |
| Withdrawal verifier | 0x2740…4c71 |
| SodiRecycler | 0x9727…1055 |
| Yield pool (syUSDG) | 0x67cE…279b |
| Yield deposit router | 0x2F00…Fd84 |
| SodiStealthSettler | 0xb299…EB31 |
| ERC-5564 Announcer | 0x5564…5564 |
| ERC-6538 Registry | 0x6538…6538 |
Every deployment and its transactions are in contracts/deployments. Morpho isn't on testnet, so the yield pool invests in a stand-in vault with no yield.
What's private, and what isn't
| Hidden | Visible |
|---|---|
| Which deposit a session was funded from | That the pool paid some amount to a session address |
| The link between the owner and any session | Payments within one session (same address) |
| The link between one session and the next | Amounts and timing |
| Which payments went to which stealth merchant | The paying session, and whoever paid settlement gas |
- Privacy grows with the number of people using the pool.
- Round budgets, and a delay between depositing and the first session, make sessions harder to correlate.
- The store file holds session keys. Keep it as safe as a wallet.
Borrowing against stocks
Stage 1 keeps the position public and makes the money private: your stock token opens a Morpho position, you borrow USDG up to 50% LTV, and the USDG lands in the pool in fixed pieces (1,000, 100 and 10) at the next hourly round instead of your wallet. SodiVault is built and has 13 tests against the live NVDA/USDG market on a mainnet fork. It isn't deployed: Morpho isn't on testnet, and mainnet waits on an audit.
Stage 2 seals the position itself in a note proven solvent with zero-knowledge proofs against stored Chainlink prices. The solvency circuit proves 29 test cases; the borrow and claim circuits are skeletons. The design and its limits are in sealed-collateral.md.
Robinhood's stock tokens aren't available to US persons or in some other places; the token enforces that on every transfer. The dollar side is open to everyone.
$SODI
Sodi's privacy depends on operators: the approver that screens deposits, the relayers that submit withdrawals, the gateways that sell inference and, in Stage 2, the liquidators. Today Sodi runs one of each. $SODI is how anyone else can run one, with something at stake instead of trust in Sodi.
Contract: 0xec383a25b6434216eaa695c11c26b765ba061521. Any other address isn't ours. The fee split is done by hand for the first 24–48 hours, then by the splitter below.
Bonds and slashing
An operator bonds $SODI for its role. A bond is slashed only on evidence a contract can check: signatures and on-chain state, never a vote. The slashed $SODI is burned, less a fixed bounty to whoever proved the fault, and unbonding takes longer than every challenge window, so nobody can leave before a fault is proven.
| Operator | Slashed for |
|---|---|
| Approver | Approving a deposit from a blocklisted address, or approving one without recording who made it. |
| Relayer | Signing to relay a valid withdrawal and not doing it by the deadline, or signing for two that spend the same note. |
| Gateway | Settling a payment it signed as refused. |
| Liquidator | Holding an opening for a provably insolvent note and not liquidating it in time (Stage 2). |
Failures no contract can check, like a gateway delivering a poor answer, aren't slashable, and the design says which those are.
Where the fees go
- Network revenue is burned. A share of each private session's opening fee, and of the inference gateway's margin, buys $SODI and burns it.
- Every $SODI trade pays a fixed fee, split by a contract with no owner, no setter and no upgrade:
| Of every $SODI trade | Goes to |
|---|---|
| 1.5% | Bought back as $SODI and burned |
| 0.8% | The backstop: the per-stock bond that pays first for any loss on a sealed position |
| 0.2% | Development |
What it isn't
- Not needed to use Sodi. Deposits, borrowing, payments, inference and API keys are all USDG. Buying a token on a public market could tie a person to their agent, so it's kept out of the user's path entirely.
- Not in the payment path. If every bond contract were switched off, the pool, sessions and payments would keep working.
- Not a yield. Nothing pays holders. $SODI is bonded to be slashed and bought to be burned.
How bonds, the backstop and the burn fit the accounting is in What secures the accounting.
Security
- Not audited. Testnet only. The pool contracts are Privacy Pools', audited, and deployed unchanged.
- An internal review found 33 issues: every high is fixed, the rest are fixed, mitigated or documented, and one low is open.
- Report a vulnerability through SECURITY.md.