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.
🚨 [SECURITY] Bootstrap tempo/charge probe can authorize non-zero one-time charges
The bootstrap HEAD flow selects any tempo/charge challenge by method/intent and immediately delegates it to the full tempo.charge client. That client signs a proof only for amount 0; non-zero amounts create a real token transfer, and the session maxDeposit cap is not applied here. A malicious endpoint can turn session bootstrap into an arbitrary one-time charge.
Recommended Fix: Enforce BigInt(challenge.request.amount) === 0n and reject value-moving charge fields before delegating, or use a dedicated proof-only bootstrap credential path with expected recipient/currency pinned.
| if (sessionStore) void Promise.resolve(sessionStore.set(channel)).catch(() => undefined) | ||
| } | ||
|
|
||
| async function hydrateSnapshotHeader(response: Response) { |
There was a problem hiding this comment.
🚨 [SECURITY] Unsigned bootstrap snapshots can drive top-ups for attacker-chosen channels
A non-402 bootstrap response can supply an unauthenticated Payment-Session-Snapshot; this code hydrates it as an opened local channel. On the next paid challenge, automatic top-up can use the snapshot descriptor through the manual topUp path, which signs topUp(descriptor, additionalDeposit) without rebinding that descriptor to the challenge recipient/token. This can lock a victim’s funds into an existing channel chosen by the endpoint.
Recommended Fix: Authenticate snapshots or move them into the HMAC-bound challenge; validate descriptor shape, recompute channelId, verify on-chain state before marking opened, and check manual action descriptors against the active challenge before signing.
| expectedSigner: Address, | ||
| ): boolean { | ||
| try { | ||
| const envelope = SignatureEnvelope.from(voucher.signature as SignatureEnvelope.Serialized) |
There was a problem hiding this comment.
🚨 [SECURITY] Magic-suffixed voucher signatures are accepted off-chain but cannot settle on-chain
SignatureEnvelope.from() is permissive and strips optional Tempo magic bytes before verification. The server then persists the original signature bytes and later submits them unchanged to the TIP20 reserve precompile, whose parser validates exact ABI bytes and does not strip that suffix. A raw65 || magicBytes voucher can therefore authorize off-chain service while being uncollectable on-chain.
Recommended Fix: Canonicalize or reject voucher signatures before storing them. Require submitted bytes to exactly match the precompile-accepted serialization, e.g. compare SignatureEnvelope.serialize(envelope) to voucher.signature and enforce exact signature lengths/types intended for settlement.
| payload.signature, | ||
| ) | ||
| assertDescriptor(payload) | ||
| const channel = await ChannelStore.loadPrecompileChannel({ |
There was a problem hiding this comment.
🚨 [SECURITY] Reused session channels are not rebound to the current challenge token or recipient
The open path validates the descriptor against the current challenge’s recipient and currency, but existing-channel actions load a stored channel and validate only against that channel’s own stored payee/token. The HMAC/pinned challenge checks do not compare the voucher/top-up/close payload descriptor to the challenge, so a token-A/payee-A channel can satisfy a token-B/payee-B route when a store is shared.
Recommended Fix: Thread getChallengePaymentFields(challenge) into voucher, topUp, and close handling and validate payload.descriptor.payee/token against request.recipient/currency before accepting state changes. Also filter reusable snapshots by expected payee/token/chain/escrow.
| challenge.request.methodDetails, | ||
| Constants.MethodDetailKeys.sessionProtocol, | ||
| ) | ||
| if (sessionProtocol === Constants.SessionProtocols.v1) |
There was a problem hiding this comment.
With duplicate tempo/session handlers, an absent sessionProtocol falls through to methods[0] instead of legacy v1, so pre-upgrade legacy challenges can be routed to v2 and fail. The marker rename from tip1034/legacy to v2/v1 also removed old strings from routing/schema/client predicates, which can reject HMAC-valid pre-rename challenges.
Recommended Fix: Treat undefined and old legacy as v1/sessionLegacy, accept old tip1034 as a v2 alias in routing, schemas, RequestState, and client canHandleChallenge, and avoid order-dependent fallback for duplicate wire identities.
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: