Testing routstr confidential mode (protocol v3)
For people who want to try it: users first, then developers, then node operators.
1. What this is
A routstr node normally sees everything you send to the AI model and everything it answers. In confidential mode the node only relays encrypted traffic between you and the provider (Venice, for now). It cannot read your prompt or the answer, and you never get the node's provider API key. Zero-knowledge proofs make sure neither side cheats, and you pay for the usage the provider itself reported.
This guide covers protocol v3. A connection to the provider can now carry several requests: over HTTP/2 the expensive setup runs once and later messages reuse it. Over HTTP/1.1 every request gets its own connection. None of this is merged into upstream Routstr yet, so every step below uses forks (see the branch map).
Background reading, all on GitHub:
- The paper (PDF): the guarantees and the two-party handshake.
- One-pager (PDF): the short version for users.
- What changed from v2 to v3 (PDF).
- PROTOCOL-V3.md: the full specification.
2. End users: the web app
Nothing to install. The app does all the cryptography in your browser.
Get started
- Open jooray.github.io/routstr-confidential-pwa.
The default node is
https://routstr.cypherpunk.today, which runs protocol v3 since 2026-11-12 (HTTP/2 and HTTP/1.1, a connection deposit of 2 sats). The header should say offer verified: the app checked the node's signed price list against the node key it has pinned. - Fund it in the Wallet panel. You have three options:
- Lightning: enter an amount in sats (500 is filled in) and press Lightning. Pay the invoice (QR code or copy). Once it is paid, the node creates a key for you, or tops up the one you have. Your wallet may add a routing fee.
- Pay with Cashu: paste a
cashuA…orcashuB…token and press Create key (or Top up). The panel lists the mints the node accepts and any mint fee. - Use an existing key: paste an
sk-…key you already have on this node.
- Pick a model, type a message and send it.
Reading the result
Under each answer there is a line like this:
verified HTTP/2 · #2 kept connection first token 0.6 s total 1.4 s 21/48 tok 0.04 sat [details]
| Part | Meaning |
|---|---|
| verified | Every check passed: the provider's certificate, the proofs, the node's signed receipt, and a cost within the signed prices. If a check fails you get an error here instead. |
| HTTP/2 · #2 or HTTP/1.1 | How the browser talked to the provider (the "framing"). With HTTP/2, #2 is the request's number on its connection. |
| new / kept / prepared connection | new: this message opened a connection (slow, about 18 MB of setup data). kept: it reused an open HTTP/2 connection (fast). prepared: it used a connection the app had set up ahead (see pre-warming below). |
| tokens and cost | From the signed receipt. |
Press details to open the Verification panel. It shows the provider's certificate chain, the
proofs (π_NK, π_WIN, π_C3S), and the Receipt card: usage, cost, the session fee, the connection
deposit and what happened to it, and the connection row (the first characters of the connection id
sid and the request number n). The full signed receipt JSON is at the bottom of that card.
A timeline shows where the time went.
HTTP/2 or HTTP/1.1
Open the Node panel and find HTTP to the provider: auto (HTTP/2 when the node offers it), HTTP/2 only, or HTTP/1.1 (HTTP/2 off). The pill in the header shows what your next message will use.
- HTTP/2: one connection serves your next messages until the node closes it after its idle time. Later answers start in under a second. The cost is that the provider can tell those messages come from one client. It can already do that for everything sent with one API key.
- HTTP/1.1: every message opens its own connection. Slower, and no two messages share a connection.
If HTTP/2 fails to open on a node, the app uses HTTP/1.1 for that node for 10 minutes and tells you so.
The connection deposit
Opening a connection costs the node real bandwidth before you have sent anything. So the node holds a small connection deposit when a connection opens (the Node panel and the Costs panel show the amount).
- The first answer on that connection gives it back. The receipt says so.
- The node keeps it if the connection ends with no answer through your doing: a prepared connection you never used, a message you stopped before it reached the provider, or a failed proof.
- You get it back if the node, its sidecar or the provider failed.
Pre-warming (Costs panel, Keep a connection prepared) is off by default: on HTTP/2 the open connection already stays warm between messages, for about two minutes of idle. When you turn it on, after your first answer the app prepares one connection in the background while the page is visible, so the next message after a pause is fast. An unused prepared connection costs its deposit. The app does not pre-warm when the deposit is above 5 sats, on a metered connection, or when your balance cannot cover the deposit plus one message.
Images
Switch to Image mode above the message box. Pick a model, resolution, aspect ratio and count. The node sees the model, the resolution and the count, because it charges for those. It does not see the prompt or the image. The price is a flat amount per image from the signed offer. Images are kept in this browser's storage with their prompt, so delete them from the gallery when you are done. For vision models, the Image button in chat mode attaches a picture to your message. That picture is sent inside the encrypted request, too.
What a failure looks like
| You see | What it means |
|---|---|
| A yellow banner: This node runs an old protocol (routstr-confidential v2); this app speaks v3, and the pill node outdated (v2) | The node has not been upgraded. Nothing is sent to it. Pick another node or wait for the operator. |
| The node speaks a newer protocol than this app. Reload the page to update the app. | The app should update itself within minutes. Reload if it does not. |
| This app and the node run different versions of the two-party key schedule | The app and the node are from different releases. Reload; if it stays, report it. |
| Not enough balance for the connection deposit or for this request's reservation | Top up, or lower max tokens. While a request runs the node holds its worst-case cost and then releases the rest. |
| The node already holds too many unused connections for your address | Other tabs or devices behind the same IP address hold connections. Close them or wait a few minutes. |
| offer rejected in the header | The node's signed offer did not verify against the pinned key or the trusted provider. Do not use the node until you know why. |
To try another node, open the Node panel, set the node URL, its key (hex or npub) and the trusted provider host, then press Apply. Defaults goes back to routstr.cypherpunk.today.
Report problems as GitHub issues on jooray/routstr-confidential. See what to include.
The app protects you from the node, not from whoever serves the page. The footer shows the app and library commits, and version.json lists a hash over every file. Keep balances small.
3. End users: routstrd, for any app
routstrd is a local daemon with an OpenAI-compatible API on http://127.0.0.1:8008/v1. Any app that
can set a base URL and one extra header can use confidential mode through it. For now you have to build it
from source, together with the SDK and two helper programs.
You need: git, Bun, Node.js with npm, and Rust through rustup (the repository pins its toolchain, 1.97.1, and rustup fetches it).
Build
Put the three checkouts next to each other. The SDK link below relies on that.
mkdir confidential && cd confidential
# 1. The helper programs cu-prover and cu-2pc (they must be from the same release as the node's sidecar)
git clone -b main https://github.com/jooray/routstr-confidential
(cd routstr-confidential/crypto && \
cargo build --release -p zkbench --bin cu-prover -p twopc --bin cu-2pc)
# result: routstr-confidential/crypto/zkbench/target/release/cu-prover and cu-2pc
# 2. The SDK with the confidential transport (not on npm yet)
git clone -b confidential-2pc https://github.com/jooray/routstr-sdk
(cd routstr-sdk && npm install && npm run build)
# 3. routstrd, built against that SDK
git clone -b confidential-upstream https://github.com/jooray/routstrd
cd routstrd
bun install
rm -rf node_modules/@routstr/sdk && ln -s "$PWD/../routstr-sdk" node_modules/@routstr/sdk
bun run build # after the link, so dist/ bundles the SDK
bun link # installs `routstrd` into ~/.bun/bin (put it on your PATH)
The binaries land in crypto/zkbench/target/release/, not in crypto/target
(the repository sets that build directory). Keep cu-prover and cu-2pc in one directory:
the SDK looks for cu-2pc next to cu-prover, or in CU_2PC_BIN / CU_2PC.
Without the SDK link the daemon fails at startup with an Export named … not found error.
After you update either checkout, run npm run build in routstr-sdk, then bun run build in routstrd.
Fund and configure
routstrd onboard
routstrd receive <cashu token> # or: routstrd receive 1000 (a Lightning invoice for 1000 sats to pay)
Then add this block to ~/.routstrd/config.json (keep the keys that are already there):
"confidentialUpstream": {
"enabled": true,
"trusted_hosts": ["api.venice.ai"],
"node_pubkeys": ["2ca46ee98f3134c4e11a638663a45983b590bfdefd31588f9c2f03455fba15e7"],
"prover_path": "/absolute/path/to/routstr-confidential/crypto/zkbench/target/release/cu-prover",
"http": "auto",
"maxConnectionDepositMsat": 100000,
"http2": { "reuse": "per-client" },
"prewarm": { "enabled": true }
}
| Key | Default | Meaning |
|---|---|---|
trusted_hosts | (none) | Provider hostnames whose TLS certificate you accept. Empty refuses every session. |
node_pubkeys | (from Nostr) | Node keys whose signed offers you accept. The key above is routstr.cypherpunk.today's. Without this list, routstrd only trusts a key that announced the node on Nostr and has a review. |
prover_path | CU_PROVER_BIN, CU_PROVER, PATH | Where cu-prover is. |
http | auto | auto takes the node's first choice and falls back to HTTP/1.1 for 10 minutes if HTTP/2 fails to open. "2" or "1.1" uses only that. "1.1" turns HTTP/2 off. |
maxConnectionDepositMsat | 100000 | The largest connection deposit you accept. A node that asks for more is not used. |
http2.reuse | per-client | Which requests may share an HTTP/2 connection: per-client (one local API key), shared (everything this daemon sends to that node), or none (a connection per request). |
prewarm.enabled | true | Prepare a connection ahead while you are sending requests. An unused one costs its deposit. It only pre-warms nodes whose deposit is at most prewarm.maxDepositMsat (5000). |
More keys (http2.maxConnections, prewarm.maxTotal, …) are in
routstrd's docs/confidential-upstream.md.
Run and send a request
routstrd start # restart it after editing config.json
# optional: pin every request to one node
routstrd start --provider https://routstr.cypherpunk.today
curl -i http://127.0.0.1:8008/v1/chat/completions \
-H 'content-type: application/json' \
-H 'x-routstr-verify: confidential' \
-d '{"model":"deepseek-v4-flash","max_tokens":200,"stream":true,
"messages":[{"role":"user","content":"Say hi in three words."}]}'
Pick a model the node lists in its offer (https://routstr.cypherpunk.today/v1/confidential/offer?v=3,
the price_list keys). max_tokens is required. With the header, a request is either
confidential or refused; it never goes out in the clear. Only POST /v1/chat/completions and the
image endpoints (/v1/images/generations, /v1/image/generate) work this way.
With the OpenAI Python client: default_headers={"x-routstr-verify": "confidential"}.
The response headers tell you what happened:
x-routstr-confidential-http: the framing,2or1.1.x-routstr-confidential-connection:new,armed(prepared) orreused, and the request's number on its connection.
A stream sends its final data: [DONE] only after the receipt and proofs check out. If a check
fails, the stream ends with an error and no [DONE].
Reuse scopes
Requests on one HTTP/2 connection are linkable for the provider. With the default per-client,
everything sent with one local API key can be linked that way, across conversations. To narrow it:
- Send
x-routstr-reuse-scope: <any string up to 256 bytes>, for example one value per conversation. Requests with different scopes never share a connection. The header can only narrow sharing. - Set
"http2": {"reuse": "none"}for a connection per request. - Set
"http": "1.1"to turn HTTP/2 off for confidential requests.
Logs
routstrd logs -n 50 # today's log
routstrd logs -f # follow
grep '\[confidential\]' ~/.routstrd/logs/$(date +%F).log
Each confidential request writes one line, [confidential] request {...}, with the framing, what it
found (cold, armed or open connection), time to first token and bytes each way. It holds no content or
credentials. Other lines to look for:
node_protocol_outdated: <node> offers routstr-confidential v2 …: that node is not upgraded and is skipped. If no other node can serve the request, you get a 502 witherror_type: "node_protocol_outdated".upgrade_required: the node is newer than your routstrd. Pull and rebuild.twopc_version_mismatch: yourcu-2pcand the node's sidecar are from different releases. Rebuild the helpers frommain.framing "2" marked bad: HTTP/2 failed to open, so that node uses HTTP/1.1 for 10 minutes.retiring armed … unused …; its deposit of N msat is charged: a prepared connection was never used.
Set ROUTSTRD_LOG_LEVEL=debug before routstrd start for more detail. routstrd never
writes confidential prompts or answers to its request/response log.
4. Developers
4a. The browser library
routstr-confidential-web is what the web app uses. The cryptography runs as WebAssembly in a Web Worker. The built wasm is committed, so you do not need Rust. Install it from git and pin a commit:
npm install github:jooray/routstr-confidential-web#<commit on main>
npm builds dist/ on install. In Vite, set worker: { format: "es" } and
optimizeDeps: { exclude: ["@routstr/confidential-web"] }. Your page's CSP needs
script-src 'self' 'wasm-unsafe-eval', worker-src 'self', and connect-src for the
node (https: and wss:). The web app's
vite.config.ts is a working example.
import { createConfidentialClient } from "@routstr/confidential-web";
const client = createConfidentialClient({
node: "https://routstr.cypherpunk.today",
nodePubkeys: ["2ca46ee98f3134c4e11a638663a45983b590bfdefd31588f9c2f03455fba15e7"],
trustedHosts: ["api.venice.ai"],
credential: "sk-…", // or client.setCredential(...) later
http: "auto", // "2", or "1.1" (HTTP/2 off); client.setHttp(...) later
prewarm: true,
onEvent: (e) => console.debug(e), // progress events, never secrets
});
const stream = await client.chat.completions.create({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: "Hello" }],
max_tokens: 512,
stream: true,
});
for await (const chunk of stream) out.textContent += chunk.choices[0]?.delta?.content ?? "";
const v = await stream.verification; // throws if any proof or receipt check failed
console.log(v.framing, v.n, v.connection, v.deposit, v.ttftMs, v.receipt.cost_msats);
The main options:
| Option | Default | Meaning |
|---|---|---|
http | "auto" | The node's first framing, with a fallback from HTTP/2 to HTTP/1.1. "2" or "1.1" forces one. |
reuseScope | one scope per credential | Requests with different scopes never share a connection, so the provider cannot link them that way. |
http2.reuse | "shared" | "none" opens a connection per request. |
maxConnectionDepositMsat | 100000 | The largest connection deposit accepted. More is refused before anything is sent. |
padBucket | off | Pad the request body to a multiple of N bytes, or "pow2". It hides the exact size from network observers and the provider, not from the node, which learns the token count at settlement. It costs upload. |
prewarm | off | true or options (maxDepositMsat, default 5000; allowMetered). Keeps one prepared connection while the page is visible and funded. |
onNodeProtocolOutdated | Called when the node's offer names an older protocol. |
What stream.verification resolves to (the main fields):
| Field | Meaning |
|---|---|
sid, n | The connection id and the request's number on it. |
framing, stream, fellBack | "2" or "1.1"; the HTTP/2 stream; whether HTTP/2 failed and it fell back. |
connection | "new", "armed" or "reused". |
receipt, receiptJson, receiptSig | The node-signed receipt. |
receiptSigOk, attestationSigOk, usageMatch, costWithinOffer | The checks. All are true, or the call would have thrown. |
costMsats, ownUsage | What you paid, and the usage the client decrypted itself. |
deposit | The connection deposit and whether this request released it. |
provider | The certificate chain, TLS version, cipher suite and ALPN. |
http, ttftMs, timings | The provider's status, the time to first token, and per-step timings and proof results. |
Typed errors to handle: NodeProtocolOutdatedError (a v2 node), UpgradeRequiredError (the
node is newer), TwoPcVersionMismatchError, ConnectionDepositError,
RequestRefusedError (402 balance, 409 offer changed). Images: client.images.generate({...}).
The library README has the rest, including
the security model of serving this code from a web page.
4b. The SDK (Node and Bun)
The routstr SDK has the same client for Node and Bun. It runs cu-prover and cu-2pc as
child processes. Build it from the confidential-2pc branch as in section 3,
then depend on your local checkout (for example npm install /path/to/routstr-sdk).
A single request, without routing:
import { fetchConfidential } from "@routstr/sdk/bun"; // or "@routstr/sdk/node"
const res = await fetchConfidential({
baseUrl: "https://routstr.cypherpunk.today",
bearer: "sk-…",
body: { model: "deepseek-v4-flash", max_tokens: 200, stream: true,
messages: [{ role: "user", content: "Hello" }] },
options: {
trustedHosts: ["api.venice.ai"],
nodePubkeys: ["2ca46ee98f3134c4e11a638663a45983b590bfdefd31588f9c2f03455fba15e7"],
proverPath: "/path/to/cu-prover", // cu-2pc next to it, or twoPcPath / CU_2PC
http: "auto",
},
reuseScope: "conversation-1", // optional
});
// read res.body as a normal SSE stream, then:
const { costMsats } = await (res as any).confidentialSettled;
console.log(res.headers.get("x-routstr-confidential-http"), res.headers.get("x-routstr-confidential-connection"));
With routing, node selection and top-ups, pass the same options as confidential: {...} to
routeRequests (from @routstr/sdk/bun or /node); RoutstrClient
then runs fetchConfidential in place of fetch(). Details:
client/confidential/README.md
in the SDK. To test against a node you run: CU_PROVER=… CU_2PC=… bun scripts/confidential-v3-e2e.ts <node> <sk-key> <model>.
5. Node operators
A confidential node is a normal Routstr node plus cu-sidecar, a small Rust service. The sidecar holds the provider key, relays the TLS connection to the provider, writes the one record that carries the key, and verifies the client's proofs. Billing uses the node's normal reservation and settlement.
What you need
- An API key for an OpenAI-compatible provider that you are willing to sell. Venice is the tested one; see Providers for others.
- A server with a public HTTPS name and a reverse proxy that passes WebSockets.
- The node: jooray/routstr-core, branch
confidential-2pc. - The sidecar: jooray/routstr-confidential, branch
main. Build it with podman/docker or with Rust. - Python 3 with OpenSSL 1.1.1 or later, for the provider check.
1. Check your provider
Confidential mode needs a provider that behaves in specific ways (it waits for the full body, uses the last
duplicate JSON key, enforces token caps, reports usage in the stream, and more). Check yours with the offer file
you will deploy. The key goes only to the offer's upstream_host.
cd routstr-confidential
T=crypto/tools/provider-profile-check.py
python3 -I $T --offer my-offer.json --key-env MY_PROVIDER_KEY --model <a cheap chat model> # A1-A13: all must pass
python3 -I $T --offer my-offer.json --key-env MY_PROVIDER_KEY --model <same> --h2 # H1-H8: before you offer HTTP/2
python3 -I $T --offer my-offer.json --key-env MY_PROVIDER_KEY --image-model <model> --images # I1-I9: before you offer images
The --h2 run idles a connection for h2.idle_secs (5 minutes by default) and ends with a
verdict: offer "2": yes, no, because … (keep HTTP/1.1 only), or not yet
(run it again). The image run generates three paid images.
2. The sidecar offer file
Start from crypto/relay/offer.venice.json. It has the host, the request-head template, the
chat block (the pinned suffix), the HTTP framings and the image endpoint. The file carries no
prices: models and prices come from the node.
"http": ["2", "1.1"],
"h2": {"probe_path": "/api/v1/models", "max_requests": 100, "max_lifetime_s": 3600,
"idle_secs": 300, "kinds": ["chat", "images"]}
| Field | Default | Meaning |
|---|---|---|
http | ["1.1"] | Framings in your order of preference. Add "2" only after --h2 said yes. Keep "1.1" after it: clients fall back to it. |
h2.probe_path | A cheap authenticated GET (the models list). It runs once per connection and stores the key in the provider's header table. | |
h2.max_requests | 100 | Requests per connection (1 to 1000). |
h2.max_lifetime_s | 3600 | A connection closes at the first settlement after this long. |
h2.idle_secs | 300 | The sidecar's idle limit. Not longer than the idle time your --h2 run passed with. |
h2.kinds | ["chat"] | Add "images" to serve images on HTTP/2 (needs endpoints.images). |
endpoints.images | The image endpoint (Venice: POST /api/v1/image/generate, count_key "variants"). Without it the node offers chat only. | |
model_allowlist | empty (all) | Restrict the confidential models. |
For another provider, see docs/SETUP.md and docs/CHAT-SUFFIX.md in routstr-confidential.
The sidecar refuses at startup a chat block that clients would refuse.
3. Build and run the sidecar
# container
podman build -f crypto/Containerfile.sidecar -t cu-sidecar crypto # or docker
openssl rand -hex 32 # the shared token: CU_SIDECAR_TOKEN here, CONFIDENTIAL_SIDECAR_TOKEN on the node
podman run -d --name cu-sidecar --network <node network> \
-e UPSTREAM_API_KEY=… -e CU_SIDECAR_TOKEN=… \
-v ./my-offer.json:/etc/cu-sidecar/offer.json:ro \
cu-sidecar \
--listen 0.0.0.0:7444 --upstream api.venice.ai:443 --key-env UPSTREAM_API_KEY \
--offer /etc/cu-sidecar/offer.json --node-settles --runs-dir /runs
# or with cargo (binary in crypto/zkbench/target/release/cu-sidecar)
cd crypto && cargo build --release -p cu-sidecar
- Never publish the sidecar's port. It does not check client credentials (the node does that before it connects), so anyone who reaches it spends your provider key. Off loopback it refuses to start without the token. The token crosses the node-to-sidecar link in cleartext: keep that link on loopback or a private container network, or tunnel it.
- The image ships Venice's offer file at
/etc/cu-sidecar/offer.json. Mount your own over it if you changed anything. - Limits:
--max-sessions32 connections,--max-body8 MiB per request. Run files are kept 24 hours (--runs-keep-hours, 0 removes them at once). - After 5 HTTP/2 connections in a row fail to open, the sidecar stops offering
"2"for 10 minutes and logsframing "2" withdrawn from the offer. If that repeats, run--h2again.
4. The node
Build routstr-core from confidential-2pc (its Dockerfile is the upstream one, or uv sync
for a source checkout) and set:
CONFIDENTIAL_SIDECAR_URL=http://cu-sidecar:7444
CONFIDENTIAL_SIDECAR_TOKEN=<the sidecar's CU_SIDECAR_TOKEN>
CORS_ORIGINS=https://jooray.github.io,https://your-app.example # comma-separated, not JSON
Start it with a long WebSocket ping timeout and one worker:
uvicorn routstr:fastapi_app --host 0.0.0.0 --port 8000 --proxy-headers \
--ws-ping-interval 20 --ws-ping-timeout 300
- Use this instead of the image's default
fastapi run routstr. On a slow link the keepalive waits behind about 16 MB of setup data, and the default 20 s timeout drops the connection. Do not set--ws-max-sizebelow 12 MiB. - The node keeps offers and sessions per process. It refuses to start in confidential mode with more than one worker.
- The sidecar URL and token are read only from the environment. The other settings below are stored in the
database at first start; after that, change them in the admin settings (
PATCH /admin/api/settings) or the dashboard. - Your reverse proxy must pass WebSockets for
/v1/confidential/wsand/v1/confidential/zk(Upgrade/Connectionheaders, a read timeout of a few minutes). CORS_ORIGINSmust allow the origins of browser clients, or the web app cannot reach your node. The default is*. Like other settings, a value already in the database wins over the environment.- The provider row must have the type venice (add it in the dashboard as a Venice provider).
A row seeded from
UPSTREAM_BASE_URLhas the typecustom, and then the offer lists no models. On a scratch test node with SQLite you can fix it directly:delete from upstream_providers where base_url not like '%venice%'; update upstream_providers set provider_type='venice';then restart.
| Setting | Default | Effect |
|---|---|---|
CONFIDENTIAL_CONNECTION_DEPOSIT_MSAT | max(abandon fee, session fee, 1000) | Held when a connection opens, released by its first settled request, kept when the client wastes the connection. 0 = no deposit. |
CONFIDENTIAL_IDLE_TIMEOUT_S | 120 | Close a connection with no request in flight after this long. At least 10, at most h2.idle_secs. |
CONFIDENTIAL_MAX_ACTIVE_PER_IP | 16 | Connections per client address. At the limit the longest-idle used one is closed, otherwise the setup gets 429. |
CONFIDENTIAL_MAX_UNUSED_PER_IP | 12 | Unused connections per client address (prepared ones count). |
CONFIDENTIAL_TRUSTED_PROXIES | none | Peers whose client-address header is believed, e.g. 127.0.0.1,::1 for nginx on the same host. Without it, all clients behind your proxy share one limit. |
CONFIDENTIAL_SESSION_FEE_MSAT | 0 | A flat fee, once per connection, with its first settled request. |
CONFIDENTIAL_ABANDON_FEE_MSAT | the session fee | For a request dropped before it reached the provider, and for most provider 4xx refusals. |
CONFIDENTIAL_RATE_SURCHARGE_PCT | 0 | Added to the per-token rates and image prices. |
CONFIDENTIAL_RESERVE_MARGIN_TOKENS | 16 | Completion tokens reserved beyond max_tokens. |
Each connection costs the node about 18 MB of upload and under 0.5 s of CPU, before any request. Size the deposit to that. All of these values go into the signed offer, so clients see them before they pay.
5. Check and test
curl -s 'https://<node>/v1/confidential/offer?v=3' | jq '{v, http, h2, twopc,
connection_deposit_msat, idle_timeout_s, upstream_host, models: (.price_list|keys)}'
v must be 3, twopc "split-public-1", http what your
offer file lists, and models not empty.
The reference driver (in crypto/client, after npm install there; it needs cu-prover and cu-2pc):
cd crypto/client && npm install
CU_PROVER=../zkbench/target/release/cu-prover CU_2PC=../zkbench/target/release/cu-2pc \
node --experimental-strip-types src/driver.ts --node https://<node> --node-pubkey <hex or npub> \
--model <model> --max-tokens 40 --http 2 --requests 3 --auth <sk-… or cashu…>
# then the same with --http 1.1
It prints every check. The gates run against your provider key and spend a little money:
| Script | What it checks |
|---|---|
VENICE_API_KEY=… crypto/tools/v3-check.sh | Starts its own sidecar on loopback. Honest connections on both framings and attacks by a malicious client. |
VENICE_API_KEY=… crypto/tools/v3-node-check.sh | Starts a sidecar and a routstr-core node on scratch state (set CU_CORE to your checkout, with its .venv). Checks the money outcome of every case in the node's database. |
Both exit non-zero on any deviation. CU_KEY_ENV, CU_UPSTREAM and CU_V3_OFFER
point them at another provider. CU_NO_IMAGES=1 skips the paid image cases.
With the web app: open the Node panel, enter your node URL, its key and the provider host, press
Apply, fund and send a message. If the app cannot reach the node, check CORS_ORIGINS first.
6. Announcing, or not
A node with a sidecar configured adds the tag ["confidential","3"] to its Nostr listing, so clients can
find confidential nodes. A test node should not announce itself. Point RELAYS at a relay that does not
exist (for example RELAYS=ws://127.0.0.1:9), and stop test nodes when you are done. A forgotten node with
real relays keeps announcing itself.
Upgrading from v2: upgrade the sidecar and the node together. v2 clients then get 426 upgrade_required.
See "Upgrading from v2" in
docs/SETUP.md.
7. Providers
Which upstream providers work with protocol v3, measured on 2026-11-12 with free models only (Venice: the earlier reference measurement; Cloudflare and OpenCode Zen end to end on 2026-11-13). Results change when providers change their front ends: re-run before you rely on a row. Details, evidence and the image analysis for OpenRouter: docs/PROVIDERS.md.
| Provider | Works (chat) | HTTP/2 | HTTP/1.1 | Idle HTTP/2 kept for | Chat | Images | End to end (sidecar) | Through a node |
|---|---|---|---|---|---|---|---|---|
| Venicereference | ✓ | ✓ | ✓ | 400 s | ✓ | ✓ | ✓ | ✓ |
| Numes | ✓ | ✓ | ✓ | 600 s | ✓ | ? | ✓ | ✓ |
| xKiro | ✓pin price fields | ✓ | ✓unknown fields accepted | 400 s | ✓ | ? | ✓ | ✓ |
| OpenRouter | ✓pin price fields | ✓ | ✓ | 400 s | ✓ | ✗only via chat | ✓ | ✓ |
| Cloudflare Workers AI | ✓pin price fields | ✓ | ✓unknown fields accepted | 400 s | ✓ | ? | ✓ | ✓ |
| NVIDIA NIM | ✗ | ✓ | ✗first model wins | 340 sTCP close | ✗ | ? | ✗decoy runs | – |
| Google AI Studio | ✗ | ✗usage in content | ✗usage in content | 240 s | ✗ | ? | – | – |
| UnoRouter | ✗ | ✗usage not last | ✗cap overrun | ? | ✗ | ? | – | – |
| OpenCode Go | ✗ | ✗no HTTP/2 | ✗needs extra header | – | ✗ | ? | – | – |
| OpenCode Zen | ?free quota never served | ✗no HTTP/2 | ?429 on every chat | – | ? | ? | ?only 429s | – |
✓ works, ✗ does not, ? not tested or not
available, – not run or not applicable. "Pin price fields": the provider honours request
fields the protocol does not fix, so the operator pins the price-changing ones off in the offer
(what that means). "Idle HTTP/2 kept for" is how long the provider keeps a silent HTTP/2
connection open before it closes it (with GOAWAY unless noted); the idle_secs to offer is in each
provider's notes. "End to end" means our sidecar and the reference driver (receipts signed by the sidecar).
"Through a node" means a scratch routstr-core node: its signed offer, its receipts, and the money of every case
checked in its database. The reasons behind each mark are below.
What "pin price fields" means
The client's part of the request body is private: the node never sees it, and the provider reads it before the
pinned suffix. A field the provider honours there that changes the price without changing the pinned model, the
caps or the usage token counts is a loss for the node, because the node bills the reported tokens at the offer's
rates. The operator therefore lists such fields in the offer's chat.fixed_pins with an "off" value
(false, "off", [], or an object whose members are all off). The suffix comes
last and the provider keeps the last value of a duplicate key, so the pin wins. This is a setting of the sidecar's
offer file, not a protocol change. Which fields need it is to verify per provider.
The key-echo check (H7)
The client decrypts every response the provider sends. If the provider ever copied the node's API key into a
response, for example an error that quotes the request's headers or says "invalid key sk-…", the client would
learn the key. Before framing "2" is offered, the HTTP/2 check (item H7 of provider-profile-check.py)
sends one request of each kind and searches every response, headers and body, for the key:
- the probe GET that carries the key into the connection;
- a normal chat request (200);
- a chat request without the key (401);
- a chat request naming a model that does not exist (400 or 404);
- a chat request with a field the provider refuses (400).
The check uses an unknown field for the last one. Providers that accept any field need a field with a bad value
instead: Numes a bad service_tier, OpenRouter and Cloudflare "top_p":2, xKiro a
messages that is not an array (it accepts every other bad value tried). With those, Venice, Numes,
xKiro, OpenRouter and Cloudflare pass H7 and every other HTTP/2 item at their suggested idle_secs.
Venice (reference) ✓ works
- Endpoint
api.venice.ai/api/v1, the deployed provider. Its model ids carry noorg/prefix, so its offer has noupstream_modelsand is unchanged.- Profile
- Every HTTP/1.1 item (A1-A13) and every HTTP/2 item (H1-H8) passes.
- Idle, keepalive
- A silent HTTP/2 connection closes after 400 s with GOAWAY. A PING or an empty SETTINGS every 2-5 min holds it 16+ min. Suggested
idle_secs: 390 (production). - Images
- ✓ native
/api/v1/image/generate. - End to end
- Sidecar (v3 gates, production): chat ✓ on HTTP/2 and HTTP/1.1, a follow-up on a kept connection ✓, an image ✓, chat and an image on one connection ✓.
- Through a node
- Chat ✓ on both framings, follow-up ✓, every receipt and the money exact:
v3-node-check.sh63/63 with the new decoy cases, routstrd 11/11.
Numes ✓ works
- Endpoint
api.numes.ai/api/v1- Model
- Free
moonshotai/kimi-k3.1-pro, offered by the node askimi-k3.1-pro(15 models in the node's offer, all viaupstream_models). - HTTP/1.1
- The profile holds (A1, A2, A5, A6, A7, A9, A11, A12). An unknown field gets a 502: passed on and refused upstream, not ignored (A4). Of the price fields,
service_tierandweb_search_optionsget a 400,predictionis accepted (A10). - HTTP/2
- Every item passes (H1-H8) at 590 s. The error-path probe (H7) uses a bad
service_tier, since an unknown field is a 502. - Idle, keepalive
- 600 s, GOAWAY. PINGs do not reset it; a request every 200 s keeps it (an idle limit, not an age limit). Suggested
idle_secs: 590. - Images
- ? no image models.
- End to end
- Sidecar: chat ✓ on both framings, three requests on one connection and a request after 60 s idle ✓. Node: chat ✓ on both framings, three on one connection ✓, money exact; the decoy ran the pinned model and the model swap was refused on both framings.
- Watch
- Slow at times (time to first token up to 69 s): one
h2-threerun hit the 180 s case limit and passed with 600 s.
xKiro ✓ works pin price fields
- Endpoint
api.xkiro.com/v1(FreeLLMAPI key)- Model
- Node:
mistral-small-2603→mistralai/mistral-small-2603. The node prices 44 of xKiro's 69 models;ministral-3bis not among them, so the sidecar-level model could not be used. - HTTP/1.1
- The profile holds (A1, A2, A5, A6, A7, A9, A11, A12), but unknown fields (A4) and price fields (A10) are accepted silently; even
"temperature":"hot"is a 200. - HTTP/2
- Every item passes (H1-H8) at 390 s. The key-echo check (H7) uses
"messages":"x"as the refused field (why): no other bad value tried gets a 4xx. - Idle, keepalive
- 400 s, GOAWAY. A PING every 120 s holds it 15 min. Suggested
idle_secs: 390. - Pin
- The same rule as OpenRouter (what pin means). Since xKiro accepts any field without complaint, which ones it honours is to verify per field before a paid account is offered.
- Images
- ? no image models.
- End to end
- Sidecar: chat ✓ on both framings, three on one connection and after 60 s idle ✓. Node: chat ✓ on both framings, three on one connection ✓, money exact, decoy and model swap as Numes. Also through routstrd (gate 10/10, five on one connection) and the SDK gate (20 requests, all cases).
OpenRouter ✓ chat pin price fields not yet safe to offer: PDF OCR, provider price spread ✗ images
- Endpoint
openrouter.ai/api/v1(FreeLLMAPI key, account with credit)- Model
- Sidecar:
liquid/lfm-2.5-2.6b:free. Node: routstr's OpenRouter catalogue drops:freeids, so a node offers paid models only; tested withmistral-nemo→mistralai/mistral-nemo($0.00000024 a request). - HTTP/1.1
- The profile holds (A1, A2, A9, A11). The usage event (A7) passes under the §5.4 rule: one choice with an empty delta,
finish_reason,native_finish_reason(it failed before the fix). The SSE framing (A12) passes on the re-run: the: OPENROUTER PROCESSINGcomment lines come first and are no part of the usage tail. Unknown fields are accepted (A4). - HTTP/2
- Every item passes (H1-H8) at 390 s. The key-echo check (H7) uses
"top_p":2as the refused field (why), since an unknown field is accepted. - Idle, keepalive
- 400 s, GOAWAY. A PING every 120 s holds it 15 min. Suggested
idle_secs: 390. - Pin
- Verified live on 2026-11-13 (docs/providers/OPENROUTER.md). The suggested offer
crypto/relay/offer.openrouter.jsonpinsplugins,models,tools,provider,preset,web_search_optionsandservice_tieroff, and the node leaves out models with non-text output, tiered prices, 1-hour cache writes, audio input and native web search. With that, every price attack tested cost OpenRouter no more than the node charged. Two gaps remain: a PDF in the request triggers paid OCR that no allowed pin switches off, and OpenRouter may route an honest request to a provider 2-7x dearer than the listed rate. Both need an account setting or a wider pin policy before OpenRouter is offered. Pinningtoolsoff also removes function calling. - Images
- ✗ images come through chat (
modalities); not supported (see docs/PROVIDERS.md). - End to end
- Sidecar with the
:freemodel: chat ✓ on both framings (one, three on a connection, decoy, model swap), follow-up ✓; image ✗, chat and image on one connection ✗. Node: chat ✓ on both framings, three on one connection ✓, money exact, decoy and model swap as Numes. This account's data policy allows no endpoint ofliquid/lfm-2.5-2.6b(a 404, settled as an error head with the abandon fee, exactly).
Cloudflare Workers AI ✓ works pin price fields; the offer publishes the account id
- Endpoint
api.cloudflare.com/client/v4/accounts/<account>/ai/v1(FreeLLMAPI key)- Model
- Free tier
@cf/meta/llama-3.2-1b-instruct; the node offers it asmeta/llama-3.2-1b-instruct(pinned as@cf/meta/llama-3.2-1b-instructviaupstream_models). - HTTP/1.1
- The profile holds (A1, A2, A5, A6, A9, A11, A12). The usage event (A7) passes under the §5.4 rule: the usage is on every chunk and the last one has an empty
deltaand afinish_reason; only the last event is disclosed (it failed before the fix). Unknown fields (A4) and price fields (A10) are accepted. - HTTP/2
- Every item passes (H1-H8) at 390 s, measured with the probe
/client/v4/user/tokens/verify. The key-echo check (H7) uses"top_p":2as the refused field (why), since an unknown field is accepted. The clients accept only an API base plus/modelsas the probe, so the end-to-end offer uses…/accounts/<account>/ai/v1/models: a 405 without the key in it, and its answer is discarded (the probe only carries the credential into the HPACK table). - Idle, keepalive
- 400 s, GOAWAY. A PING every 120 s holds it 15 min. Suggested
idle_secs: 390. - Images
- ? image models only on
/ai/run/@cf/...;/ai/v1/images/generationsanswers "No route for that URI". - End to end
- Sidecar (2026-11-13, 17/17): one request on each framing, three on one HTTP/2 connection, two HTTP/1.1 connections, a 200 kB body on both, a second request after 60 s idle, armed connections on both, a refused request then the good one on both, the decoy on both (the provider ran and reported the pinned model), the suffix and model-swap attacks refused on both. Node (8/8): chat ✓ on both framings, three on one connection, two HTTP/1.1 connections, the decoy on both, the model swap refused on both; every receipt verified and the money exact.
- Node setup
- Workers AI has no
/modelslisting (405), so the node'sgenericprovider finds no models: the model is added through the node's admin API (POST /admin/api/upstream-providers/{id}/models, priced by hand). - Watch
- The account id is in the chat path, so the head template and the probe path of the signed offer publish it. Pin: the same rule as OpenRouter (what pin means); which Workers AI fields change the price is to verify.
NVIDIA NIM ✗ model binding broken
- Endpoint
integrate.api.nvidia.com/v1(FreeLLMAPI key)- HTTP/1.1
- ✗ the first
modelkey wins (routing),max_tokensthe last (profile item A2). So a client can name another model in the private part and be billed at the pinned model's rates; configuration cannot fix it. The rest holds (A1, A4 with a 400, A5, A6, A7, A9, A11, A12); price fields are accepted (A10). - HTTP/2
- Every item passes (H1-H8) at 330 s.
- Idle, keepalive
- 340 s, a TCP close without GOAWAY. A PING every 120 s holds it 15 min. Suggested
idle_secs: 330. - Images
- ? image models are on
ai.api.nvidia.com/v1/genai/..., another host and route. - End to end
- Sidecar: chat ✓ on both framings and three on one connection ✓, but the decoy ✗ on both framings (the request went to the decoy model and did not finish in 180 s). Node: not run, blocked by the model binding.
Google AI Studio ✗ usage event shape
- Endpoint
generativelanguage.googleapis.com/v1beta/openai(FreeLLMAPI key)- HTTP/1.1
- ✗ the usage is merged into the last content chunk (A7); disclosing it would disclose output, so it is not supported. Only one cap key is accepted:
max_tokenswithmax_completion_tokensis a 400 (A6); completion tokens are 0 atfinish_reason: length(thinking is not counted). The rest holds (A1, A2, A4 with a 400, A5, A9, A11, A12). - HTTP/2
- ✗ a request without a key gets a 400, not a 401 (H5), and the usage is not its own event (H8). H1-H4 and H6 pass.
- Idle, keepalive
- 240 s, GOAWAY
session_timed_out. PINGs do not reset it; a request every 150 s keeps it. Suggestedidle_secs: 230. - Images
- ? no free image model.
- End to end
- Not run (blocked).
- Key
- This key has a
., outside the provable alphabet (the sidecar accepts only[A-Za-z0-9_-], the π_NK alphabet). ClassicAIza…keys fit.
UnoRouter ✗ cap overrun, usage not last
- Endpoint
api.unorouter.com/v1(FreeLLMAPI key). Free models: 1 request/min per model.- HTTP/1.1
- ✗ 64 completion tokens at a cap of 20 (A6). Duplicate keys are refused in both orders (A2); an incomplete answer (A11). An intermittent 403
consent_required;: PINGcomments; the model is reported without:free. - HTTP/2
- ✗ after 173 s idle the last event before
[DONE]was not the usage event (H8; H5 fails with it). H1-H4 and H6 pass. - Idle, keepalive
- ? not measured (1 request/min).
- Images
- ? free FLUX models on
/v1/images/generations, but the account's image budget answered 429. - End to end
- Not run (blocked).
OpenCode Go ✗ needs a session header
- Endpoint
opencode.ai/zen/go/v1, a flat-rate subscription; tested withglm-5.3-flash.- HTTP/1.1
- ✗ every request needs an
x-opencode-sessionheader (400MissingSessionID); the head template allows only Host, Authorization, Content-Type, Accept-Encoding, Connection and Content-Length. With the header added the profile holds (A1, A2, A6, A7, A9, A11, A12); unknown and price fields are accepted (A4, A10). - HTTP/2
- ✗ no
h2in ALPN (H1). - End to end
- Not run (blocked). What carrying the header would take is in docs/PROVIDERS.md.
OpenCode Zen ? untested: the free quota is never available to this key
- Endpoint
opencode.ai/zen/v1(OpenCode Go key). 47 models, 11 free (/zen/v1/models), for exampledeepseek-v4.1-flash-free.- Quota
- Every chat request is a 429
FreeUsageLimitError("Free models work in OpenCode; subscribe to OpenCode Go to keep going"), for four free models, with or withoutx-opencode-session. Tried 2026-11-12 ~20:30 UTC (retry-after12 655 s), 23:36 UTC (retry-after1405 s, counting down to midnight) and 2026-11-13 00:00:35 UTC: still a 429, now withretry-after86 364 s. The free models appear to be meant for the OpenCode client only. Paid models need a balance (402). - HTTP/1.1
- The TLS profile passes (A9); every other item is inconclusive on the 429.
- HTTP/2
- ✗ no
h2in ALPN (H1): HTTP/1.1 only (an offer withhttp: ["1.1"]and noh2block). - End to end
- Sidecar (2026-11-13, HTTP/1.1): the protocol runs against Zen (handshake, credential head, body, proofs); the 429 head was disclosed through π_C3S and settled as an error, the receipt verified; the model-swap attack was refused. Chat itself (usage event, decoy, caps) is untested; no node run.
- Header
- Unlike Go, Zen does not ask for
x-opencode-session(an unknown model without it is a 400 "Model is unavailable", notMissingSessionID).
How it was measured
The checks of provider-profile-check.py are numbered A1-A13 (HTTP/1.1) and H1-H8 (HTTP/2); the
provider notes above name them next to what they mean, for operators who re-run the check.
Each provider got its own offer file and ran the HTTP/1.1 profile items (A1, A2, A4-A7, A9-A12; A3 is
Venice-only, A8 and A13 were not run), the HTTP/2 items H1-H8 at the suggested idle_secs, the idle close of
one silent HTTP/2 connection and whether a PING every 120 s holds it 15 minutes. The profile check uses the
client's exact TLS profile (TLS 1.3, one suite, P-256, a KeyUpdate after the head). Providers whose chat profile
holds then ran end to end through our sidecar with the reference driver (crypto/tools/v3-check.sh: both
framings, three requests on one connection, a request after 60 s idle, a 200 kB body, a decoy model, the
model-swap attack), and then through a scratch routstr-core node (crypto/tools/v3-node-check.sh: both
framings, three requests on one connection, the decoy, the model swap on both framings, every receipt checked and
the money of each case exact in the node's database).
A node offers a model under its public id (without the org/ prefix these providers use) and pins the
provider's own id in the request; the signed offer lists the pairs in upstream_models.
Images: no provider here has a free image model on a route the sidecar describes.
To re-run for your provider (the key goes only to the offer's upstream_host):
T=crypto/tools/provider-profile-check.py
python3 -I $T --offer my-offer.json --key-env MY_KEY --model <model>
python3 -I $T --offer my-offer.json --key-env MY_KEY --model <model> --h2 --h2-no-images --h2-idle <idle_secs>
CU_V3_OFFER=my-offer.json CU_KEY_ENV=MY_KEY CU_UPSTREAM=<host>:443 CU_TRUSTED_HOSTS=<host> CU_MODEL=<model> \
CU_SWAP_MODEL=<other model> CU_NO_IMAGES=1 CU_CASES="h2-one h11-one h2-three h2-decoy h11-decoy" crypto/tools/v3-check.sh
6. Branch map
Nothing here is merged upstream. The pull requests below are open against Routstr. routstr.cypherpunk.today runs the v3 branches below.
| Component | Repository | Branch |
|---|---|---|
| protocol, sidecar, reference client, paper | jooray/routstr-confidential | main (v3); v2 archived |
| node | jooray/routstr-core | confidential-2pc (v3; Routstr/routstr-core#855); confidential-v2 archived |
| SDK | jooray/routstr-sdk | confidential-2pc (v3; Routstr/routstr-sdk#112); confidential-v2 |
| routstrd | jooray/routstrd | confidential-upstream (v3; Routstr/routstrd#155); confidential-v2 |
| browser library | jooray/routstr-confidential-web | main (v3); v2 |
| web app | jooray/routstr-confidential-pwa | main (v3); v2 |
Every component must be on v3. There is no fallback between versions: a v3 node answers a v2 client with
upgrade_required, and a v3 client reports node_protocol_outdated for a v2 node and does not use it.
7. Reporting problems
Open an issue at github.com/jooray/routstr-confidential/issues. Include:
- Versions. Web app: the app and library versions and commits from the footer. routstrd, SDK, helpers: the commit of each checkout (
git rev-parse --short HEAD). Node operators: the routstr-core and sidecar commits. - The node URL, and the offer's
vandhttp. - The framing (HTTP/2 or HTTP/1.1) and what the request found (new, kept/reused, prepared/armed).
- The receipt's
sidandn(Receipt card, connection row; or the receipt JSON). With these the operator can find the session in the logs. - The exact error text, and for routstrd the
[confidential]log lines around it. - Browser and OS, or Bun/Node version. Your link speed, if the problem is slowness.
sk- key, Cashu tokens, provider API keys, node nsecs, the sidecar
token, or your prompts. The receipt JSON is safe to share. It holds usage and costs, no content.