Skip to content

feat: add TIP-1034 tempo session#510

Open
brendanjryan wants to merge 11 commits into
wevm:mainfrom
brendanjryan:brendanjryan/tempo-session-refactor
Open

feat: add TIP-1034 tempo session#510
brendanjryan wants to merge 11 commits into
wevm:mainfrom
brendanjryan:brendanjryan/tempo-session-refactor

Conversation

@brendanjryan

@brendanjryan brendanjryan commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the new tempo/session flow on the TIP-1034 precompile.

The PR makes tempo.session a 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.session client and server implementations backed by the TIP-1034 TIP20EscrowChannel precompile
  • explicit tempo.session.client, tempo.session.server, and tempo.session.precompile module boundaries
  • legacy session code isolated under tempo/legacy and exposed through sessionLegacy APIs
  • client session manager for HTTP fetch, SSE streams, WebSocket streams, manual top-up, and cooperative close
  • server-side channel state, voucher verification, settlement, SSE metering, and WebSocket metering for precompile sessions
  • server-provided session snapshots in request.methodDetails.sessionSnapshot so clients can resume from server state without local persistence
  • browser playground for manual, auto-managed, and SSE session flows using pathUSD

End-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 receipt
Loading

Server 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 fresh tempo/session challenge.

Public Interfaces and Controls

  • tempo.session.method() creates the precompile-backed client payment method for Mppx.create()
  • tempo.session() creates the auto-driving client session manager
  • tempo.session() on the server creates the precompile-backed server method
  • tempo.session.settle() and tempo.session.settleBatch() expose server settlement controls
  • sessionManager.fetch(), sessionManager.sse(), and sessionManager.ws() power HTTP, SSE, and WebSocket flows
  • sessionManager.topUp() and sessionManager.close() expose manual lifecycle controls
  • VITE_TEMPO_NETWORK=localnet|moderato switches the playground and tests between Docker localnet and Tempo Moderato
  • VITE_RPC_URL overrides the selected network RPC URL

State Consolidation

  • session snapshot encoding now lives in one shared tempo/session/Snapshot protocol module used by both client and server
  • server snapshots now carry authoritative chainId and escrow fields so bootstrap does not infer channel identity from client configuration
  • client credential context selection now goes through one resolver for stored-channel fallback and snapshot-aware recovery
  • session manager state updates now dispatch reducer events for challenge receipt, channel activation, receipt acceptance, top-up reconciliation, and close completion

API and Plumbing Changes

  • core constants are centralized in src/Constants.ts
  • session files are organized under src/tempo/session/client, src/tempo/session/server, and src/tempo/session/precompile
  • old session modules are isolated under src/tempo/legacy
  • Fetch.from() and transport helpers understand the session challenge/retry flow used for automatic voucher management
  • test network setup resolves localnet, Moderato, or no-network mode from one configuration path

Compatibility

  • the old session implementation remains available through explicit legacy exports
  • existing HTTP payment flows continue to use the same MPP challenge, credential, and receipt headers
  • session snapshot fields are additive under request.methodDetails

Public 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.

import { tempo } from 'mppx/client'
import { createClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount('0x...')
const client = createClient({
  account,
  transport: http(),
})

const session = tempo.session({
  account,
  client,
  maxDeposit: '1',
})

const response = await session.fetch('/api/click')
console.log(response.receipt, response.channelId, response.cumulative)

for await (const chunk of await session.sse('/api/stream')) {
  console.log(chunk)
}

const socket = await session.ws('ws://localhost:5174/api/ws')
socket.addEventListener('message', (event) => console.log(event.data))

await session.topUp('0.25')
await session.close()

Managed client store for channel hints and restart recovery. The manager sends stored open channels as Payment-Session on HTTP requests and WebSocket probes. Servers can read that hint in resolveChannelId() and return a snapshot; if no snapshot is returned, the client can use the stored descriptor/cumulative data as a fallback voucher context.

const session = tempo.session({
  account,
  client,
  maxDeposit: '1',
  sessionStore: {
    get() {
      return JSON.parse(localStorage.getItem('mppx-session') ?? 'null')
    },
    set(channel) {
      localStorage.setItem('mppx-session', JSON.stringify(channel))
    },
    delete() {
      localStorage.removeItem('mppx-session')
    },
  },
})

Managed client options:

const session = tempo.session({
  account,
  client, // shorthand for getClient: () => client
  authorizedSigner: account.address,
  decimals: 6, // only parses local human-readable maxDeposit/topUp values
  escrow: '0x4d50500000000000000000000000000000000000', // optional local override
  fetch: globalThis.fetch,
  maxDeposit: '1', // local cap; server still suggests deposit/top-up requirements
  webSocket: globalThis.WebSocket,
})

Low-level client method for Mppx.create():

import { Mppx, tempo } from 'mppx/client'

const mppx = Mppx.create({
  methods: [
    tempo.session.method({
      account,
      getClient: () => client,
      authorizedSigner: account.address,
      decimals: 6,
      escrow: '0x4d50500000000000000000000000000000000000',
      maxDeposit: '1',
      onChannelUpdate(channel) {
        console.log(channel.channelId, channel.cumulativeAmount, channel.deposit)
      },
    }),
  ],
})

Server

Default server method. The server owns session policy and advertises it in the challenge.

import { Mppx, Store, tempo } from 'mppx/server'
import { createClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { tempoLocalnet } from 'viem/chains'

const account = privateKeyToAccount('0x...')
const store = Store.memory()
const client = createClient({
  account,
  chain: tempoLocalnet,
  transport: http('http://localhost:18545'),
})

const mppx = Mppx.create({
  secretKey: 'session-secret',
  methods: [
    tempo.session({
      account,
      getClient: () => client,
      store,
      amount: '0.00005',
      currency: '0x20c0000000000000000000000000000000000001',
      recipient: account.address,
      unitType: 'request',
    }),
  ],
})

Server options:

const method = tempo.session({
  account,
  getClient: () => client,
  store,
  amount: '0.00005',
  currency: '0x20c0000000000000000000000000000000000001',
  recipient: account.address,
  decimals: 6,
  unitType: 'request',
  chainId: tempoLocalnet.id,
  escrowContract: '0x4d50500000000000000000000000000000000000',
  operator: '0x0000000000000000000000000000000000000000',
  feeToken: '0x20c0000000000000000000000000000000000001',
  feePayer: true,
  feePayerPolicy: {},
  channelStateTtl: 5_000,
  minVoucherDelta: '0',
  resolveChannelId({ request, credential, paymentRequest, store }) {
    // Explicit credential/request channel IDs are used before this hook.
    // Client session stores send this hint automatically on the next request.
    const hintedChannelId = request?.headers.get('Payment-Session')
    if (hintedChannelId) return hintedChannelId

    // Apps can also use cookies, auth headers, route params, or an
    // app-maintained payer/session index to find the latest open channel.
    const sessionId = request?.headers.get('cookie')?.match(/sid=([^;]+)/)?.[1]
    if (!sessionId) return undefined
    return channelIdBySession.get(sessionId)
  },
  suggestedDeposit: '0.001',
  settlementSchedule: {
    units: 10,
    amount: '0.01',
    intervalMs: 60_000,
  },
  sse: {
    poll: true,
  },
})

Server lifecycle controls:

const channel = await tempo.session.charge(channelId, 50n)
const settleTx = await tempo.session.settle(channel.channelId)
const batchSettleTx = await tempo.session.settleBatch([channel.channelId])

@pkg-pr-new

pkg-pr-new Bot commented Jun 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/mppx@510

commit: c01eeda

@brendanjryan brendanjryan marked this pull request as ready for review June 4, 2026 14:38
@brendanjryan brendanjryan force-pushed the brendanjryan/tempo-session-refactor branch from 7de52a3 to c01eeda Compare June 8, 2026 22:45

@tempoxyz-bot tempoxyz-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ 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 shared tempo/session handler map is order-dependent; registering legacy before v2 can silently orphan the v2 handler.
  • Raw sessionLegacy export lacks alias (src/tempo/server/index.ts:6): dispatch relies on alias: "sessionLegacy", but one public export path omits it.
  • Manual descriptor validation is asymmetric (src/tempo/session/client/CredentialState.ts:475 vs :737): recovery validates descriptors against the challenge, manual actions do not.
  • Snapshot schema is a type cast (src/tempo/session/Snapshot.ts:39): replace z.custom<ChannelDescriptor>() with real descriptor validation.
  • WebSocket/composed-offer pricing (src/tempo/session/server/Ws.ts:200): document or enforce expectedAmount when one route is backed by multiple offers.

}
const challenge = Challenge.fromResponseList(challengeResponse).find(isTempoChargeChallenge)
if (!challenge) return
const credential = await chargeMethod.createCredential({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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.

Comment thread src/server/Mppx.ts
challenge.request.methodDetails,
Constants.MethodDetailKeys.sessionProtocol,
)
if (sessionProtocol === Constants.SessionProtocols.v1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] Session-protocol migration misroutes or rejects in-flight challenges

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants