feat: add TIP-1034 tempo session#510
Conversation
commit: |
7de52a3 to
c01eeda
Compare
tempoxyz-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review
PR #510 adds TIP-1034 precompile-backed Tempo sessions, client bootstrap/snapshot handling, and server-side channel/voucher settlement. I found five actionable issues inline: bootstrap can authorize unintended value movement, snapshots are unauthenticated, voucher signature parsing can diverge from on-chain validation, reused channels are not rebound to challenge economics, and the session-protocol migration can brick in-flight sessions.
Reviewer Callouts
- ⚡ Duplicate session method registration order (
src/server/Mppx.ts:447): the sharedtempo/sessionhandler map is order-dependent; registering legacy before v2 can silently orphan the v2 handler. - ⚡ Raw
sessionLegacyexport lacks alias (src/tempo/server/index.ts:6): dispatch relies onalias: "sessionLegacy", but one public export path omits it. - ⚡ Manual descriptor validation is asymmetric (
src/tempo/session/client/CredentialState.ts:475vs:737): recovery validates descriptors against the challenge, manual actions do not. - ⚡ Snapshot schema is a type cast (
src/tempo/session/Snapshot.ts:39): replacez.custom<ChannelDescriptor>()with real descriptor validation. - ⚡ WebSocket/composed-offer pricing (
src/tempo/session/server/Ws.ts:200): document or enforceexpectedAmountwhen one route is backed by multiple offers.
| } | ||
| const challenge = Challenge.fromResponseList(challengeResponse).find(isTempoChargeChallenge) | ||
| if (!challenge) return | ||
| const credential = await chargeMethod.createCredential({ |
There was a problem hiding this comment.
this should use the zero dollar auth flow -- fine to be defensive here
| } | ||
| const challenge = Challenge.fromResponseList(challengeResponse).find(isTempoChargeChallenge) | ||
| if (!challenge) return | ||
| const credential = await chargeMethod.createCredential({ |
There was a problem hiding this comment.
this should use the zero dollar auth flow -- fine to be defensive here
tempoxyz-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review — PR #510
This PR adds the TIP-1034 Tempo session implementation, v1/v2 session negotiation, session-resumption headers, and new accounting paths. Several actionable trust-boundary and accounting issues remain. Findings tied to changed lines are inline; body-only findings are below where the relevant line is outside the PR diff or ambiguous for inline placement.
Body-only findings
🚨 [SECURITY] SSE null-body responses return success before an unchecked asynchronous deduction
Severity: Medium
File: src/tempo/server/internal/transport.ts:182
For null-body statuses, the SSE fallback constructs a charged-looking receipt, calls void ChannelStore.deductFromChannel(...), and immediately returns success. The deduction can return { ok: false } on races, pending close, finalized channels, or insufficient headroom, so concurrent duplicate requests can perform side effects while only one spend mutation commits.
Recommended Fix: Await deductFromChannel, require result.ok, and build the returned receipt from committed post-deduction state.
🚨 [SECURITY] MCP client discards the canHandleChallenge-selected method for duplicate tempo/session challenges
Severity: Medium
File: src/mcp-sdk/client/McpClient.ts:96
AcceptPayment.selectChallenge() returns the exact method that passed the v1/v2 canHandleChallenge predicate, but McpClient.wrap() discards it and re-selects by the shared name/intent wire key. Clients configured with both legacy and TIP-1034 session methods can sign using the wrong implementation depending on local method order.
Recommended Fix: Pass selected.method directly into credential creation and add MCP regression tests for both method orders and session protocol markers.
🚨 [SECURITY] Paid proxy leaks new session headers across the upstream boundary
Severity: Low
File: src/proxy/internal/Headers.ts:13
The proxy scrubber predates Payment-Session and Payment-Session-Snapshot, so paid proxied requests can forward stable channel identifiers to upstream services, and upstream Payment-Session* response headers survive response scrubbing.
Recommended Fix: Derive the proxy denylist from Constants.Headers plus x402 headers and apply it to both request and response paths.
⚠️ [ISSUE] Framework middleware wrappers leave new sessionLegacy alias keys as raw core handlers
Severity: Low
File: src/middlewares/internal/mppx.ts:47
Core registers alias handlers such as tempo/sessionLegacy, but the framework wrapper only wraps canonical ${name}/${intent} keys. Express/Hono/Next/Elysia users who call the explicit legacy alias receive a raw core handler rather than framework middleware.
Recommended Fix: Wrap both canonical and alias keys and add framework tests for the new alias paths.
Reviewer Callouts
- ⚡ Snapshot ingestion in
SessionManager.storeSnapshotHeader: inspect whether bootstrap acceptsPayment-Session-Snapshoton any non-402 HEAD response and whether it bypasses the ignored-channel blacklist while persistingopened: true. - ⚡ Alias/discovery consistency: OpenAPI/discovery string configs may not index the new
sessionLegacyalias even after middleware wrapping is fixed. - ⚡ Centralized protocol headers: all protocol-owned headers should be centralized so future scrubbers cannot miss newly added names.
| ): bigint { | ||
| const { context, decimals, requestAmount, snapshot, settled } = parameters | ||
|
|
||
| if (snapshot) { |
There was a problem hiding this comment.
🚨 [SECURITY] Client can be coerced into signing vouchers up to the channel deposit
Recovery returns the server-supplied sessionSnapshot.acceptedCumulative/requiredCumulative max verbatim, and the default maxDeposit path is uncapped. Retry and need-voucher flows also derive signed cumulatives from server-provided amounts, so a malicious server can make a fresh/recovering client authorize far more than the current request cost.
Recommended Fix: Require a finite local cap by default, validate snapshot deposit against on-chain state, cap recovery to bounded headroom over settled/request cost, and assert the cap immediately before every voucher signature.
| return { channelId: expectedChannelId, state } | ||
| } | ||
|
|
||
| function assertReusableChannelDescriptor(parameters: { |
There was a problem hiding this comment.
🚨 [SECURITY] Reusable-channel recovery does not bind the descriptor to the local payer/signer
This validator checks channel ID, payee, and token, but not descriptor.payer or descriptor.authorizedSigner against the local account and signer. A server snapshot can point a client at another same-payee channel and obtain a valid voucher if the local key is authorized there.
Recommended Fix: Plumb the local payer address and resolved authorized signer into this check and reject mismatches.
| store: ChannelStore.ChannelStore | ||
| }): Promise<Hex | undefined> { | ||
| const { capturedRequest, credential, request, resolveChannelId, source, store } = parameters | ||
| const explicitChannelId = |
There was a problem hiding this comment.
🚨 [SECURITY] Snapshot resolution trusts an unauthenticated credential channel ID
resolveSessionChannelId prefers credential.payload.channelId, but the session request() hook runs before HMAC/payload/signature verification. That raw channel ID can drive resolveSessionSnapshot, and the snapshot is returned in a new challenge on later verification failure.
Recommended Fix: Do not resolve snapshots from credential payload fields until after the credential is authenticated, or require a verified payer/source identity and ownership binding first.
| reason: 'credential authorizedSigner does not match descriptor', | ||
| }) | ||
| const channelId = ChannelStore.normalizeChannelId(payload.channelId) | ||
| validateChannelDescriptor( |
There was a problem hiding this comment.
🚨 [SECURITY] Open-channel verification accepts the payload operator
The descriptor validation path checks channel ID, payee, and token, but the expected operator is not part of the server-side validation. The subsequent open transaction validation uses payload.descriptor.operator as the expected operator, allowing clients to choose an operator that can later prevent the server hot wallet from settling.
Recommended Fix: Pass the challenge/configured operator into descriptor validation and into broadcastOpenTransaction; reject payload descriptors whose operator does not match the server expectation.
| source: parameters.credential.source, | ||
| store: parameters.store, | ||
| }) | ||
| const snapshot = await resolveSessionSnapshot({ |
There was a problem hiding this comment.
🚨 [SECURITY] Bootstrap snapshot lookup omits expected payment fields
The normal request path passes expected chain/escrow/recipient/currency fields into resolveSessionSnapshot, but this bootstrap path calls it with only amount, channel ID, and store. A valid bootstrap proof carrying another channel ID can receive that channel's snapshot without the payment-field filter.
Recommended Fix: Pass the same expected payment fields used by the normal session request path, and avoid honoring raw channel IDs unless ownership has been established.
| (payload.action === 'open' || payload.action === 'voucher') && | ||
| input.method === 'POST' && | ||
| !input.url?.search && | ||
| !hasBodyIntentHeaders(input) |
There was a problem hiding this comment.
🚨 [SECURITY] SSE charge classifier disagrees with the content gate
The handler gate treats a POST with hasBody === true as billable content, but this SSE plain-response classifier returns no-charge for open/voucher POSTs with no query and no body-intent headers. Fetch-style body streams without those headers can reach the handler and then avoid deduction.
Recommended Fix: Share one content/management decision through verification and receipt handling, or make this branch treat hasBody === true as body intent.
| channel.cumulativeAmount > eventAmounts.requiredCumulative | ||
| ? channel.cumulativeAmount | ||
| : uint96(eventAmounts.requiredCumulative) | ||
| channel.cumulativeAmount = cumulativeAmount |
There was a problem hiding this comment.
🚨 [SECURITY] Need-voucher handling mutates local cumulative state before the voucher is signed or accepted
channel.cumulativeAmount is raised before createSessionCredential signs and before the management POST/send succeeds. If signing is rejected or the transport fails, the inflated baseline remains and a later voucher can gift the difference to the server.
Recommended Fix: Only update cached cumulative state after the voucher is signed and accepted, or roll back this mutation on every failure path.
| name: method.name, | ||
| intent: method.intent, | ||
| html: method.html, | ||
| _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }), |
There was a problem hiding this comment.
compose() can route the first duplicate tempo/session credential to the wrong handler
This initial _canonicalRequest is built before the session request() hook adds methodDetails.sessionProtocol. A fresh compose([v2, v1]) route receiving a v1 credential first can have both candidates filtered out and fall back to handlers[0], returning a wrong-protocol 402 until a no-credential probe hydrates both handlers.
Recommended Fix: Eagerly hydrate the canonical request after the request hook, or dispatch duplicate methods using the credential challenge's sessionProtocol like verifyCredential() does.
| request?: SessionChannelIdRequest | undefined | ||
| /** Credential submitted with the request, when present. */ | ||
| credential: Credential.Credential | null | undefined | ||
| /** Cryptographic payer identity from a verified zero-amount bootstrap proof. */ |
There was a problem hiding this comment.
🛡️ [DEFENSE-IN-DEPTH] source is documented as verified but can be attacker-controlled here
The source parameter is documented as coming from a verified bootstrap proof, but the regular session request() path passes credential?.source to user resolveChannelId hooks before credential HMAC/payload/signature verification. Custom resolvers may trust this free-form field.
Recommended Fix: Strip source in pre-verification request handling, or expose it with explicit unverified semantics and provide a separate verified-source API.
| } | ||
|
|
||
| /** Validates a server receipt without allowing it to increase the local signing boundary. */ | ||
| export function assertReceiptWithinLocalState(parameters: LocalReceiptValidationParameters): void { |
There was a problem hiding this comment.
🛡️ [DEFENSE-IN-DEPTH] Receipts are applied without binding to the active challenge
Receipt validation checks the channel and amount bounds, but not receipt.challengeId against the active challenge. Stale same-channel receipts can regress observable HTTP/SSE/runtime session state such as acceptedCumulative, units, and challengeId.
Recommended Fix: Include the active challenge ID in receipt validation before projecting receipts into public state, and use challenge/channel-specific predicates for default waiters.
Summary
Implements the new
tempo/sessionflow on the TIP-1034 precompile.The PR makes
tempo.sessiona precompile-backed session interface for HTTP, SSE, and WebSocket payments, while moving the previous channel implementation behind explicit legacy exports.What This PR Implements
tempo.sessionclient and server implementations backed by the TIP-1034TIP20EscrowChannelprecompiletempo.session.client,tempo.session.server, andtempo.session.precompilemodule boundariestempo/legacyand exposed throughsessionLegacyAPIsrequest.methodDetails.sessionSnapshotso clients can resume from server state without local persistenceEnd-to-End Flow
sequenceDiagram participant Client participant Manager as SessionManager participant Server participant Store as Channel store participant Chain as TIP-1034 precompile Client->>Manager: fetch/sse/ws paid resource Manager->>Server: request without credential Server->>Store: resolve reusable channel snapshot Server-->>Manager: 402 tempo.session challenge alt no reusable channel Manager->>Chain: open channel transaction Manager->>Server: retry with open credential + initial voucher else reusable snapshot available Manager->>Manager: hydrate runtime from server snapshot Manager->>Server: retry with voucher credential end Server->>Chain: verify/open/top-up/settle as needed Server->>Store: persist channel accounting Server-->>Manager: 200 + Payment-Receipt Manager->>Manager: reconcile receipt into state machine loop streaming or repeated requests Server-->>Manager: need-voucher event when headroom is low alt required cumulative exceeds deposit Manager->>Chain: top-up transaction Manager->>Server: post top-up credential end Manager->>Server: post/send voucher credential Server-->>Manager: receipt end Client->>Manager: close() Manager->>Server: close credential alt close challenge expired Server-->>Manager: fresh 402 tempo.session challenge Manager->>Server: retry close credential end Server->>Chain: close channel Server-->>Manager: close receiptServer Behavior
tempo.session()now creates a precompile-backed server method. It validates open, top-up, voucher, and close credentials against the canonical TIP-1034 channel descriptor, tracks accepted cumulative vouchers separately from spent units, and can settle channels on a configured schedule.The server attaches reusable session state to challenges when it can resolve a channel. The snapshot includes the channel descriptor, deposit, accepted cumulative amount, required cumulative amount, settled amount, spent amount, close state, and unit count. This lets clients hydrate from the server as the source of session state instead of requiring local persistence.
SSE and WebSocket transports use the same channel accounting model. Both can request additional voucher headroom, enforce local spend accounting, and drive top-up/voucher management posts without duplicating settlement logic.
Client Behavior
The client-side
sessionManager()owns a pure state-machine runtime for session lifecycle transitions. It opens channels, signs incremental vouchers, posts top-ups when the server asks for more deposit, reconciles receipts, and cooperatively closes the channel.HTTP, SSE, and WebSocket paths share the same credential state and voucher policy. The client keeps deposit, spent, and cumulative voucher authorization explicit so server snapshots can hydrate state without letting the server inflate the next signed voucher boundary.
close()now retries once when the close credential was signed against an expired challenge and the server returns a freshtempo/sessionchallenge.Public Interfaces and Controls
tempo.session.method()creates the precompile-backed client payment method forMppx.create()tempo.session()creates the auto-driving client session managertempo.session()on the server creates the precompile-backed server methodtempo.session.settle()andtempo.session.settleBatch()expose server settlement controlssessionManager.fetch(),sessionManager.sse(), andsessionManager.ws()power HTTP, SSE, and WebSocket flowssessionManager.topUp()andsessionManager.close()expose manual lifecycle controlsVITE_TEMPO_NETWORK=localnet|moderatoswitches the playground and tests between Docker localnet and Tempo ModeratoVITE_RPC_URLoverrides the selected network RPC URLState Consolidation
tempo/session/Snapshotprotocol module used by both client and serverchainIdandescrowfields so bootstrap does not infer channel identity from client configurationAPI and Plumbing Changes
src/Constants.tssrc/tempo/session/client,src/tempo/session/server, andsrc/tempo/session/precompilesrc/tempo/legacyFetch.from()and transport helpers understand the session challenge/retry flow used for automatic voucher managementCompatibility
request.methodDetailsPublic Interfaces
Client
Default managed client. The server challenge supplies protocol details such as
chainId,escrowContract,operator, fee-payer support, suggested deposit, and reusable session snapshots.Managed client store for channel hints and restart recovery. The manager sends stored open channels as
Payment-Sessionon HTTP requests and WebSocket probes. Servers can read that hint inresolveChannelId()and return a snapshot; if no snapshot is returned, the client can use the stored descriptor/cumulative data as a fallback voucher context.Managed client options:
Low-level client method for
Mppx.create():Server
Default server method. The server owns session policy and advertises it in the challenge.
Server options:
Server lifecycle controls: