diff --git a/.env.example b/.env.example index a926219d..89c8d3e8 100644 --- a/.env.example +++ b/.env.example @@ -6,43 +6,72 @@ NETWORK=testnet # Required for MPP servers: binds payment challenges to their contents so the # server can verify incoming credentials match challenges it issued. +# MAINNET: the server refuses to boot on this placeholder, the dev default, or +# any secret shorter than 24 chars — set a long random string (e.g. openssl rand -hex 32). MPP_SECRET_KEY=change-me-to-a-long-random-string # Wallet that RECEIVES stablecoin payments (your earnings address on Tempo). -# Testnet: funded with pathUSD via faucet. Mainnet: funded with real USDC.e. +# Testnet: funded with pathUSD via faucet. Mainnet: receives real USDC.e. +# MAINNET: the server refuses to boot on the built-in demo address — set your own. TEMPO_RECIPIENT=0x… -# Optional: the PAYEE's private key (address MUST equal TEMPO_RECIPIENT). When set, the -# server cooperatively CLOSES the channel on-chain as the payee (clean open+close ≈ 2 txs, -# paying the fee from its own balance) instead of letting the payer's deposit reclaim on -# channel timeout. Unset → close is best-effort. Must be a wallet whose key you hold. +# Optional: the PAYEE's private key (address MUST equal TEMPO_RECIPIENT — mismatch is +# fatal on mainnet). When set, the server cooperatively CLOSES the channel on-chain as +# the payee (clean open+close ≈ 2 txs, paying the fee from its own balance) instead of +# letting the payer's deposit reclaim on channel timeout. Unset → close is best-effort +# and unsettled revenue is lost on restart (state is in-memory). Must be a wallet whose +# key you hold. Tempo has NO native gas token — fees are paid in stablecoin, so on +# mainnet this account needs a small USDC.e balance for close-tx fees. TEMPO_RECIPIENT_PRIVATE_KEY= +# The real Phala Intel TDX enclave (the private upstream). Unset → stub mode: the +# server runs an offline demo, /health reports ready:false, and agents (correctly) +# refuse to pay. Required for any deployment that should actually earn. +TEE_ENDPOINT= + +# Optional strict-pin: reject the enclave quote unless mrtd/rtmr equals this value. +# Unset → soft-pin (compare-to-advertised + display only). See DESIGN §1. +EXPECTED_MEASUREMENT= + # Pricing (decimal token units; TIP-20 stablecoins use 6 decimals). -PRICE_PER_CALL=0.01 # one-time `charge` per non-stream request PRICE_PER_UNIT=0.0002 # `session` per-response-chunk price for streaming -# ── Upstream LLM (OpenAI-compatible). Leave UPSTREAM_URL empty to use the -# built-in MOCK streamer so the server runs fully offline for a demo. ─────── -# Point this at SolRouter, OpenAI, OpenRouter, a local model, etc. -UPSTREAM_URL= -UPSTREAM_API_KEY= +# How many SSE chunks to slice a response into — each chunk = one MPP voucher tick. +# 1 = one charge per inference; >1 = metered in up to N ticks (short responses +# produce fewer chunks; billing never exceeds N — ADR-0003). +CHUNK_COUNT=1 + +# Default model id the blind relay forwards to the enclave when a client omits one. UPSTREAM_MODEL=gpt-oss:20b +# Request-body cap in bytes (encrypted prompt only; management POSTs are header-only). +MAX_BODY_BYTES=1000000 + PORT=8402 +LOG_LEVEL=info -# ── Client/agent side (src/agent.ts) ───────────────────────────────────────── -# Funded Tempo wallet key used by the paying agent. +# ── Client/agent side (src/agent.ts, sdk, cli, mcp) ────────────────────────── +# Funded Tempo wallet key used by the paying agent. The client's NETWORK must match +# the server's (chain mismatch → vouchers reject with CHAIN_MISMATCH, ADR-0002). # Testnet: get test pathUSD from the faucet at https://explore.testnet.tempo.xyz # Mainnet: fund with real USDC.e via your Tempo wallet AGENT_PRIVATE_KEY= SERVER_URL=http://localhost:8402 +# Read by BOTH sides, in stablecoin units (real dollars on mainnet): +# - Server: suggestedDeposit in every 402 challenge — compliant agents may escrow +# this much per session, so keep it small. +# - Client (agent/sdk/cli/mcp): spend cap per session. +MAX_DEPOSIT=1 # ── Mainnet notes ─────────────────────────────────────────────────────────── # To run on mainnet: # 1. Set NETWORK=mainnet -# 2. Set TEMPO_RECIPIENT to your mainnet earnings address -# 3. Set TEMPO_RECIPIENT_PRIVATE_KEY (mainnet key for cooperative close) -# 4. Set AGENT_PRIVATE_KEY to a mainnet wallet funded with USDC.e -# 5. Currency automatically switches from pathUSD → USDC.e (chain 4217) -# 6. RPC automatically switches to https://rpc.tempo.xyz +# 2. Set TEMPO_RECIPIENT to your mainnet earnings address (demo default = boot failure) +# 3. Set MPP_SECRET_KEY to a long random string (dev default = boot failure) +# 4. Set TEE_ENDPOINT to the real enclave (stub mode earns nothing) +# 5. Set TEMPO_RECIPIENT_PRIVATE_KEY (same address as TEMPO_RECIPIENT; fund it with a +# little USDC.e — Tempo gas is paid in stablecoin) for cooperative close +# 6. Clients: NETWORK=mainnet + AGENT_PRIVATE_KEY funded with real USDC.e +# 7. Currency auto-switches pathUSD → USDC.e (chain 4217); RPC → https://rpc.tempo.xyz +# 8. Voucher state is in-memory (Store.memory) — settle before restarts/redeploys or +# unsettled revenue is lost. Persistent store is a known follow-up. diff --git a/README.md b/README.md index ceabd599..f77fb98b 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ Environment variables (see [`.env.example`](.env.example)): |---|---|---|---| | `TEE_ENDPOINT` | No | — | Phala TDX base URL (`…/tee`). Unset → stub mode. | | `NETWORK` | No | `testnet` | `testnet` (Moderato 42431) or `mainnet` (Allegro 4217). | -| `MPP_SECRET_KEY` | Yes | — | HMAC secret for challenge binding (not a chain key). | -| `TEMPO_RECIPIENT` | Yes | — | Wallet address receiving stablecoin payments. | +| `MPP_SECRET_KEY` | Yes | — | HMAC secret for challenge binding (not a chain key). Mainnet: ≥24 chars, placeholders refused at boot. | +| `TEMPO_RECIPIENT` | Yes | — | Wallet address receiving stablecoin payments. Mainnet: must be a valid, non-demo address (boot guard). | | `TEMPO_RECIPIENT_PRIVATE_KEY` | No | — | Payee key for cooperative channel close. | | `PRICE_PER_UNIT` | No | `0.0002` | Stablecoin per response-chunk. | | `CHUNK_COUNT` | No | `1` | SSE chunks per inference (metering granularity). | @@ -229,8 +229,19 @@ decrypt to plaintext answer.` - ✅ Multi-unit streaming supported (`CHUNK_COUNT > 1`); prod bills one charge per inference - ✅ Cooperative `manager.close()` settles on-chain as the payee +**🛡️ Mainnet readiness VERIFIED on the hardening branch (2026-07-14, PR #3):** + +- ✅ `NETWORK=mainnet` boot serves Allegro (chain 4217, USDC.e) consistently on `/`, `/llms.txt`, `/SKILL.md`, `/openapi.json`; testnet behavior unchanged +- ✅ Fail-closed boot guards: demo recipient, placeholder/short `MPP_SECRET_KEY`, payee-key mismatch, invalid recipient address — all refuse to boot on mainnet; unknown `NETWORK` values crash instead of silently running testnet +- ✅ Chain facts verified against live RPC + official tokenlist (chainId `4217`, USDC.e `0x20C0…8b50`, 6 dp) +- ✅ Staging E2E on the branch: real enclave (`tdx-live`), paid run, **first on-chain cooperative close** (see [evidence.md](evidence.md), staging-run section) +- ⏳ Cutover pending: mainnet env on the deployed service + explicit deploy (the service has never auto-deployed on push — deploy explicitly) + pricing decision below + ## TODO +- [ ] **Fix session economics before mainnet** — close fee (~0.0006) > per-session revenue (0.0002 × 1 chunk): raise `PRICE_PER_UNIT`, meter more chunks, or batch settlement (mppx `settlementSchedule`) +- [ ] **Persistent voucher store** — `Store.memory()` loses unsettled vouchers on restart (real revenue on mainnet); wire an mppx redis/upstash store +- [ ] **Mainnet cutover** — set mainnet env on the deployed service (see `.env.example` Mainnet notes) + explicit deploy + small real-money E2E - [ ] **List on MPPScan** — register at https://www.mppscan.com/register (instant, ~2 min) - [ ] **List on mpp.dev/services** — open PR to `quiknode-labs/mpp-dev-official-docs` (see [DISCOVERY.md](docs/DISCOVERY.md)) - [ ] **Add GitHub topics** — `mpp`, `ai-inference`, `tee`, `intel-tdx`, `privacy`, `stablecoins`, `agents` diff --git a/cli/temprouter.ts b/cli/temprouter.ts index 84b79f7c..09d14669 100644 --- a/cli/temprouter.ts +++ b/cli/temprouter.ts @@ -8,13 +8,13 @@ import { parseArgs } from 'node:util' import process from 'node:process' import { TempRouter, detectSensitive, formatReport, AttestationError } from '../sdk/src/index.js' -import { config } from '../src/config.js' +import { config, tempoChain, isMainnet } from '../src/config.js' const USAGE = `temprouter — attestation-gated, MPP-paid confidential inference on Tempo usage: temprouter infer "" [--model ] [--server ] [--max-deposit ] [--json] - verify the enclave, pay per response-chunk in pathUSD, decrypt locally. + verify the enclave, pay per response-chunk in the network's stablecoin, decrypt locally. temprouter verify [--server ] run the pre-pay attestation gate only. Never pays. Exit 0 if it passes. temprouter detect "" @@ -23,7 +23,7 @@ usage: flags: --model model id (default from SDK, e.g. nosana:gpt-oss:20b) --server tempRouter base URL (default: $SERVER_URL or config) - --max-deposit pathUSD deposit headroom (default: config.maxDeposit) + --max-deposit stablecoin deposit headroom (default: config.maxDeposit) --json machine-readable output for 'infer' (no decorative lines) -h, --help show this help ` @@ -43,7 +43,7 @@ async function cmdInfer(prompt: string, flags: Record) { } if (!config.agentPrivateKey) { - console.error('AGENT_PRIVATE_KEY not set (fund a Tempo testnet wallet)') + console.error(`AGENT_PRIVATE_KEY not set (fund a Tempo ${isMainnet ? 'MAINNET wallet with real USDC.e' : 'testnet wallet'})`) process.exit(2) } @@ -62,7 +62,7 @@ async function cmdInfer(prompt: string, flags: Record) { if (!json) console.log('\n── pre-pay attestation gate ──\n' + formatReport(r)) }, onUnit: (n, paid) => { - if (!json) process.stdout.write(`\r 💸 [units paid: ${n} | ${paid} pathUSD]`) + if (!json) process.stdout.write(`\r 💸 [units paid: ${n} | ${paid} ${tempoChain.currencyName}]`) }, }) @@ -87,7 +87,7 @@ async function cmdInfer(prompt: string, flags: Record) { console.log('\n── post-pay receipt verification ──\n' + formatReport(res.attestation.postPay)) } console.log('\n🔓 decrypted answer (plaintext only ever seen by you + the attested enclave):\n' + res.answer) - console.log(`\n(${res.units} units · ${res.paid} pathUSD)`) + console.log(`\n(${res.units} units · ${res.paid} ${tempoChain.currencyName})`) } catch (e) { if (e instanceof AttestationError) { console.error('⛔ ' + e.message + '\n' + formatReport(e.report)) diff --git a/evidence.md b/evidence.md index 47da69e6..7364a1a7 100644 --- a/evidence.md +++ b/evidence.md @@ -149,6 +149,8 @@ content. returns exactly one event — the OPEN — and no close. The production server runs close **best-effort** (no payee settlement key set), so the channel deposit reclaims on **timeout** rather than settling on-chain. Vouchers are off-chain, so this is the expected MPP shape, not a failure. + > **Update 2026-07-14:** cooperative close HAS now been demonstrated on-chain — see the + > staging run section below. It remains disabled in prod until `TEMPO_RECIPIENT_PRIVATE_KEY` is set. ## 6. Payer state (read-only, public RPC) @@ -210,3 +212,37 @@ verification, paid 0.0002 pathUSD per chunk, and decrypted successfully. | 3 | `hex-private-key` | Wallet key exposure → security review | [`0x23d1…dee2`](https://explore.testnet.tempo.xyz/tx/0x23d1cabe4dfb85956fcc050d3533d3be1fa2a70850723c2a5bf7217ef8b7dee2) | All three: 1 unit · 0.0002 pathUSD · total 0.0006 pathUSD on Tempo Moderato testnet. + +## Staging run — mainnet-hardening branch (2026-07-14) + +Full paid E2E on Tempo Moderato testnet, run against `feat/mainnet-hardening` (PR #3) +with the real Phala enclave (`mode=tdx-live`, `ready:true`) and — for the first time — +a **payee settlement key configured**. + +``` +detect → pre-pay DCAP verify ✓ → encrypt → 402 → MPP session → +1 chunk streamed & charged (0.0002 pathUSD) → post-pay receipt ✓ → decrypt ✓ +``` + +**First on-chain cooperative close.** With `TEMPO_RECIPIENT_PRIVATE_KEY` set, the server +settled the channel as the payee instead of leaving the deposit to timeout-reclaim: + +``` +payee (throwaway) 0x8e790205001a263df321d84c4b1c80daa1e32dab +nonce 0 → 1 (exactly one outgoing tx: the close) +pathUSD balanceOf 1,000,000 → 999,999.999611 + = +0.0002 settled revenue − ~0.000589 close-tx fee +``` + +Anyone can verify: the address above has a single outgoing transaction on +https://explore.testnet.tempo.xyz — the cooperative close. + +> ⚠️ **Economics finding:** at `PRICE_PER_UNIT=0.0002` and `CHUNK_COUNT=1`, the close fee +> (~0.0006) exceeds per-session revenue — every 1-chunk session settles at a net LOSS +> (≈ −0.0004). Before mainnet: raise the price, meter more chunks per session, or batch +> settlement (mppx `settlementSchedule`). Tracked in the README TODO. + +Also verified in the same session (see PR #3 for the full matrix): mainnet boot guards +fail closed on demo recipient / placeholder or short `MPP_SECRET_KEY` / payee-key mismatch; +unknown `NETWORK` values crash the boot instead of silently running testnet; `NETWORK=mainnet` +serves chainId 4217 / USDC.e consistently on `/`, `/llms.txt`, `/SKILL.md`, `/openapi.json`. diff --git a/mcp/server.ts b/mcp/server.ts index 40f1a554..be7d83d6 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -12,14 +12,14 @@ // • private_inference(prompt, model?) — verify → encrypt → pay-per-chunk → decrypt // // Wire into an MCP client (e.g. Claude) as a stdio server, with AGENT_PRIVATE_KEY (a -// funded Tempo testnet wallet) in the environment: +// funded Tempo wallet on the server's network) in the environment: // { "command": "npx", "args": ["tsx", "/abs/path/tempRouter/mcp/server.ts"] } import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import { TempRouter, detectSensitive, formatReport, AttestationError } from '../sdk/src/index.js' -import { config } from '../src/config.js' +import { config, tempoChain, isMainnet } from '../src/config.js' const ok = (t: string) => ({ content: [{ type: 'text' as const, text: t }] }) const err = (t: string) => ({ content: [{ type: 'text' as const, text: t }], isError: true }) @@ -36,7 +36,7 @@ function makeClient(serverUrl?: string) { }) } -const NO_WALLET = 'AGENT_PRIVATE_KEY not set — fund a Tempo testnet wallet and set it in this MCP server\'s environment.' +const NO_WALLET = `AGENT_PRIVATE_KEY not set — fund a Tempo ${isMainnet ? 'mainnet wallet (real USDC.e)' : 'testnet wallet'} and set it in this MCP server's environment.` const server = new McpServer({ name: 'temprouter', version: '0.1.0' }) @@ -77,7 +77,7 @@ server.registerTool( { title: 'Confidential inference (verify → encrypt → pay → decrypt)', description: - 'Run a prompt through tempRouter\'s confidential lane: verify a real Phala Intel TDX enclave with Intel DCAP, encrypt the prompt to the enclave key, pay per response-chunk in pathUSD on Tempo, and decrypt the answer locally. A failed attestation pays NOTHING. Use for any prompt containing secrets, credentials, or PII. The enclave runs an OSS model (gpt-oss:20b) — use it for confidential payloads, not as a frontier-model replacement.', + 'Run a prompt through tempRouter\'s confidential lane: verify a real Phala Intel TDX enclave with Intel DCAP, encrypt the prompt to the enclave key, pay per response-chunk in the network stablecoin (pathUSD testnet / USDC.e mainnet) on Tempo, and decrypt the answer locally. A failed attestation pays NOTHING. Use for any prompt containing secrets, credentials, or PII. The enclave runs an OSS model (gpt-oss:20b) — use it for confidential payloads, not as a frontier-model replacement.', inputSchema: { prompt: z.string().describe('The (possibly sensitive) prompt to run privately.'), model: z.string().optional().describe('Model id (default nosana:gpt-oss:20b).'), @@ -88,7 +88,7 @@ server.registerTool( if (!config.agentPrivateKey) return err(NO_WALLET) try { const res = await makeClient(server).infer(prompt, { model }) - const footer = `\n\n— verified TDX enclave · ${res.units} chunk(s) · ${res.paid} pathUSD${res.attestation.postPay?.ok ? ' · receipt ✓' : ''}` + const footer = `\n\n— verified TDX enclave · ${res.units} chunk(s) · ${res.paid} ${tempoChain.currencyName}${res.attestation.postPay?.ok ? ' · receipt ✓' : ''}` return ok(res.answer + footer) } catch (e) { if (e instanceof AttestationError) diff --git a/public/index.html b/public/index.html index 187407f4..f8684001 100644 --- a/public/index.html +++ b/public/index.html @@ -6,7 +6,7 @@ tempRouter — payable, end-to-end-encrypted LLM inference on MPP - + @@ -16,21 +16,21 @@ - + - + @@ -214,7 +214,7 @@
Payable inference · MPP · Tempo · Phala Intel TDX

Pay an LLM only after you
prove it never saw your prompt.

-

tempRouter is a payable inference endpoint on MPP. Your agent verifies, with Intel DCAP, that the prompt runs inside a real Intel TDX enclave that can't read it — then pays per response-chunk in pathUSD on Tempo. End-to-end encrypted; the relay is blind. Ships as an SDK, CLI, MCP server, and agent skill.

+

tempRouter is a payable inference endpoint on MPP. Your agent verifies, with Intel DCAP, that the prompt runs inside a real Intel TDX enclave that can't read it — then pays per response-chunk in USD stablecoin on Tempo (pathUSD testnet · USDC.e mainnet). End-to-end encrypted; the relay is blind. Ships as an SDK, CLI, MCP server, and agent skill.

no API key · no signup verify-before-pay @@ -232,10 +232,10 @@

Pay an LLM only after you
prove it never saw your prompt

# the whole handshake — verify, then pay
 $ agent → POST /v1/chat/completions/stream
-  ← 402 Payment Required   (mppx session · pathUSD · Tempo Moderato)
+  ← 402 Payment Required   (mppx session · USD stablecoin · Tempo)
 $ verify enclave (Intel DCAP)✅ INTEL-TDX-PHALA · quote authentic · key bound
 $ encrypt prompt → enclave key   (Arcium RescueCipher + X25519)
-$ pay + retry                    → 200 OK · SSE ciphertext · 💸 0.0002 pathUSD
+$ pay + retry                    → 200 OK · SSE ciphertext · 💸 0.0002 stablecoin
 $ decrypt locally                → plaintext — seen only by you + the enclave
@@ -249,7 +249,7 @@

Payment was the easy half.

Two-phase attestation

Pre-pay: genuine TDX + UpToDate TCB + key binding + code measurement. Post-pay receipt: enclave ed25519 over the exact ciphertext. Every check is auditable on the wire.

Blind relay

The prompt is E2E-encrypted (Arcium RescueCipher + X25519) to a key that only exists inside the enclave. tempRouter holds no key and never sees plaintext — it forwards ciphertext.

Client-side secret policy

Your agent detects API keys, private keys, JWTs and PII locally and forces the private lane — the only correct place, since a blind relay can't classify what it can't read.

-

Per-chunk MPP metering

An MPP session over SSE: pay per response-chunk in pathUSD, settled in ~2 on-chain txs. One charge per inference by default; multi-chunk metering supported.

+

Per-chunk MPP metering

An MPP session over SSE: pay per response-chunk in the network's USD stablecoin, settled in ~2 on-chain txs. One charge per inference by default; multi-chunk metering supported.

Discoverable + payable

