Concepts

Payment Pre-Auth Cache

Skip the 402 round-trip on subsequent calls to the same model with a short-lived signed receipt — disabled on Solana

Status: specification only. Pre-auth is not implemented in the gateway today. This page documents the protocol design Solvela will adopt when the optimization is wired in (slot: crates/gateway/src/middleware/x402.rs). Track follow-up at the bottom of this page.

What it is

After a successful 402 → payment → 200 cycle for a given (model, payer, amount) tuple, the client may sign a pre-authorized payment payload for its next request to the same model. The gateway accepts the pre-signed payload directly, skipping the 402 round-trip and saving roughly 150–250ms of wallclock time per call.

Pre-auth is opt-in client-side, advisory server-side, and MUST be disabled on Solana — see Critical safety constraint.

Why it matters

Agentic loops typically issue 5–50 LLM calls per task: a planner call, a series of tool-use turns, a final consolidation. A 402 round-trip costs one signing operation client-side plus one verification + RPC call gateway-side — empirically 150–250ms end-to-end on Solana, comparable on EVM. Across 5–50 calls that compounds to 1–10 seconds of pure protocol overhead, on top of the LLM latency itself. Pre-auth collapses the protocol overhead to a single payload-signing operation per turn.

Protocol shape

Cycle 1 (cold) — normal 402 dance

The agent sends a request without payment. The gateway returns 402 with a PaymentRequired body. The agent signs a payment payload and resends with payment-signature: <base64>. The gateway verifies, proxies to the LLM provider, returns 200 with the response.

On the success response, the gateway includes a new header:

HTTP/1.1 200 OK
Content-Type: application/json
X-Solvela-PreAuth-Receipt: <base64-encoded-receipt>
X-Solvela-PreAuth-TTL-Ms: 60000
X-Solvela-PreAuth-Scope: model=auto;payer=GH...;amount_atomic=1234

X-Solvela-PreAuth-Receipt is a server-signed receipt that authorizes the bearer to make ONE additional call to the same (model, payer, amount) within the TTL window without going through 402.

Cycle 2 (warm) — opt-in pre-auth

The agent caches the receipt locally. On its next request to the same model, the agent sets:

POST /v1/chat/completions
X-Solvela-PreAuth: true
X-Solvela-PreAuth-Receipt: <base64-encoded-receipt>
Content-Type: application/json

{ "model": "auto", "messages": [...] }

The agent does NOT send a payment-signature header on this request — the receipt is the proof of payment.

Gateway accepts or rejects

The gateway verifies the receipt's signature, scope (model/payer/amount must match the new request), and freshness (within TTL). On success, the request is proxied normally and a fresh receipt is issued in the response. On any verification failure, the gateway responds with 402 Payment Required as if no pre-auth header were present — clients must handle this fail-open path.

Receipt rotation

Each successful cycle issues a fresh receipt. Receipts are NOT chainable — a single receipt authorizes exactly one subsequent call. This bounds the blast radius of a leaked receipt to one request's worth of inference cost.

Receipt structure

The base64-decoded receipt is a JSON object signed by the gateway's HMAC key:

{
  "v": 1,
  "iss": "solvela-gateway",
  "iat": 1730320000000,
  "exp": 1730320060000,
  "scope": {
    "model": "auto",
    "payer": "GHRkn...",
    "amount_atomic": "1234",
    "currency": "USDC"
  },
  "nonce": "01HG5...",
  "sig": "base64(hmac_sha256(secret, canonical_json(above)))"
}

The gateway's HMAC secret rotates per-deploy; receipts older than the most recent rotation become invalid. This is acceptable — clients fall back to the 402 path.

Header reference

HeaderDirectionRequired whenNotes
X-Solvela-PreAuthclient → serverOpting into pre-authValue: true (any other value treated as opt-out)
X-Solvela-PreAuth-Receiptboth directionsAlways when pre-auth in useBase64-encoded JSON, see structure above
X-Solvela-PreAuth-TTL-Msserver → clientAlways on receipt issuanceHint to client; authoritative TTL is in receipt's exp
X-Solvela-PreAuth-Scopeserver → clientAlways on receipt issuanceHuman-readable scope summary; (model, payer, amount_atomic)

Critical safety constraint

Pre-auth MUST be disabled on Solana.

Solana transactions are bound to a recent_blockhash that expires roughly 60–90 seconds after issuance — the cluster enforces this at the validator level (see Solana's transaction confirmation handling docs). Once expired, the cluster rejects the transaction outright; the signature can never land. That single chain property rules out pre-auth on Solana for two independent reasons:

1. The validity window is too short to amortize. Pre-auth's whole payoff is reusing a signed payload across many calls. With a 60–90 second ceiling, the receipt expires before a typical agentic loop finishes its planning phase. The client ends up re-signing on nearly every call anyway, paying the protocol overhead the optimization was supposed to eliminate.

2. There is no off-chain authorization escape hatch. EVM's EIP-3009 transferWithAuthorization lets the signer pick validAfter / validBefore themselves, and the contract's nonce mapping prevents on-chain replay — the gateway can hold the authorization and settle later within the signer's chosen window. Solana's SPL transfer_checked has no equivalent: every settlement must be re-signed against a fresh blockhash and is single-use by signature. A receipt that grants credit toward a "future" Solana call has no on-chain authorization the gateway can later submit on the user's behalf, which means honoring it would require the gateway to front funds against an unenforceable promise.

Either argument alone is sufficient. Together they make pre-auth structurally a no-fit for Solana under x402's trustless settlement invariant.

Solvela's implementation:

  1. The receipt's scope.currency indicates the payment chain.
  2. If scope.currency === "USDC-SPL" (Solana), the gateway rejects the pre-auth header even if the receipt itself is valid, returning 402 instead.
  3. Clients on Solana SHOULD NOT cache or send pre-auth receipts. The gateway omits the X-Solvela-PreAuth-Receipt header entirely on Solana payment success responses — there is nothing to cache.

On Solana, pre-auth is a no-op — the gateway behaves exactly as it does today.

EVM applicability

When Solvela adds EVM support (Base / Ethereum / Polygon), pre-auth becomes safe because EIP-3009 TransferWithAuthorization payloads carry their own validAfter / validBefore timestamp window — there is no blockhash binding to slot timing. A pre-signed authorization can only be redeemed once (the contract's nonce mapping prevents replay), and only within the timestamp window the client encodes.

The receipt model maps cleanly:

  • scope.currency = "USDC-EVM-Base" (or -Ethereum, etc.) gates pre-auth as ENABLED.
  • The gateway's validBefore enforcement aligns with the receipt exp.
  • The nonce field in the receipt mirrors EIP-3009's per-authorization nonce.

So the same opt-in client header set works for EVM with no protocol change beyond the chain-aware enablement gate.

Response cache — adjacent failsafe

Even with pre-auth disabled on Solana and constrained on EVM, the gateway carries a related layer that limits the blast radius of accidentally-replayed requests:

The existing response cache (ResponseCache in crates/gateway/src/cache.rs) keys on SHA-256(model ‖ serialised_messages ‖ temperature) and stores the upstream completion for the configured TTL (default 10 minutes). A client that issues the same prompt twice within the TTL window — pre-auth header or not — gets the cached response and the upstream LLM is only charged once.

This is not a payment-replay guard. Payment-replay protection is handled separately: the gateway tracks transaction signatures and rejects duplicate payment-signature headers with 402 Invalid Payment. The response cache is a cost-control / latency optimization, not a correctness safeguard against double-charging.

The two layers are deliberately independent:

  • Pre-auth is a latency optimization on the gateway's HTTP boundary (skip the 402 round-trip).
  • Response cache is a cost optimization on the upstream LLM hop (don't call the provider twice for the same prompt).
  • Payment-signature replay protection is a correctness guard at the verifier (don't accept the same signed transaction twice).

The pre-auth design only interacts with the first two; it cannot weaken the third.

Sequence diagram

sequenceDiagram
    participant A as Agent (client)
    participant G as Solvela Gateway
    participant P as LLM Provider

    Note over A,G: Cycle 1 — cold path
    A->>G: POST /v1/chat/completions (no payment)
    G-->>A: 402 PaymentRequired { amount, pay_to }
    A->>A: Sign payment payload
    A->>G: POST /v1/chat/completions<br/>payment-signature: <base64>
    G->>G: Verify payment
    G->>P: Forward request
    P-->>G: Response
    G-->>A: 200 OK<br/>X-Solvela-PreAuth-Receipt: <r1><br/>X-Solvela-PreAuth-TTL-Ms: 60000

    Note over A,G: Cycle 2 — warm path (EVM only)
    A->>A: Cache receipt r1
    A->>G: POST /v1/chat/completions<br/>X-Solvela-PreAuth: true<br/>X-Solvela-PreAuth-Receipt: r1
    G->>G: Verify receipt sig + scope + TTL
    G->>P: Forward request
    P-->>G: Response
    G-->>A: 200 OK<br/>X-Solvela-PreAuth-Receipt: <r2>

    Note over A,G: Cycle 3 — fail-open on bad receipt
    A->>G: POST /v1/chat/completions<br/>X-Solvela-PreAuth: true<br/>X-Solvela-PreAuth-Receipt: <expired>
    G->>G: Verify fails (expired)
    G-->>A: 402 PaymentRequired<br/>(falls back to cold path)
    A->>A: Discard expired receipt, sign fresh payment
    A->>G: POST ... payment-signature: <new>
    G-->>A: 200 OK ...

    Note over A,G: Cycle 4 — Solana (pre-auth disabled)
    A->>G: POST /v1/chat/completions (no payment)
    G-->>A: 402 PaymentRequired
    A->>G: POST ... payment-signature (Solana USDC-SPL)
    G->>P: Forward
    P-->>G: Response
    G-->>A: 200 OK<br/>(NO PreAuth-Receipt header — Solana)

Failure modes and how the protocol handles them

Receipt expired

Gateway returns 402 with PaymentRequired body. Client discards receipt and falls back to cold path. No charge.

Receipt scope mismatch

Client requested a different model, payer, or amount than the receipt covers. Gateway rejects with 402. No charge.

HMAC secret rotated

Receipt was signed by the previous secret. Gateway returns 402. Client discards receipt and falls back. No charge.

Solana request with receipt

Gateway returns 402 unconditionally — Solana is not eligible for pre-auth regardless of receipt validity. No charge.

Both PreAuth-Receipt AND payment-signature sent

Gateway prefers payment-signature (the more authoritative proof) and ignores the receipt. Request-dedup catches accidental retries.

Receipt leaked / stolen

Attacker can redeem at most ONE call's worth of inference within TTL (single-use receipt, narrow scope). Bounded blast radius.

Client-side implementation sketch

A client SDK should treat pre-auth as a transparent optimization: callers do not opt in per-call; the SDK manages the receipt cache.

// pseudocode
class SolvelaClient {
  private receiptCache = new Map<string, Receipt>();

  async chatCompletion(req: ChatRequest): Promise<ChatResponse> {
    const scopeKey = `${req.model}:${this.payer}:${this.estimateAmount(req)}`;
    const cached = this.receiptCache.get(scopeKey);

    if (cached && this.chain === "evm" && !cached.isExpired()) {
      const res = await this.send(req, { preAuthReceipt: cached.encoded });
      if (res.status === 200) {
        this.receiptCache.set(scopeKey, res.headers.preAuthReceipt);
        return res;
      }
      // 402 — fall through to cold path; receipt was no good
      this.receiptCache.delete(scopeKey);
    }

    // cold path
    const cold = await this.send(req);
    if (cold.status === 402) {
      const signed = await this.signPayment(cold.body);
      const res = await this.send(req, { paymentSignature: signed });
      if (this.chain === "evm" && res.headers.preAuthReceipt) {
        this.receiptCache.set(scopeKey, res.headers.preAuthReceipt);
      }
      return res;
    }
    return cold;
  }
}

The Solana branch never enters the cached path — the gateway will not issue receipts there.

Implementation TODO

When this spec is implemented:

  1. Slot: crates/gateway/src/middleware/x402.rs — add a PreAuthLayer that runs before the existing x402 middleware. On hit, sets Extension<VerifiedPayment> so the rest of the chain treats the request as paid. On miss or invalid receipt, removes the pre-auth headers and lets the request fall through to the normal 402 path.
  2. Receipt signing: new module crates/gateway/src/preauth.rs — HMAC-SHA256 signer with rotating secrets. Reuse ring::hmac (already a transitive dep).
  3. Per-chain gate: explicit if matches!(chain, Chain::Solana) { return reject_preauth(); } in the PreAuthLayer. Inline comment citing this page.
  4. Client SDK changes: the standalone solvela-ts, solvela-python, solvela-go repos and the in-monorepo sdks/mcp — receipt cache + opt-in header set. Solana code paths remain unchanged.
  5. Tests:
    • Receipt round-trip (sign / verify / scope match)
    • Expired receipt → fall-through to 402
    • Solana request with receipt → 402 even when receipt valid
    • HMAC secret rotation invalidates old receipts
    • Both payment-signature and receipt sent → payment-signature wins
  6. Metrics: solvela_preauth_hit_total{chain}, solvela_preauth_miss_total{chain, reason}, solvela_preauth_savings_ms histogram.

Until implemented, the headers above are reserved — clients setting them today get a normal 402 response (the gateway ignores unknown headers).