Documentation

Build with Sodi.

Private payments for agents, private inference, and stealth revenue for merchants, on Robinhood Chain. Everything here runs on testnet today, and all of it is in the open repo.

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

  1. Deposit (public). The owner deposits USDG into the privacy pool, built on 0xbow's Privacy Pools contracts, deployed unchanged.
  2. Approval. The approval service screens the deposit. After 30 minutes it joins the approved set, which private withdrawals prove membership of.
  3. 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.
  4. 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.
  5. 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

shell
git clone --recurse-submodules https://github.com/rowanweiss/sodicd sodi && npm installnpm run build -w @sodi/rx402-mcp

2. 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.

shell
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.

prompt
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.

ToolWhat it does
rx402_fetchFetches a URL. If it costs money, pays privately and returns the response. An optional max_price_usdg caps that request.
ask_modelAsks 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_statusPrivate balance in the pool, the current session, and spending limits.
rx402_open_sessionPre-funds a new session ahead of a burst of paid requests. Normally automatic.
rx402_recycleMoves what's left in finished sessions back into the pool, through the relayer.
rx402_depositMoves 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_REQUEST on one request (default 0.10 USDG). A per-call cap can only lower it.
  • RX402_MAX_TOTAL_SPEND while the server runs (default 5 USDG).
  • Hosts in RX402_ALLOWED_HOSTS, if set.

Other clients

claude_desktop_config.json
{  "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

VariableDefault
RX402_MNEMONICrequired without an issuerSeed for the pool secrets. Treat it like a wallet seed.
RX402_ISSUER_URLunsetGet sessions from a session issuer; the agent then holds no pool secret.
RX402_AGENT_KEYwith an issuerThe agent's own key: signs issuer requests, derives session keys.
RX402_SESSION_BUDGET1USDG per session
RX402_STEALTHtruePay merchants that offer a stealth address at a one-time address
RX402_GATEWAY_URLinference.sodi.networkGateway for ask_model; off removes the tool
RX402_GATEWAY_MODELdeepseek/deepseek-v4.1-flashDefault model for ask_model
RELAYER_URLrelayer.sodi.networkRun your own to rely on nobody
ASP_URLsodi-asp.onrender.comApproval service
RX402_STORE~/.sodi/rx402.jsonNotes 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.

shell
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. Set ANTHROPIC_MODEL to 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.

agent.ts
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

TypeScript
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.
shell
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 issuer

Then 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/completionsOpenAI format. Streaming with "stream": true.
POST /v1/messagesAnthropic Messages format. Streaming with "stream": true.
GET /v1/modelsThe models, with per-token prices and the margin. Open to any site.
GET /healthWhether 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:

TypeScript
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

price
(input tokens × prompt price + max_tokens × completion price) × (1 + margin)
  • The price is fixed before anything goes upstream, and assumes every max_tokens is 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-SIGNATUREThe x402 v2 payment (or X-PAYMENT for v1).
PAYMENT-RESPONSEThe settlement transaction, on the answer.
Sodi-PayerYour session address, on the unpaid request, to spend its credit.
Sodi-CreditCredit left for the session. For streams, a : sodi-credit comment.
Sodi-Delivery-DigestSHA-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.

StatusWhen
402No payment (the body is the quote), or a payment that couldn't be settled.
400Invalid JSON, an unknown model, a field that can't be priced, or max_tokens over 8,192.
403The abuse check refused the prompt.
413Body over 1 MB.
429Over 30 requests a minute from one paying address.
502The upstream is unavailable, or broke off after payment.
503No 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.

OpenAI SDK
const client = new OpenAI({ baseURL: "https://inference.sodi.network/v1", apiKey: process.env.SODI_KEY });
Anthropic SDK
const client = new Anthropic({ baseURL: "https://inference.sodi.network", apiKey: process.env.SODI_KEY });
Endpoint
POST /v1/keys/topup?amount=2.5With 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/meBalance, 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-balance comment 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 (with balance and quote), 429 per 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.

402 requirement
{  "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"    }  }}
merchant.ts
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 to that isn't payTo, 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 /fundFunds a yield-pool session through its SessionFunder.
POST /recycleSubmits 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.

EndpointReturns
GET /healthStatus, deposits screened, last scanned block, latest root
GET /association-setThe latest published set: root, IPFS CID and labels
GET /association-set/:cidA specific published set
GET /deposits/:labelOne 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.

ContractAddress
Entrypoint0xaCa6…69Ce
USDG pool0x8cdd…BF76
USDG0x7E95…802F
Withdrawal verifier0x2740…4c71
SodiRecycler0x9727…1055
Yield pool (syUSDG)0x67cE…279b
Yield deposit router0x2F00…Fd84
SodiStealthSettler0xb299…EB31
ERC-5564 Announcer0x5564…5564
ERC-6538 Registry0x6538…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

HiddenVisible
Which deposit a session was funded fromThat the pool paid some amount to a session address
The link between the owner and any sessionPayments within one session (same address)
The link between one session and the nextAmounts and timing
Which payments went to which stealth merchantThe 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.

OperatorSlashed for
ApproverApproving a deposit from a blocklisted address, or approving one without recording who made it.
RelayerSigning to relay a valid withdrawal and not doing it by the deadline, or signing for two that spend the same note.
GatewaySettling a payment it signed as refused.
LiquidatorHolding 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 tradeGoes 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.