OpenAI-shaped request, MPP 402 flow, and a machine-readable discovery doc so any agent or registry (mpp.dev/services, MPPScan) can find and pay the endpoint.

@@ -304,7 +304,7 @@

Don't use tempRouter when:

Verified on-chain

Real prompts, real receipts.

-

Every run settles on Tempo testnet — inspect the transaction, verify the ciphertext hash, and confirm the payment yourself.

+

These recorded runs settled on Tempo Moderato testnet — inspect the transaction, verify the ciphertext hash, and confirm the payment yourself.

🔒 detected: openai-key
@@ -334,7 +334,7 @@

How an agent uses it.

  • Detect — your agent's policy flags a secret/PII payload and forces the private lane. Non-sensitive prompts go to any public model (out of scope).
  • VerifyGET /tee/attestation, run Intel DCAP on the quote. Fail → sign zero vouchers, refuse to pay.
  • Encrypt — encrypt the prompt to the enclave's X25519 key. Plaintext never leaves the agent.
  • -
  • PayPOST /v1/chat/completions/stream → 402 → open an MPP session on Tempo, pay per response-chunk in pathUSD.
  • +
  • PayPOST /v1/chat/completions/stream → 402 → open an MPP session on Tempo, pay per response-chunk in the network's stablecoin.
  • Decrypt — reassemble the ciphertext chunks and decrypt locally. Only the agent and the attested enclave ever see plaintext.
  • @@ -374,8 +374,8 @@

    Drop it into your agent.

    Speak MPP, get inference.

    All four surfaces speak the same wire protocol. Any MPP-speaking agent can hit it directly — the 402 handshake handles pay → retry.

    -
    POST/v1/chat/completions/stream0.0002 pathUSD / response-chunk
    -
    Attested confidential inference, metered per response-chunk over an MPP session (SSE). MPP tempo intent session, currency pathUSD, network Tempo Moderato (chain 42431).
    +
    POST/v1/chat/completions/stream0.0002 stablecoin / response-chunk
    +
    Attested confidential inference, metered per response-chunk over an MPP session (SSE). MPP tempo intent session; network & currency per deployment (Moderato testnet · pathUSD, chain 42431 — or mainnet · USDC.e, chain 4217) — see GET / or /openapi.json.
    GET/tee/attestationfree · verify before you pay
    @@ -407,7 +407,7 @@

    What your agent actually checks.

    Measurement. mrtd/rtmr compared to the boot-advertised value (soft-pin); strict-pin opt-in.
    Fail-closed. Any failure → zero vouchers signed, no payment, by construction.
    -

    Honest scope: the private lane runs an OSS model (gpt-oss:20b) inside the enclave — use it for confidential payloads, not as a frontier-model replacement. Verification covers DCAP cert-chain + key binding + enclave signature; code-measurement pinning is opt-in. The enclave key is attached to the MPP session as a settlement label, not an enforced gate. Currently Tempo Moderato testnet, pathUSD.

    +

    Honest scope: the private lane runs an OSS model (gpt-oss:20b) inside the enclave — use it for confidential payloads, not as a frontier-model replacement. Verification covers DCAP cert-chain + key binding + enclave signature; code-measurement pinning is opt-in. The enclave key is attached to the MPP session as a settlement label, not an enforced gate. Network per deployment — Moderato testnet (pathUSD) or mainnet (USDC.e, real money); GET / reports the live chain.

    @@ -419,7 +419,8 @@

    What your agent actually checks.

    /llms.txt /openapi.json mpp.dev/services - Tempo explorer + Tempo explorer + Tempo testnet explorer SolRouter
    Router Labs · built on MPP + Tempo + Phala Intel TDX.
    diff --git a/public/llms.txt b/public/llms.txt index d9631a86..2ae00451 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,9 +1,12 @@ # tempRouter -> A payable, end-to-end-encrypted LLM inference endpoint on MPP — pay per response-chunk in pathUSD on Tempo (Moderato testnet). +> A payable, end-to-end-encrypted LLM inference endpoint on MPP — pay per response-chunk +> in a USD stablecoin on Tempo (pathUSD on Moderato testnet · USDC.e on mainnet). +> This file is the static snapshot; GET /llms.txt on the live endpoint states the +> deployment's actual network and is authoritative. tempRouter is an MPP-payable endpoint for **confidential** LLM inference. An agent -pays per response-chunk in pathUSD over an MPP session — but only after it +pays per response-chunk in the network's stablecoin over an MPP session — but only after it cryptographically verifies (Intel DCAP) that inference runs inside a real Phala Intel TDX enclave that cannot read the prompt. The relay is blind: it forwards only ciphertext and never holds a decryption key. @@ -18,13 +21,15 @@ OSS model (gpt-oss:20b) in the enclave. 1. GET /tee/attestation → verify the TDX quote (DCAP) BEFORE paying. 2. Encrypt the prompt to the enclave key (Arcium RescueCipher + X25519). 3. POST /v1/chat/completions/stream with {encryptedPrompt, model}. - - 402 → open an MPP session (Tempo Moderato, chain 42431), pay per chunk. + - 402 → open an MPP session on the deployment's Tempo network (chain 42431 testnet · 4217 mainnet), pay per chunk. - 200 → SSE stream of ciphertext chunks (one MPP voucher tick each) + a final receipt frame. 4. Reassemble + decrypt locally. ## Payment -- Network: Tempo Moderato testnet (chain 42431), currency pathUSD (6 decimals). Testnet only — not mainnet, no real money. -- Price: 0.0002 pathUSD per response-chunk. +- Network: per deployment — Tempo Moderato testnet (chain 42431, pathUSD, no real money) + or Tempo mainnet (chain 4217, USDC.e, REAL MONEY). Both use 6 decimals. Check + GET / → network.chainId; the 402 challenge is authoritative. +- Price: 0.0002 stablecoin units per response-chunk (see the live /llms.txt or /openapi.json). - Model: MPP `tempo` session intent, unitType `response-chunk`. - Discovery: GET /openapi.json (x-service-info + x-payment-info). - Entrypoint: GET /SKILL.md — agent skill (install: `npx skills add Router-Labs/tempRouter`). diff --git a/render.yaml b/render.yaml index 3f9c36cc..b38fea21 100644 --- a/render.yaml +++ b/render.yaml @@ -15,13 +15,18 @@ services: # HMAC secret for binding/verifying MPP challenges. NOT a chain key. - key: MPP_SECRET_KEY generateValue: true - # Network: "testnet" (Moderato, chain 42431, pathUSD) or "mainnet" (Allegro, chain 4217, USDC.e) + # Network: "testnet" (Moderato, chain 42431, pathUSD) or "mainnet" (Allegro, chain 4217, USDC.e). + # Mainnet cutover is a dashboard operation: set NETWORK=mainnet there, and note the + # server refuses to boot on mainnet with the demo recipient or dev MPP secret + # (see .env.example "Mainnet notes"). - key: NETWORK value: testnet - # Earnings address (where payments settle). Set to your wallet address. - # Testnet: pathUSD. Mainnet: USDC.e. + # Earnings address (where payments settle). Testnet: pathUSD. Mainnet: real USDC.e. - key: TEMPO_RECIPIENT - value: "0xa726a1CD723409074DF9108A2187cfA19899aCF8" + value: "0x4d881B146b2199dfC2598FB618eC80410B9aBD46" + # TEMPO_RECIPIENT_PRIVATE_KEY (payee key for cooperative close; same address as + # TEMPO_RECIPIENT, funded with a little USDC.e for close-tx fees) is a SECRET — + # set it only in the Render dashboard, never in this file. - key: PRICE_PER_UNIT value: "0.0002" - key: CHUNK_COUNT diff --git a/skills/temprouter/SKILL.md b/skills/temprouter/SKILL.md index 6ace8f1c..4584414f 100644 --- a/skills/temprouter/SKILL.md +++ b/skills/temprouter/SKILL.md @@ -1,6 +1,6 @@ --- name: temprouter -description: Call tempRouter — a payable, end-to-end-encrypted LLM inference endpoint on MPP (Tempo). Pay per inference in pathUSD stablecoin AFTER your agent verifies (Intel DCAP) that the prompt runs inside a real Phala Intel TDX enclave that can't read it. Use when a prompt holds secrets/PII (API keys sk-…, private keys 0x…, seed phrases, JWTs, passwords, customer data) that must never reach a public model host. Verify-before-pay: a failed attestation pays nothing; the relay is blind (ciphertext only). +description: Call tempRouter — a payable, end-to-end-encrypted LLM inference endpoint on MPP (Tempo). Pay per response-chunk in the network's USD stablecoin (pathUSD on Moderato testnet · USDC.e on mainnet — check GET / → network.chainId) AFTER your agent verifies (Intel DCAP) that the prompt runs inside a real Phala Intel TDX enclave that can't read it. Use when a prompt holds secrets/PII (API keys sk-…, private keys 0x…, seed phrases, JWTs, passwords, customer data) that must never reach a public model host. Verify-before-pay: a failed attestation pays nothing; the relay is blind (ciphertext only). --- # tempRouter — payable confidential inference (entrypoint) @@ -27,7 +27,9 @@ just confirms the enclave is real. Good for kicking the tires. temprouter infer "sanitize this key before rotation: sk-proj-abc123" ``` Runs the full lane: detect → verify → encrypt → pay → stream → decrypt. Requires a -funded Tempo testnet wallet (`account` env var or `--account` flag). +funded Tempo wallet on the server's network (`AGENT_PRIVATE_KEY` env var) — +check `GET /` → `network.chainId` first: `42431` = Moderato testnet (pathUSD, free faucet +money) · `4217` = mainnet (USDC.e, **real money**). Set `NETWORK=mainnet` client-side to match. ### 3. Detect sensitivity (client-side, no network) ```bash @@ -40,7 +42,8 @@ zero cost. Use as a pre-filter in your agent pipeline. --- ## What it is -A **payable inference endpoint on MPP**: your agent pays per response-chunk in **pathUSD** +A **payable inference endpoint on MPP**: your agent pays per response-chunk in the +network's **USD stablecoin** (pathUSD on Moderato testnet · USDC.e on mainnet) on Tempo for an LLM answer that is **end-to-end encrypted** to a real **Phala Intel TDX enclave**. Before any money moves, the agent runs **Intel DCAP** on the live enclave quote; a failed check signs **zero vouchers**. tempRouter is a **blind relay** — it forwards only @@ -67,7 +70,7 @@ import { TempRouter, detectSensitive } from '@temprouter/sdk' const client = new TempRouter({ serverUrl: 'https://temprouter.onrender.com', - account: process.env.AGENT_PRIVATE_KEY as `0x${string}`, // funded Tempo testnet wallet + account: process.env.AGENT_PRIVATE_KEY as `0x${string}`, // funded Tempo wallet — match the server's network (NETWORK=mainnet ⇒ USDC.e) }) if (detectSensitive(prompt).sensitive) { const { answer, units, paid } = await client.infer(prompt) // throws AttestationError on a failed gate @@ -90,13 +93,17 @@ temprouter detect "" # is it sensitive? | Environment | Cost | Notes | |---|---|---| -| **Tempo testnet** (chain `42431`) | **Free** | pathUSD is testnet-only; no real money. Use for dev/testing. | -| **Mainnet** (planned) | **~0.0002 USDC per chunk** | 1 chunk ≈ 1 response segment. A typical short answer = 1–3 chunks. | +| **Tempo Moderato testnet** (chain `42431`) | **Free** (faucet pathUSD) | No real money. Use for dev/testing. | +| **Tempo mainnet** (chain `4217`) | **0.0002 USDC.e per chunk — real money** | 1 chunk ≈ 1 response segment. A typical short answer = 1–3 chunks. | +- Which network a deployment charges is its config — **verify live** via `GET /` → + `network.chainId`, `GET /openapi.json` → `x-payment-info`, or the served `/SKILL.md` + banner. The 402 challenge is authoritative. - There is **no subscription, no API key fee, no minimum**. You pay per chunk via an MPP session (SSE stream), settled in ~2 on-chain transactions. - The `units` field in the response tells you exactly how many chunks were charged. -- Testnet pathUSD can be obtained from the Tempo testnet faucet. +- Testnet pathUSD can be obtained from the Tempo testnet faucet; mainnet needs a wallet + funded with real USDC.e. ## Common errors & troubleshooting @@ -111,9 +118,9 @@ check `temprouter verify` output for the specific failure (cert chain, TCB level measurement mismatch). ### `InsufficientBalanceError` -Your Tempo wallet doesn't have enough pathUSD to cover the first chunk voucher. -- **Testnet:** Request funds from the Tempo testnet faucet. -- **Mainnet:** Fund your wallet with USDC on the Tempo chain. +Your Tempo wallet doesn't have enough of the network's stablecoin to cover the first chunk voucher. +- **Testnet:** Request pathUSD from the Tempo testnet faucet. +- **Mainnet:** Fund your wallet with real USDC.e on the Tempo chain. ### `EnclaveMismatchError: measurement mismatch` The enclave's measured measurement (`mrtd`) doesn't match `EXPECTED_MEASUREMENT` in your @@ -144,7 +151,9 @@ attestation. This indicates a potential MITM or enclave swap mid-session. - Verification = DCAP cert-chain + key binding + enclave ed25519 signature. **Code-measurement pinning is opt-in** (`EXPECTED_MEASUREMENT`); default is soft-pin ("same enclave the service advertised"), not "trusted reproducible build." -- Currently **Tempo Moderato testnet** (chain `42431`), currency **pathUSD**; one charge per inference. +- Network is deployment config — **Moderato testnet** (chain `42431`, pathUSD) or **mainnet** + (chain `4217`, USDC.e, real money). Check `GET /` → `network.chainId` before funding a wallet; + one charge per inference by default. ## Install this skill ```bash diff --git a/src/agent.ts b/src/agent.ts index c65d8e63..6e43a610 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -3,7 +3,7 @@ // Fail-closed: a failed attestation gate signs ZERO vouchers (ADR-0002). import { TempRouter, detectSensitive, formatReport, AttestationError } from '../sdk/src/index.js' -import { config } from './config.js' +import { config, tempoChain, isMainnet } from './config.js' const MODEL = process.env.MODEL ?? 'nosana:gpt-oss:20b' // Default demo prompt carries a (fake) leaked credential → forces the private lane. @@ -24,7 +24,11 @@ async function main() { } if (!config.agentPrivateKey) { - console.error('\nAGENT_PRIVATE_KEY not set — fund a Tempo testnet key to run the paid stream (faucet: https://explore.testnet.tempo.xyz).') + console.error( + isMainnet + ? '\nAGENT_PRIVATE_KEY not set — fund a Tempo MAINNET wallet with real USDC.e to run the paid stream.' + : '\nAGENT_PRIVATE_KEY not set — fund a Tempo testnet key to run the paid stream (faucet: https://explore.testnet.tempo.xyz).', + ) process.exit(2) } @@ -40,7 +44,7 @@ async function main() { const res = await client.infer(PROMPT, { model: MODEL, onVerify: (r) => console.log('\n── pre-pay attestation gate ──\n' + formatReport(r)), - onUnit: (n, paid) => process.stdout.write(`\r 💸 [units paid: ${n} | ${paid} pathUSD]`), + onUnit: (n, paid) => process.stdout.write(`\r 💸 [units paid: ${n} | ${paid} ${tempoChain.currencyName}]`), }) if (res.attestation.postPay) console.log('\n── post-pay receipt verification ──\n' + formatReport(res.attestation.postPay)) console.log('\n🔓 decrypted answer (plaintext only ever seen by you + the attested enclave):\n' + res.answer) diff --git a/src/config.ts b/src/config.ts index b2a38079..17f306d8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,18 +8,23 @@ try { /* no .env — use process env / defaults */ } +// Dev-only fallbacks. Fine on testnet; the server refuses to boot on mainnet with +// either still in effect (see the mainnet safety gate in server.ts). +export const DEV_SECRET_KEY = 'dev-insecure-secret-change-me' +export const DEV_RECIPIENT = '0xa726a1CD723409074DF9108A2187cfA19899aCF8' as `0x${string}` + export const config = { port: Number(process.env.PORT ?? 8402), - secretKey: process.env.MPP_SECRET_KEY ?? 'dev-insecure-secret-change-me', - recipient: (process.env.TEMPO_RECIPIENT ?? - '0xa726a1CD723409074DF9108A2187cfA19899aCF8') as `0x${string}`, + secretKey: process.env.MPP_SECRET_KEY ?? DEV_SECRET_KEY, + recipient: (process.env.TEMPO_RECIPIENT ?? DEV_RECIPIENT).trim() as `0x${string}`, // Pricing (decimal token units; TIP-20 stablecoins use 6 decimals). pricePerUnit: process.env.PRICE_PER_UNIT ?? '0.0002', // per response-chunk (session/SSE) // How many SSE chunks the blind relay slices the enclave's single ciphertext // blob into — each chunk = one MPP voucher tick, so the payer's balance visibly // ticks per chunk. Multi-unit metering is fixed + verified end-to-end (ADR-0003). - // Default 1 = one charge per inference; set CHUNK_COUNT>1 to meter a response in N ticks. + // Default 1 = one charge per inference; CHUNK_COUNT>1 meters a response in up to N ticks + // (short ciphertexts produce fewer chunks — see chunk() in upstream.ts). chunkCount: Number(process.env.CHUNK_COUNT ?? 1), // Real Phala Intel TDX enclave (the private upstream). When unset → stub mode. @@ -52,7 +57,7 @@ export const tempoMainnet = { rpcUrl: 'https://rpc.tempo.xyz', explorer: 'https://explore.tempo.xyz', currency: '0x20C000000000000000000000b9537d11c60E8b50' as `0x${string}`, // USDC.e - currencyName: 'USDC', + currencyName: 'USDC.e', // on-chain symbol() of the bridged-USDC TIP-20 predeploy decimals: 6, } as const @@ -66,7 +71,12 @@ export const tempoTestnet = { } as const // Active chain — select via NETWORK env ("mainnet" | "testnet"), default testnet. -export const isMainnet = process.env.NETWORK === 'mainnet' +// Normalized (trim + lowercase) and CLOSED: any other value is a hard error instead of +// a silent testnet fallback — a typo'd mainnet flip must fail the deploy, not no-op. +const NETWORK = (process.env.NETWORK ?? 'testnet').trim().toLowerCase() || 'testnet' +if (NETWORK !== 'mainnet' && NETWORK !== 'testnet') + throw new Error(`NETWORK must be "mainnet" or "testnet", got ${JSON.stringify(process.env.NETWORK)}`) +export const isMainnet = NETWORK === 'mainnet' export const tempoChain = isMainnet ? tempoMainnet : tempoTestnet // Derived: where to fetch the enclave attestation + public key (blind passthrough). diff --git a/src/server.ts b/src/server.ts index 77708a72..b9377df5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,9 +14,10 @@ import { cors } from 'hono/cors' import { Mppx, tempo, Store } from 'mppx/server' import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' +import { isAddress } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { z } from 'zod' -import { config, resolveMode, tempoChain, isMainnet, type PrivacyMode } from './config.js' +import { config, resolveMode, tempoChain, isMainnet, DEV_SECRET_KEY, DEV_RECIPIENT, type PrivacyMode } from './config.js' import { teeProcess, fetchAttestation, fetchTeePublicKeyRaw, chunk } from './upstream.js' import { log } from './logger.js' @@ -41,8 +42,41 @@ const ContentBody = z.object({ // timeout (ADR-0003). NB: do NOT set `feePayer: true` — that makes the server sponsor // the PAYER's channel-open tx, which trips Tempo's sponsor maxFeePerGas policy. const settlementAccount = config.recipientPrivateKey ? privateKeyToAccount(config.recipientPrivateKey) : undefined -if (settlementAccount && config.recipient.toLowerCase() !== settlementAccount.address.toLowerCase()) - log.warn('recipient_mismatch', { recipient: config.recipient, settlementAccount: settlementAccount.address }) +const recipientMismatch = settlementAccount + ? config.recipient.toLowerCase() !== settlementAccount.address.toLowerCase() + : false +if (recipientMismatch) + log.warn('recipient_mismatch', { recipient: config.recipient, settlementAccount: settlementAccount?.address }) + +// ── Mainnet safety gate — fail closed, real USDC.e must never ride dev defaults ── +// On testnet the fallbacks are a convenience; on mainnet the same fallbacks would +// settle real money to the demo address / verify challenges with a public secret, +// and a recipient/key mismatch makes every cooperative close fail the on-chain +// payee check (mppx Settlement) — silently, per request. Refuse to boot instead. +if (isMainnet) { + const fatal = (reason: string, extra: Record = {}) => { + log.error('mainnet_guard', { reason, ...extra }) + process.exit(1) + } + if (!isAddress(config.recipient)) + fatal('TEMPO_RECIPIENT is not a valid address — set your mainnet earnings address', { recipient: config.recipient }) + if (config.recipient.toLowerCase() === DEV_RECIPIENT.toLowerCase()) + fatal('TEMPO_RECIPIENT is the built-in demo address — set your own mainnet earnings address') + // Known public placeholders (code default + the .env.example template) and anything + // short enough to brute-force: challenge provenance is HMAC-SHA256 over this secret. + const KNOWN_SECRETS = [DEV_SECRET_KEY, 'change-me-to-a-long-random-string'] + if (KNOWN_SECRETS.includes(config.secretKey) || config.secretKey.trim().length < 24) + fatal('MPP_SECRET_KEY is a known placeholder or shorter than 24 chars — set a long random secret') + if (recipientMismatch) + fatal('TEMPO_RECIPIENT_PRIVATE_KEY address does not match TEMPO_RECIPIENT — every cooperative close would fail on-chain', { + recipient: config.recipient, + settlementAccount: settlementAccount?.address, + }) + if (!settlementAccount) + log.warn('mainnet_no_payee_key', { + hint: 'cooperative close disabled — unsettled revenue reclaims to payers on channel timeout (ADR-0003); set TEMPO_RECIPIENT_PRIVATE_KEY', + }) +} const mppx = Mppx.create({ methods: [ @@ -237,13 +271,52 @@ app.get('/openapi.json', (c) => { }) }) -app.get('/llms.txt', (c) => { - try { - return c.text(readFileSync(new URL('../public/llms.txt', import.meta.url), 'utf8')) - } catch { - return c.text('# tempRouter\nAttestation-gated private AI inference paid per response-chunk in pathUSD on Tempo.\n') - } -}) +// Agent-readable context, generated per-network so discovery can never contradict the +// 402 challenge / openapi.json. (public/llms.txt is a static snapshot for the repo/Pages; +// this route — not the file — is what paying agents read.) +const NETWORK_LABEL = isMainnet ? 'Tempo mainnet' : 'Tempo Moderato testnet' +const MONEY_LINE = isMainnet + ? 'REAL MONEY — every response-chunk settles in USDC.e on mainnet.' + : 'Testnet only — not mainnet, no real money.' +app.get('/llms.txt', (c) => + c.text(`# tempRouter + +> A payable, end-to-end-encrypted LLM inference endpoint on MPP — pay per response-chunk in ${tempoChain.currencyName} on ${NETWORK_LABEL}. + +tempRouter is an MPP-payable endpoint for **confidential** LLM inference. An agent +pays per response-chunk in ${tempoChain.currencyName} over an MPP session — but only after it +cryptographically verifies (Intel DCAP) that inference runs inside a real Phala +Intel TDX enclave that cannot read the prompt. The relay is blind: it forwards +only ciphertext and never holds a decryption key. + +## When to use +Use tempRouter when the prompt contains secrets, credentials, PII, or other +confidential data that must never reach a third-party model host. For non-sensitive +work, use a normal (cheaper / more capable) public model — the private lane runs an +OSS model (gpt-oss:20b) in the enclave. + +## Flow (agent) +1. GET /tee/attestation → verify the TDX quote (DCAP) BEFORE paying. +2. Encrypt the prompt to the enclave key (Arcium RescueCipher + X25519). +3. POST /v1/chat/completions/stream with {encryptedPrompt, model}. + - 402 → open an MPP session (${NETWORK_LABEL}, chain ${tempoChain.chainId}), pay per chunk. + - 200 → SSE stream of ciphertext chunks (one MPP voucher tick each) + a final receipt frame. +4. Reassemble + decrypt locally. + +## Payment +- Network: ${NETWORK_LABEL} (chain ${tempoChain.chainId}), currency ${tempoChain.currencyName} (${tempoChain.decimals} decimals). ${MONEY_LINE} +- Price: ${config.pricePerUnit} ${tempoChain.currencyName} per response-chunk. +- Model: MPP \`tempo\` session intent, unitType \`response-chunk\`. +- Discovery: GET /openapi.json (x-service-info + x-payment-info) — the 402 challenge is authoritative. +- Entrypoint: GET /SKILL.md — agent skill (install: \`npx skills add Router-Labs/tempRouter\`). + +## Verified runs (historical, Tempo Moderato testnet) +Three recorded testnet-era runs (each tripping a different detector) are linked on the +landing page (#verified): openai-key, email/PII, and hex-private-key — each settled at +0.0002 pathUSD on Moderato. The CURRENT network/currency is stated under Payment above. +The enclave key is attached to the MPP session as a settlement label, not an enforced gate. +`), +) // Crawler/agent discoverability: robots + /.well-known aliases for the discovery surfaces. app.get('/robots.txt', (c) => { @@ -258,13 +331,27 @@ app.get('/.well-known/openapi.json', (c) => c.redirect('/openapi.json')) app.get('/.well-known/skill.md', (c) => c.redirect('/SKILL.md')) // ── Agent skill entrypoint (installable: `npx skills add Router-Labs/tempRouter`) ── +// The repo file is network-neutral; stamp the live network under the H1 so an agent +// reading the served copy knows exactly which chain/currency THIS deployment charges. +const SKILL_HEADING = '# tempRouter — payable confidential inference (entrypoint)' +const SKILL_NET_BANNER = `> **Live network:** ${NETWORK_LABEL} (chain \`${tempoChain.chainId}\`), currency **${tempoChain.currencyName}**. ${MONEY_LINE}` const serveSkill = (c: any) => { try { - return c.text(readFileSync(new URL('../skills/temprouter/SKILL.md', import.meta.url), 'utf8'), 200, { + const raw = readFileSync(new URL('../skills/temprouter/SKILL.md', import.meta.url), 'utf8') + // Prefer stamping under the H1; if the heading ever drifts, fall back to after the + // frontmatter, then to the very top — the banner must never silently disappear. + let stamped: string + if (raw.includes(SKILL_HEADING)) { + stamped = raw.replace(SKILL_HEADING, `${SKILL_HEADING}\n\n${SKILL_NET_BANNER}`) + } else { + const fm = raw.match(/^---\n[\s\S]*?\n---\n/) + stamped = fm ? `${fm[0]}\n${SKILL_NET_BANNER}\n${raw.slice(fm[0].length)}` : `${SKILL_NET_BANNER}\n\n${raw}` + } + return c.text(stamped, 200, { 'content-type': 'text/markdown; charset=utf-8', }) } catch { - return c.text('# tempRouter\nPayable, E2E-encrypted LLM inference on MPP. See /llms.txt + /openapi.json.\n') + return c.text(`# tempRouter\nPayable, E2E-encrypted LLM inference on MPP, per response-chunk in ${tempoChain.currencyName}. See /llms.txt + /openapi.json.\n`) } } // Canonical path + the common variants a human or agent might try. @@ -273,7 +360,14 @@ for (const p of ['/SKILL.md', '/skill.md', '/skill', '/skills.md', '/skills']) a resolveMode().then((mode) => { MODE = mode const server = honoServe({ fetch: app.fetch, port: config.port }, (info) => { - log.info('listening', { port: info.port, mode: MODE, recipient: config.recipient }) + log.info('listening', { + port: info.port, + mode: MODE, + network: isMainnet ? 'mainnet' : 'testnet', + chainId: tempoChain.chainId, + currency: tempoChain.currencyName, + recipient: config.recipient, + }) if (MODE !== 'tdx-live') log.warn('no_live_tdx', { mode: MODE, hint: 'agents will (correctly) refuse to pay; set TEE_ENDPOINT for tdx-live' }) })