From f7a130196d211087a6c35a5939c027efb273b470 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:18:06 -0700 Subject: [PATCH 1/6] feat: add session hint reconciliation --- src/client/internal/Fetch.test.ts | 33 +++++ src/client/internal/Fetch.ts | 20 ++- src/tempo/Methods.test.ts | 20 +++ src/tempo/Methods.ts | 12 ++ src/tempo/client/ChannelOps.ts | 108 ++++++++++++++- src/tempo/client/Session.test.ts | 71 ++++++++++ src/tempo/client/Session.ts | 205 +++++++++++++++++++++++------ src/tempo/client/SessionManager.ts | 48 +++++-- src/tempo/server/Session.test.ts | 41 ++++++ src/tempo/server/Session.ts | 43 +++++- src/tempo/session/Types.ts | 18 +++ 11 files changed, 554 insertions(+), 65 deletions(-) diff --git a/src/client/internal/Fetch.test.ts b/src/client/internal/Fetch.test.ts index 186466c9..689309c4 100644 --- a/src/client/internal/Fetch.test.ts +++ b/src/client/internal/Fetch.test.ts @@ -601,6 +601,20 @@ describe('Fetch.from: init passthrough (non-402)', () => { expect(new Headers(receivedInits[0]?.headers).get('Accept-Payment')).toBe('test/test') } }) + + test('calls method response hooks for successful non-402 responses', async () => { + const onResponse = vi.fn() + const method = { ...noopMethod, onResponse } + const fetch = Fetch.from({ + fetch: async () => new Response('OK', { status: 200 }), + methods: [method], + }) + + await fetch('https://example.com/api') + + expect(onResponse).toHaveBeenCalledOnce() + expect(onResponse.mock.calls[0]![0]).toBeInstanceOf(Response) + }) }) describe('Fetch.from: 402 retry path', () => { @@ -930,6 +944,25 @@ describe('Fetch.from: 402 retry path', () => { expect(events).toEqual(['failed:true:abc', '*:payment.failed']) }) + test('calls method response hooks for successful retry responses', async () => { + let callCount = 0 + const onResponse = vi.fn() + const method = { ...noopMethod, onResponse } + const fetch = Fetch.from({ + fetch: async () => { + callCount++ + if (callCount === 1) return make402() + return new Response('OK', { status: 200 }) + }, + methods: [method], + }) + + await fetch('https://example.com/api') + + expect(onResponse).toHaveBeenCalledOnce() + expect(callCount).toBe(2) + }) + test('preserves existing headers on retry', async () => { let callCount = 0 const calls: { init: RequestInit | undefined }[] = [] diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 0d02ebac..0770c46a 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -14,6 +14,10 @@ type WrappedFetch = typeof globalThis.fetch & { [MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch } +type ResponseAwareClient = Method.AnyClient & { + onResponse?: ((response: Response) => Promise | void) | undefined +} + let originalFetch: typeof globalThis.fetch | undefined export type ClientEventMap< @@ -183,7 +187,10 @@ export function from( ) const response = await baseFetch(initialRequest.input, initialRequest.init) - if (response.status !== 402) return response + if (response.status !== 402) { + await handleResponse(methods, response) + return response + } // Only extract context for payment handling after confirming 402. const context = (init as Record | undefined)?.context @@ -262,6 +269,7 @@ export function from( ...fetchInit, headers: withAuthorizationHeader(initialRequest.headers, credential), }) + await handleResponse(methods, paymentResponse) if (paymentResponse.ok) await events.emit( 'payment.response', @@ -765,6 +773,16 @@ export function validateCredentialHeaderValue(credential: string): void { } } +async function handleResponse( + methods: readonly Method.AnyClient[], + response: Response, +): Promise { + for (const method of methods) { + const onResponse = (method as ResponseAwareClient).onResponse + if (onResponse) await onResponse(response) + } +} + /** @internal */ async function resolveCredential( challenge: Challenge.Challenge, diff --git a/src/tempo/Methods.test.ts b/src/tempo/Methods.test.ts index 3594a37f..39e7a012 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -248,6 +248,26 @@ describe('session', () => { expect(request.amount).toBe('1000000') expect(request.methodDetails?.minVoucherDelta).toBe('100000') }) + + test('schema: preserves additive session hints in methodDetails', () => { + const request = Methods.session.schema.request.parse({ + acceptedCumulative: '5000000', + amount: '1', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + deposit: '10000000', + escrowContract: '0x1234567890abcdef1234567890abcdef12345678', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + requiredCumulative: '6000000', + spent: '4000000', + unitType: 'token', + }) + + expect(request.methodDetails?.acceptedCumulative).toBe('5000000') + expect(request.methodDetails?.deposit).toBe('10000000') + expect(request.methodDetails?.requiredCumulative).toBe('6000000') + expect(request.methodDetails?.spent).toBe('4000000') + }) }) describe('subscription', () => { diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index 7f7447ce..22bf981d 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -224,11 +224,13 @@ export const session = Method.from({ request: z.pipe( z .object({ + acceptedCumulative: z.optional(z.string()), amount: z.amount(), chainId: z.optional(z.number()), channelId: z.optional(z.hash()), currency: z.string(), decimals: z.number(), + deposit: z.optional(z.string()), escrowContract: z.optional(z.string()), feePayer: z.optional( z.pipe( @@ -238,6 +240,8 @@ export const session = Method.from({ ), minVoucherDelta: z.optional(z.amount()), recipient: z.optional(z.string()), + requiredCumulative: z.optional(z.string()), + spent: z.optional(z.string()), suggestedDeposit: z.optional(z.amount()), unitType: z.string(), }) @@ -249,13 +253,17 @@ export const session = Method.from({ ), z.transform( ({ + acceptedCumulative, amount, chainId, channelId, decimals, + deposit, escrowContract, feePayer, minVoucherDelta, + requiredCumulative, + spent, suggestedDeposit, ...rest }) => ({ @@ -267,13 +275,17 @@ export const session = Method.from({ } : {}), methodDetails: { + ...(acceptedCumulative !== undefined && { acceptedCumulative }), + ...(deposit !== undefined && { deposit }), escrowContract, ...(channelId !== undefined && { channelId }), ...(minVoucherDelta !== undefined && { minVoucherDelta: parseUnits(minVoucherDelta, decimals).toString(), }), + ...(requiredCumulative !== undefined && { requiredCumulative }), ...(chainId !== undefined && { chainId }), ...(feePayer !== undefined && { feePayer }), + ...(spent !== undefined && { spent }), }, }), ), diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index e5f60793..7d29d0cd 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -21,16 +21,23 @@ import * as Credential from '../../Credential.js' import * as defaults from '../internal/defaults.js' import { escrowAbi, getOnChainChannel } from '../session/Chain.js' import * as Channel from '../session/Channel.js' -import type { SessionCredentialPayload } from '../session/Types.js' +import type { + SessionChallengeMethodDetails, + SessionCredentialPayload, + SessionReceipt, +} from '../session/Types.js' import { signVoucher } from '../session/Voucher.js' export type ChannelEntry = { + acceptedCumulative: bigint + chainId: number channelId: Hex.Hex - salt: Hex.Hex cumulativeAmount: bigint + deposit?: bigint | undefined escrowContract: Address - chainId: number opened: boolean + salt: Hex.Hex + spent: bigint } export function resolveChainId(challenge: Challenge): number { @@ -69,6 +76,87 @@ export function serializeCredential( }) } +type ChannelSnapshot = { + acceptedCumulative?: bigint | string | undefined + deposit?: bigint | string | undefined + spent?: bigint | string | undefined +} + +function toBigInt(value: bigint | string): bigint { + return typeof value === 'bigint' ? value : BigInt(value) +} + +export function createHintedChannelEntry(options: { + chainId: number + channelId: Hex.Hex + escrowContract: Address + hints: Pick +}): ChannelEntry { + const acceptedCumulative = BigInt(options.hints.acceptedCumulative ?? options.hints.spent ?? '0') + const spent = BigInt(options.hints.spent ?? options.hints.acceptedCumulative ?? '0') + + return { + acceptedCumulative, + chainId: options.chainId, + channelId: options.channelId, + cumulativeAmount: acceptedCumulative, + ...(options.hints.deposit !== undefined && { deposit: BigInt(options.hints.deposit) }), + escrowContract: options.escrowContract, + opened: true, + salt: '0x' as Hex.Hex, + spent, + } +} + +export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnapshot): boolean { + let changed = false + + if (snapshot.acceptedCumulative !== undefined) { + const acceptedCumulative = toBigInt(snapshot.acceptedCumulative) + if (entry.acceptedCumulative !== acceptedCumulative) { + entry.acceptedCumulative = acceptedCumulative + changed = true + } + if (entry.cumulativeAmount !== acceptedCumulative) { + entry.cumulativeAmount = acceptedCumulative + changed = true + } + } + + if (snapshot.spent !== undefined) { + const spent = toBigInt(snapshot.spent) + if (entry.spent !== spent) { + entry.spent = spent + changed = true + } + if (snapshot.acceptedCumulative === undefined && entry.acceptedCumulative < spent) { + entry.acceptedCumulative = spent + changed = true + } + if (snapshot.acceptedCumulative === undefined && entry.cumulativeAmount < spent) { + entry.cumulativeAmount = spent + changed = true + } + } + + if (snapshot.deposit !== undefined) { + const deposit = toBigInt(snapshot.deposit) + if (entry.deposit !== deposit) { + entry.deposit = deposit + changed = true + } + } + + return changed +} + +export function reconcileChannelReceipt(entry: ChannelEntry, receipt: SessionReceipt): boolean { + return reconcileChannelEntry(entry, { + acceptedCumulative: receipt.acceptedCumulative, + spent: receipt.spent, + }) +} + export async function createVoucherPayload( client: viem_Client, account: viem_Account, @@ -185,12 +273,15 @@ export async function createOpenPayload( return { entry: { + acceptedCumulative: initialAmount, + chainId, channelId, - salt, cumulativeAmount: initialAmount, + deposit, escrowContract, - chainId, opened: true, + salt, + spent: 0n, }, payload: { action: 'open', @@ -225,12 +316,15 @@ export async function tryRecoverChannel( if (onChain.deposit > 0n && !onChain.finalized) { return { + acceptedCumulative: onChain.settled, + chainId, channelId, - salt: '0x' as Hex.Hex, cumulativeAmount: onChain.settled, + deposit: onChain.deposit, escrowContract, - chainId, opened: true, + salt: '0x' as Hex.Hex, + spent: onChain.settled, } } } catch {} diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index f8475f87..10a7cacd 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -12,6 +12,7 @@ import * as Challenge from '../../Challenge.js' import * as Credential from '../../Credential.js' import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js' import { escrowAbi } from '../session/Chain.js' +import { createSessionReceipt, serializeSessionReceipt } from '../session/Receipt.js' import type { SessionCredentialPayload } from '../session/Types.js' import { session } from './Session.js' @@ -66,6 +67,40 @@ describe('session (pure)', () => { }) }) + describe('server-authored hints', () => { + const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex + + test('prefers requiredCumulative and hydrates channel from challenge hints', async () => { + const method = session({ + getClient: () => pureClient, + account: pureAccount, + deposit: '10', + }) + + const result = await method.createCredential({ + challenge: makeChallenge({ + methodDetails: { + acceptedCumulative: '5000000', + chainId: 42431, + channelId, + deposit: '10000000', + escrowContract: escrowAddress, + requiredCumulative: '6000000', + spent: '4000000', + }, + }), + context: {}, + }) + + const cred = deserializePayload(result) + expect(cred.payload.action).toBe('voucher') + if (cred.payload.action === 'voucher') { + expect(cred.payload.channelId).toBe(channelId) + expect(cred.payload.cumulativeAmount).toBe('6000000') + } + }) + }) + describe('manual action validation', () => { const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex @@ -507,6 +542,42 @@ describe.runIf(isLocalnet)('session (on-chain)', () => { expect(updates[0]!.cumulativeAmount).toBe(1_000_000n) expect(updates[1]!.cumulativeAmount).toBe(2_000_000n) }) + + test('reconciles local cumulative from Payment-Receipt before the next voucher', async () => { + const method = session({ + getClient: () => client, + account: payer, + deposit: '10', + escrowContract, + }) + + const challenge = makeLiveChallenge() + const first = await method.createCredential({ challenge, context: {} }) + const firstCred = deserializePayload(first) + if (firstCred.payload.action !== 'open') throw new Error('expected open payload') + + method.onResponse( + new Response(null, { + headers: { + 'Payment-Receipt': serializeSessionReceipt( + createSessionReceipt({ + challengeId: challenge.id, + channelId: firstCred.payload.channelId, + acceptedCumulative: 5_000_000n, + spent: 3_000_000n, + }), + ), + }, + }), + ) + + const second = await method.createCredential({ challenge, context: {} }) + const secondCred = deserializePayload(second) + expect(secondCred.payload.action).toBe('voucher') + if (secondCred.payload.action === 'voucher') { + expect(secondCred.payload.cumulativeAmount).toBe('6000000') + } + }) }) describe('onChannelUpdate callback', () => { diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index 2418db51..fbb07524 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -9,12 +9,20 @@ import * as Client from '../../viem/Client.js' import * as z from '../../zod.js' import * as defaults from '../internal/defaults.js' import * as Methods from '../Methods.js' -import type { SessionCredentialPayload } from '../session/Types.js' +import { deserializeSessionReceipt } from '../session/Receipt.js' +import type { + SessionChallengeMethodDetails, + SessionCredentialPayload, + SessionReceipt, +} from '../session/Types.js' import { signVoucher } from '../session/Voucher.js' import { type ChannelEntry, + createHintedChannelEntry, createOpenPayload, createVoucherPayload, + reconcileChannelEntry, + reconcileChannelReceipt, resolveEscrow, serializeCredential, tryRecoverChannel, @@ -110,14 +118,104 @@ export function session(parameters: session.Parameters = {}) { return resolveEscrow(challenge, chainId, parameters.escrowContract) } + function rememberChannel(key: string, entry: ChannelEntry) { + channels.set(key, entry) + channelIdToKey.set(entry.channelId, key) + escrowContractMap.set(entry.channelId, entry.escrowContract) + } + + function getChallengeHints( + challenge: Challenge.Challenge, + ): SessionChallengeMethodDetails | undefined { + return challenge.request.methodDetails as SessionChallengeMethodDetails | undefined + } + + function getContextCumulative(context?: SessionContext): bigint | undefined { + return context?.cumulativeAmountRaw + ? BigInt(context.cumulativeAmountRaw) + : context?.cumulativeAmount + ? parseUnits(context.cumulativeAmount, decimals) + : undefined + } + + function hydrateChannelFromHints( + channelId: Hex.Hex, + chainId: number, + escrowContract: Address, + hints: SessionChallengeMethodDetails | undefined, + ): ChannelEntry | undefined { + if ( + hints?.acceptedCumulative === undefined && + hints?.deposit === undefined && + hints?.spent === undefined + ) { + return undefined + } + + return createHintedChannelEntry({ + chainId, + channelId, + escrowContract, + hints: { + acceptedCumulative: hints.acceptedCumulative, + deposit: hints.deposit, + spent: hints.spent, + }, + }) + } + + async function resolveSuggestedChannel(parameters: { + challenge: Challenge.Challenge + chainId: number + client: Awaited> + context?: SessionContext | undefined + escrowContract: Address + key: string + suggestedChannelId: Hex.Hex + }): Promise { + const { challenge, chainId, client, context, escrowContract, key, suggestedChannelId } = + parameters + + const hinted = hydrateChannelFromHints( + suggestedChannelId, + chainId, + escrowContract, + getChallengeHints(challenge), + ) + if (hinted) { + const contextCumulative = getContextCumulative(context) + if (contextCumulative !== undefined) hinted.cumulativeAmount = contextCumulative + rememberChannel(key, hinted) + notifyUpdate(hinted) + return hinted + } + + const recovered = await tryRecoverChannel(client, escrowContract, suggestedChannelId, chainId) + if (!recovered) return undefined + + const contextCumulative = getContextCumulative(context) + if (contextCumulative !== undefined) recovered.cumulativeAmount = contextCumulative + rememberChannel(key, recovered) + notifyUpdate(recovered) + return recovered + } + + function reconcileReceipt(receipt: SessionReceipt) { + const key = channelIdToKey.get(receipt.channelId) + if (!key) return + + const entry = channels.get(key) + if (!entry) return + + if (reconcileChannelReceipt(entry, receipt)) notifyUpdate(entry) + } + async function autoManageCredential( challenge: Challenge.Challenge, account: viem_Account, context?: SessionContext, ): Promise { - const md = challenge.request.methodDetails as - | { chainId?: number; escrowContract?: string; channelId?: string; feePayer?: boolean } - | undefined + const md = getChallengeHints(challenge) const chainId = md?.chainId ?? 0 const client = await getClient({ chainId }) const escrowContract = resolveEscrowCached(challenge, chainId) @@ -145,29 +243,33 @@ export function session(parameters: session.Parameters = {}) { const key = channelKey(payee, currency, escrowContract) let entry = channels.get(key) + const suggestedChannelId = (context?.channelId ?? md?.channelId) as Hex.Hex | undefined + + if (entry && suggestedChannelId && entry.channelId !== suggestedChannelId) { + entry = await resolveSuggestedChannel({ + challenge, + chainId, + client, + context, + escrowContract, + key, + suggestedChannelId, + }) + } if (!entry) { - const suggestedChannelId = (context?.channelId ?? md?.channelId) as Hex.Hex | undefined if (suggestedChannelId) { - const recovered = await tryRecoverChannel( + entry = await resolveSuggestedChannel({ + challenge, + chainId, client, + context, escrowContract, + key, suggestedChannelId, - chainId, - ) - if (recovered) { - const contextCumulative = context?.cumulativeAmountRaw - ? BigInt(context.cumulativeAmountRaw) - : context?.cumulativeAmount - ? parseUnits(context.cumulativeAmount, decimals) - : undefined - if (contextCumulative !== undefined) recovered.cumulativeAmount = contextCumulative - channels.set(key, recovered) - channelIdToKey.set(recovered.channelId, key) - escrowContractMap.set(recovered.channelId, escrowContract) - entry = recovered - notifyUpdate(entry) - } else if (context?.channelId) { + }) + + if (!entry && context?.channelId) { throw new Error( `Channel ${context.channelId} cannot be reused (closed or not found on-chain).`, ) @@ -175,10 +277,23 @@ export function session(parameters: session.Parameters = {}) { } } + if ( + entry && + reconcileChannelEntry(entry, { + acceptedCumulative: md?.acceptedCumulative, + deposit: md?.deposit, + spent: md?.spent, + }) + ) { + notifyUpdate(entry) + } + let payload: SessionCredentialPayload if (entry?.opened) { - entry.cumulativeAmount += amount + entry.cumulativeAmount = md?.requiredCumulative + ? BigInt(md.requiredCumulative) + : entry.cumulativeAmount + amount payload = await createVoucherPayload( client, account, @@ -200,9 +315,7 @@ export function session(parameters: session.Parameters = {}) { chainId, feePayer: md?.feePayer, }) - channels.set(key, result.entry) - channelIdToKey.set(result.entry.channelId, key) - escrowContractMap.set(result.entry.channelId, escrowContract) + rememberChannel(key, result.entry) payload = result.payload notifyUpdate(result.entry) } @@ -215,9 +328,7 @@ export function session(parameters: session.Parameters = {}) { account: viem_Account, context: SessionContext, ): Promise { - const md = challenge.request.methodDetails as - | { chainId?: number; escrowContract?: string; channelId?: string } - | undefined + const md = getChallengeHints(challenge) const chainId = md?.chainId ?? 0 const client = await getClient({ chainId }) @@ -341,24 +452,36 @@ export function session(parameters: session.Parameters = {}) { return serializeCredential(challenge, payload, chainId, account) } - return Method.toClient(Methods.session, { - context: sessionContextSchema, + return Object.assign( + Method.toClient(Methods.session, { + context: sessionContextSchema, - async createCredential({ challenge, context }) { - const chainId = challenge.request.methodDetails?.chainId ?? 0 - const client = await getClient({ chainId }) - const account = getAccount(client, context) + async createCredential({ challenge, context }) { + const chainId = challenge.request.methodDetails?.chainId ?? 0 + const client = await getClient({ chainId }) + const account = getAccount(client, context) - if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) - return autoManageCredential(challenge, account, context) + if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) + return autoManageCredential(challenge, account, context) - if (context?.action) return manualCredential(challenge, account, context) + if (context?.action) return manualCredential(challenge, account, context) - throw new Error( - 'No `action` in context and no `deposit` or `maxDeposit` configured. Either provide context with action/channelId/cumulativeAmount, or configure `deposit`/`maxDeposit` for auto-management.', - ) + throw new Error( + 'No `action` in context and no `deposit` or `maxDeposit` configured. Either provide context with action/channelId/cumulativeAmount, or configure `deposit`/`maxDeposit` for auto-management.', + ) + }, + }), + { + onResponse(response: Response) { + const receiptHeader = response.headers.get('Payment-Receipt') + if (!receiptHeader) return + + try { + reconcileReceipt(deserializeSessionReceipt(receiptHeader)) + } catch {} + }, }, - }) + ) } export declare namespace session { diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index dac4a8fb..b2ca5b00 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -11,7 +11,7 @@ import { deserializeSessionReceipt } from '../session/Receipt.js' import { parseEvent } from '../session/Sse.js' import type { SessionCredentialPayload, SessionReceipt } from '../session/Types.js' import * as Ws from '../session/Ws.js' -import type { ChannelEntry } from './ChannelOps.js' +import { reconcileChannelReceipt, type ChannelEntry } from './ChannelOps.js' import { session as sessionPlugin } from './Session.js' type WebSocketConstructor = { @@ -93,10 +93,10 @@ export type PaymentResponse = Response & { * the session is lost and a new on-chain channel will be opened on the next * request — the previous channel's deposit is orphaned until manually closed. * - * When the server includes a `channelId` in the 402 challenge `methodDetails`, - * the client will attempt to recover the channel by reading its on-chain state - * via `getOnChainChannel()`. If the channel has a positive deposit and is not - * finalized, it resumes from the on-chain settled amount. + * When the server includes session hints in the 402 challenge `methodDetails`, + * the client resumes from those authoritative values first. If only a + * `channelId` is available, it falls back to reading on-chain state via + * `getOnChainChannel()` and resumes from the on-chain settled amount. */ export function sessionManager(parameters: sessionManager.Parameters): SessionManager { const fetchFn = parameters.fetch ?? globalThis.fetch @@ -132,6 +132,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa onChannelUpdate(entry) { if (entry.channelId !== channel?.channelId) spent = 0n channel = entry + spent = entry.spent }, }) @@ -148,10 +149,36 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { if (!receipt || receipt.channelId !== channel?.channelId) return assertReceiptWithinLocalState(receipt) + if (reconcileChannelReceipt(channel, receipt)) { + spent = channel.spent + return + } const next = BigInt(receipt.spent) spent = spent > next ? spent : next } + function reconcileReceipt(receipt: SessionReceipt | null | undefined) { + if (!receipt) return + if (channel && receipt.channelId === channel.channelId) { + updateSpentFromReceipt(receipt) + return + } + spent = BigInt(receipt.spent) + } + + function reconcileResponse(response: Response): SessionReceipt | undefined { + const receiptHeader = response.headers.get('Payment-Receipt') + if (!receiptHeader) return undefined + + try { + const receipt = deserializeSessionReceipt(receiptHeader) + reconcileReceipt(receipt) + return receipt + } catch { + return undefined + } + } + function assertReceiptWithinLocalState(receipt: SessionReceipt) { if (!channel || receipt.channelId !== channel.channelId) return const acceptedCumulative = BigInt(receipt.acceptedCumulative) @@ -250,9 +277,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa } function toPaymentResponse(response: Response): PaymentResponse { - const receiptHeader = response.headers.get('Payment-Receipt') - const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : null - updateSpentFromReceipt(receipt) + const receipt = reconcileResponse(response) ?? null return Object.assign(response, { receipt, challenge: lastChallenge, @@ -434,6 +459,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa `Open request failed with status ${response.status}${body ? `: ${body}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`, ) } + reconcileResponse(response) }, fetch: doFetch, @@ -509,11 +535,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (!voucherResponse.ok) { throw new Error(`Voucher POST failed with status ${voucherResponse.status}`) } + reconcileResponse(voucherResponse) break } case 'payment-receipt': - updateSpentFromReceipt(event.data) + reconcileReceipt(event.data) onReceipt?.(event.data) break } @@ -840,8 +867,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa `Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`, ) } - const receiptHeader = response.headers.get('Payment-Receipt') - const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined + const receipt = reconcileResponse(response) return receipt }, diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 6e09a29b..4e0aaca2 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -3786,6 +3786,47 @@ describe.runIf(isLocalnet)('session', () => { }) describe('respond', () => { + test('request() adds reusable channel hints to challenge data', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 10_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 5_000_000n, + highestVoucher: { + channelId, + cumulativeAmount: 5_000_000n, + signature: '0xdeadbeef' as Hex, + }, + spent: 5_000_000n, + units: 5, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer() + const request = await server.request!({ + credential: undefined, + request: { + ...makeRequest(), + amount: '1', + channelId, + }, + }) + + expect(request.acceptedCumulative).toBe('5000000') + expect(request.deposit).toBe('10000000') + expect(request.requiredCumulative).toBe('6000000') + expect(request.spent).toBe('5000000') + }) + test('returns 204 for POST with open action', () => { const server = createServer() const result = server.respond!({ diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index a9bc85ac..452e7dff 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -51,18 +51,45 @@ import { } from '../session/Chain.js' import * as ChannelStore from '../session/ChannelStore.js' import { createSessionReceipt } from '../session/Receipt.js' -import type { SessionCredentialPayload, SessionReceipt, SignedVoucher } from '../session/Types.js' +import type { + SessionChallengeMethodDetails, + SessionCredentialPayload, + SessionReceipt, + SignedVoucher, +} from '../session/Types.js' import { parseVoucherFromPayload, verifyVoucher } from '../session/Voucher.js' import { captureRequestBodyProbe, isSessionContentRequest } from './internal/request-body.js' import * as Transport from './internal/transport.js' /** Challenge methodDetails shape for session methods. */ -type SessionMethodDetails = { +type SessionMethodDetails = SessionChallengeMethodDetails & { escrowContract: Address chainId: number - channelId?: Hex | undefined - minVoucherDelta?: string | undefined - feePayer?: boolean | undefined +} + +function createChallengeHints( + channel: ChannelStore.State | null, + amount: bigint | undefined, +): + | Pick + | undefined { + if (!channel || channel.finalized || channel.deposit === 0n || channel.closeRequestedAt !== 0n) + return undefined + + const requiredCumulative = (() => { + if (amount === undefined) return undefined + const nextSpent = channel.spent + amount + const target = + nextSpent > channel.highestVoucherAmount ? nextSpent : channel.highestVoucherAmount + return target.toString() + })() + + return { + acceptedCumulative: channel.highestVoucherAmount.toString(), + deposit: channel.deposit.toString(), + ...(requiredCumulative !== undefined && { requiredCumulative }), + spent: channel.spent.toString(), + } } /** @@ -166,6 +193,11 @@ export function session( parameters.escrowContract ?? defaults.escrowContract[chainId as keyof typeof defaults.escrowContract] + const amount = parseUnits(request.amount, request.decimals ?? decimals) + const challengeHints = request.channelId + ? createChallengeHints(await store.getChannel(request.channelId as Hex), amount) + : undefined + // Extract feePayer. const resolvedFeePayer = (() => { if (request.feePayer === false) return credential ? false : undefined @@ -178,6 +210,7 @@ export function session( return { ...request, + ...challengeHints, chainId, escrowContract: resolvedEscrow, feePayer: resolvedFeePayer, diff --git a/src/tempo/session/Types.ts b/src/tempo/session/Types.ts index 0a4cf7c9..22f78eb4 100644 --- a/src/tempo/session/Types.ts +++ b/src/tempo/session/Types.ts @@ -49,6 +49,24 @@ export type SessionCredentialPayload = signature: Hex } +/** + * Optional session state hints carried in `challenge.request.methodDetails`. + * + * These fields are additive reconciliation hints, not protocol requirements. + * Amounts are serialized in raw base units so clients can reuse them directly. + */ +export interface SessionChallengeMethodDetails { + acceptedCumulative?: string | undefined + chainId?: number | undefined + channelId?: Hex | undefined + deposit?: string | undefined + escrowContract?: Address | undefined + feePayer?: boolean | undefined + minVoucherDelta?: string | undefined + requiredCumulative?: string | undefined + spent?: string | undefined +} + /** * SSE event emitted when session balance is exhausted mid-stream. * The client responds by sending a new voucher credential. From 8d7045dba8da9ae203333f1e535b326a74459b94 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:45:45 -0700 Subject: [PATCH 2/6] fix monotonic session hint reconciliation --- src/tempo/client/ChannelOps.test.ts | 53 ++++++++++++ src/tempo/client/ChannelOps.ts | 14 ++-- src/tempo/client/Session.test.ts | 60 ++++++++++++++ src/tempo/client/Session.ts | 13 ++- src/tempo/client/SessionManager.test.ts | 103 +++++++++++++++++++++++- src/tempo/client/SessionManager.ts | 1 + src/tempo/server/Session.test.ts | 41 ++++++++++ 7 files changed, 271 insertions(+), 14 deletions(-) diff --git a/src/tempo/client/ChannelOps.test.ts b/src/tempo/client/ChannelOps.test.ts index 75fffbed..11529fb3 100644 --- a/src/tempo/client/ChannelOps.test.ts +++ b/src/tempo/client/ChannelOps.test.ts @@ -17,9 +17,11 @@ import { } from '../internal/defaults.js' import { verifyVoucher } from '../session/Voucher.js' import { + createHintedChannelEntry, createClosePayload, createOpenPayload, createVoucherPayload, + reconcileChannelEntry, resolveEscrow, serializeCredential, tryRecoverChannel, @@ -169,6 +171,57 @@ describe('createClosePayload', () => { }) }) +describe('reconcileChannelEntry', () => { + test('does not move channel state backwards for stale snapshots', () => { + const entry = createHintedChannelEntry({ + chainId, + channelId, + escrowContract, + hints: { + acceptedCumulative: '6000000', + deposit: '10000000', + spent: '4000000', + }, + }) + entry.cumulativeAmount = 7_000_000n + + const changed = reconcileChannelEntry(entry, { + acceptedCumulative: '5000000', + deposit: '9000000', + spent: '3000000', + }) + + expect(changed).toBe(false) + expect(entry.acceptedCumulative).toBe(6_000_000n) + expect(entry.cumulativeAmount).toBe(7_000_000n) + expect(entry.deposit).toBe(10_000_000n) + expect(entry.spent).toBe(4_000_000n) + }) + + test('raises spent without lowering a newer local cumulative amount', () => { + const entry = createHintedChannelEntry({ + chainId, + channelId, + escrowContract, + hints: { + acceptedCumulative: '5000000', + deposit: '10000000', + spent: '3000000', + }, + }) + entry.cumulativeAmount = 7_000_000n + + const changed = reconcileChannelEntry(entry, { + spent: '6000000', + }) + + expect(changed).toBe(true) + expect(entry.acceptedCumulative).toBe(6_000_000n) + expect(entry.cumulativeAmount).toBe(7_000_000n) + expect(entry.spent).toBe(6_000_000n) + }) +}) + describe.runIf(isLocalnet)('createOpenPayload', () => { const payer = accounts[2] const payee = accounts[1].address diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index 7d29d0cd..bcecaa2f 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -86,14 +86,18 @@ function toBigInt(value: bigint | string): bigint { return typeof value === 'bigint' ? value : BigInt(value) } +function maxBigInt(current: bigint, next: bigint): bigint { + return current > next ? current : next +} + export function createHintedChannelEntry(options: { chainId: number channelId: Hex.Hex escrowContract: Address hints: Pick }): ChannelEntry { - const acceptedCumulative = BigInt(options.hints.acceptedCumulative ?? options.hints.spent ?? '0') const spent = BigInt(options.hints.spent ?? options.hints.acceptedCumulative ?? '0') + const acceptedCumulative = maxBigInt(BigInt(options.hints.acceptedCumulative ?? '0'), spent) return { acceptedCumulative, @@ -113,11 +117,11 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap if (snapshot.acceptedCumulative !== undefined) { const acceptedCumulative = toBigInt(snapshot.acceptedCumulative) - if (entry.acceptedCumulative !== acceptedCumulative) { + if (acceptedCumulative > entry.acceptedCumulative) { entry.acceptedCumulative = acceptedCumulative changed = true } - if (entry.cumulativeAmount !== acceptedCumulative) { + if (acceptedCumulative > entry.cumulativeAmount) { entry.cumulativeAmount = acceptedCumulative changed = true } @@ -125,7 +129,7 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap if (snapshot.spent !== undefined) { const spent = toBigInt(snapshot.spent) - if (entry.spent !== spent) { + if (spent > entry.spent) { entry.spent = spent changed = true } @@ -141,7 +145,7 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap if (snapshot.deposit !== undefined) { const deposit = toBigInt(snapshot.deposit) - if (entry.deposit !== deposit) { + if (entry.deposit === undefined || deposit > entry.deposit) { entry.deposit = deposit changed = true } diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index 10a7cacd..f9bde131 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -99,6 +99,38 @@ describe('session (pure)', () => { expect(cred.payload.cumulativeAmount).toBe('6000000') } }) + + test('does not let stale requiredCumulative move local cumulative backwards', async () => { + const method = session({ + getClient: () => pureClient, + account: pureAccount, + deposit: '10', + }) + + const challenge = makeChallenge({ + methodDetails: { + acceptedCumulative: '5000000', + chainId: 42431, + channelId, + deposit: '10000000', + escrowContract: escrowAddress, + requiredCumulative: '6000000', + spent: '5000000', + }, + }) + + const first = deserializePayload(await method.createCredential({ challenge, context: {} })) + const second = deserializePayload(await method.createCredential({ challenge, context: {} })) + + expect(first.payload.action).toBe('voucher') + expect(second.payload.action).toBe('voucher') + if (first.payload.action === 'voucher') { + expect(first.payload.cumulativeAmount).toBe('6000000') + } + if (second.payload.action === 'voucher') { + expect(second.payload.cumulativeAmount).toBe('7000000') + } + }) }) describe('manual action validation', () => { @@ -506,6 +538,34 @@ describe.runIf(isLocalnet)('session (on-chain)', () => { }), ).rejects.toThrow('cannot be reused') }) + + test('falls back to opening a new channel when hints omit cumulative state', async () => { + const hintedChannelId = + '0x0000000000000000000000000000000000000000000000000000000000000bad' as Hex + const method = session({ + getClient: () => client, + account: payer, + deposit: '10', + escrowContract, + }) + + const challenge = makeLiveChallenge({ + methodDetails: { + chainId: chain.id, + channelId: hintedChannelId, + deposit: '10000000', + escrowContract, + }, + }) + + const result = await method.createCredential({ challenge, context: {} }) + const cred = deserializePayload(result) + + expect(cred.payload.action).toBe('open') + if (cred.payload.action === 'open') { + expect(cred.payload.channelId).not.toBe(hintedChannelId) + } + }) }) describe('cumulative tracking in auto mode', () => { diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index fbb07524..575cf500 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -144,11 +144,7 @@ export function session(parameters: session.Parameters = {}) { escrowContract: Address, hints: SessionChallengeMethodDetails | undefined, ): ChannelEntry | undefined { - if ( - hints?.acceptedCumulative === undefined && - hints?.deposit === undefined && - hints?.spent === undefined - ) { + if (hints?.acceptedCumulative === undefined && hints?.spent === undefined) { return undefined } @@ -291,9 +287,10 @@ export function session(parameters: session.Parameters = {}) { let payload: SessionCredentialPayload if (entry?.opened) { - entry.cumulativeAmount = md?.requiredCumulative - ? BigInt(md.requiredCumulative) - : entry.cumulativeAmount + amount + const nextCumulative = entry.cumulativeAmount + amount + const requiredCumulative = md?.requiredCumulative ? BigInt(md.requiredCumulative) : 0n + entry.cumulativeAmount = + nextCumulative > requiredCumulative ? nextCumulative : requiredCumulative payload = await createVoucherPayload( client, account, diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 2d5550f6..c676ca6f 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -1,14 +1,30 @@ +import { createClient, http } from 'viem' import type { Hex } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' import { describe, expect, test, vi } from 'vp/test' import * as Challenge from '../../Challenge.js' +import * as Credential from '../../Credential.js' +import { createSessionReceipt, serializeSessionReceipt } from '../session/Receipt.js' import { formatNeedVoucherEvent, parseEvent } from '../session/Sse.js' -import type { NeedVoucherEvent, SessionReceipt } from '../session/Types.js' +import type { + NeedVoucherEvent, + SessionCredentialPayload, + SessionReceipt, +} from '../session/Types.js' import { sessionManager } from './SessionManager.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex +const staleChannelId = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex const challengeId = 'test-challenge-1' const realm = 'test.example.com' +const account = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const paymentClient = createClient({ + account, + transport: http('http://127.0.0.1'), +}) function makeChallenge(overrides: Record = {}): Challenge.Challenge { return Challenge.from({ @@ -50,6 +66,15 @@ function makeSseResponse(events: string[]): Response { }) } +function makeReceiptResponse(receipt: SessionReceipt, body?: string): Response { + return new Response(body ?? 'ok', { + status: 200, + headers: { + 'Payment-Receipt': serializeSessionReceipt(receipt), + }, + }) +} + describe('Session', () => { describe('parseEvent round-trip via SSE', () => { test('parses message events from SSE stream', () => { @@ -325,5 +350,81 @@ describe('Session', () => { await s.close() expect(mockFetch).not.toHaveBeenCalled() }) + + test('ignores delayed receipts for other channels when closing the active channel', async () => { + let callCount = 0 + const mockFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount++ + + if (callCount === 1) { + return make402Response( + makeChallenge({ + methodDetails: { + acceptedCumulative: '5000000', + chainId: 4217, + channelId, + deposit: '10000000', + escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', + requiredCumulative: '6000000', + spent: '5000000', + }, + }), + ) + } + + if (callCount === 2) { + return makeReceiptResponse( + createSessionReceipt({ + challengeId, + channelId, + acceptedCumulative: 6_000_000n, + spent: 6_000_000n, + }), + ) + } + + if (callCount === 3) { + return makeReceiptResponse( + createSessionReceipt({ + challengeId, + channelId: staleChannelId, + acceptedCumulative: 1_000_000n, + spent: 1_000_000n, + }), + ) + } + + const authorization = new Headers(init?.headers).get('Authorization') + if (!authorization) throw new Error('expected Authorization header on close') + + const credential = Credential.deserialize(authorization) + expect(credential.payload.action).toBe('close') + if (credential.payload.action === 'close') { + expect(credential.payload.cumulativeAmount).toBe('6000000') + } + + return makeReceiptResponse( + createSessionReceipt({ + challengeId, + channelId, + acceptedCumulative: 6_000_000n, + spent: 6_000_000n, + }), + ) + }) + + const s = sessionManager({ + account, + client: paymentClient as never, + fetch: mockFetch as typeof globalThis.fetch, + maxDeposit: '10', + }) + + await s.fetch('https://api.example.com/data') + await s.fetch('https://api.example.com/data') + await s.close() + + expect(mockFetch).toHaveBeenCalledTimes(4) + }) }) }) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index b2ca5b00..9ebb9727 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -163,6 +163,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa updateSpentFromReceipt(receipt) return } + if (channel) return spent = BigInt(receipt.spent) } diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 4e0aaca2..03153059 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -3827,6 +3827,47 @@ describe.runIf(isLocalnet)('session', () => { expect(request.spent).toBe('5000000') }) + test('request() omits reuse hints when the stored channel is closing', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000ab' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 10_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 5_000_000n, + highestVoucher: { + channelId, + cumulativeAmount: 5_000_000n, + signature: '0xdeadbeef' as Hex, + }, + spent: 5_000_000n, + units: 5, + closeRequestedAt: 1n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer() + const request = await server.request!({ + credential: undefined, + request: { + ...makeRequest(), + amount: '1', + channelId, + }, + }) + + expect(request.acceptedCumulative).toBeUndefined() + expect(request.deposit).toBeUndefined() + expect(request.requiredCumulative).toBeUndefined() + expect(request.spent).toBeUndefined() + }) + test('returns 204 for POST with open action', () => { const server = createServer() const result = server.respond!({ From af1b9e6b872a26265c06ea8bcb4f10d402d36314 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:17:45 -0700 Subject: [PATCH 3/6] feat: add stateless session resume --- src/Method.ts | 3 +- src/server/Mppx.ts | 5 +- src/tempo/client/SessionManager.test.ts | 78 +++++++++++ src/tempo/client/SessionManager.ts | 94 +++++++++++-- src/tempo/server/Charge.test.ts | 32 +++++ src/tempo/server/Session.test.ts | 106 ++++++++++++++ src/tempo/server/Session.ts | 74 +++++++++- src/tempo/session/ChannelStore.test.ts | 43 ++++++ src/tempo/session/ChannelStore.ts | 178 +++++++++++++++++++++++- 9 files changed, 588 insertions(+), 25 deletions(-) diff --git a/src/Method.ts b/src/Method.ts index 958f3622..efad9ba5 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -95,7 +95,8 @@ export type VerifiedChallengeEnvelope< /** Request hook parameters for a single method. */ export type RequestContext = { capturedRequest?: CapturedRequest - credential?: Credential.Credential | null + credential?: Credential.Credential | null | undefined + input?: globalThis.Request | undefined request: z.input } diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 061934fc..38a07163 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -824,7 +824,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R method, request: parameters.request, }) as never, - ) + ) return response } @@ -858,6 +858,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R defaults, description, expires, + input: input instanceof globalThis.Request ? input : undefined, meta: effectiveMeta, method, realm, @@ -1639,6 +1640,7 @@ async function resolveRouteChallenge(parameters: { defaults?: Record | undefined description?: string | undefined expires?: string | undefined + input?: globalThis.Request | undefined meta?: Record | undefined method: Method.Method realm?: string | undefined @@ -1659,6 +1661,7 @@ async function resolveRouteChallenge(parameters: { ? ((await parameters.request({ capturedRequest: parameters.capturedRequest, credential: parameters.credential, + input: parameters.input, request: merged, } as never)) as Record) : merged diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index c676ca6f..df27d2ad 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -193,6 +193,84 @@ describe('Session', () => { expect(configuredOrderChallenges).not.toHaveBeenCalled() expect(requestOrderChallenges).toHaveBeenCalledOnce() }) + + test('performs zero-dollar auth before stateless session resume', async () => { + const authChallenge = Challenge.from({ + id: 'auth-challenge-1', + realm, + method: 'tempo', + intent: 'charge', + request: { + amount: '0', + currency: '0x20c0000000000000000000000000000000000001', + recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + decimals: 6, + methodDetails: { + chainId: 4217, + }, + }, + }) + + let callCount = 0 + const mockFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + callCount++ + + if (callCount === 1) return make402Response(authChallenge) + + const authorization = new Headers(init?.headers).get('Authorization') + if (!authorization) throw new Error('expected Authorization header') + + if (callCount === 2) { + const credential = Credential.deserialize<{ type: string }>(authorization) + expect(credential.payload.type).toBe('proof') + expect(credential.source).toBe(`did:pkh:eip155:4217:${account.address}`) + return make402Response( + makeChallenge({ + methodDetails: { + acceptedCumulative: '5000000', + chainId: 4217, + channelId, + deposit: '10000000', + escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', + requiredCumulative: '6000000', + spent: '5000000', + }, + }), + ) + } + + const credential = Credential.deserialize(authorization) + expect(credential.payload.action).toBe('voucher') + if (credential.payload.action === 'voucher') { + expect(credential.payload.channelId).toBe(channelId) + expect(credential.payload.cumulativeAmount).toBe('6000000') + } + + return makeReceiptResponse( + createSessionReceipt({ + challengeId, + channelId, + acceptedCumulative: 6_000_000n, + spent: 6_000_000n, + }), + ) + }) + + const s = sessionManager({ + account, + client: paymentClient as never, + fetch: mockFetch as typeof globalThis.fetch, + maxDeposit: '10', + }) + + const response = await s.fetch('https://api.example.com/data') + + expect(response.status).toBe(200) + expect(response.receipt?.acceptedCumulative).toBe('6000000') + expect(s.channelId).toBe(channelId) + expect(s.cumulative).toBe(6_000_000n) + expect(mockFetch).toHaveBeenCalledTimes(3) + }) }) describe('.ws()', () => { diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 9ebb9727..1af205b1 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -12,6 +12,7 @@ import { parseEvent } from '../session/Sse.js' import type { SessionCredentialPayload, SessionReceipt } from '../session/Types.js' import * as Ws from '../session/Ws.js' import { reconcileChannelReceipt, type ChannelEntry } from './ChannelOps.js' +import { charge as chargePlugin } from './Charge.js' import { session as sessionPlugin } from './Session.js' type WebSocketConstructor = { @@ -83,9 +84,9 @@ export type PaymentResponse = Response & { * Creates a session manager that handles the full client payment lifecycle: * channel open, incremental vouchers, SSE streaming, and channel close. * - * Internally delegates to the `session()` method for all - * channel state management and credential creation, and to `Fetch.from` - * for the 402 challenge/retry flow. + * Internally delegates to the `session()` method for channel state + * management and credential creation, while owning a bounded 402 retry + * loop for zero-auth bootstrap and stateless resume. * * ## Session resumption * @@ -135,15 +136,9 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa spent = entry.spent }, }) - - const wrappedFetch = Fetch.from({ - fetch: fetchFn, - methods: [method], - onChallenge: async (challenge, _helpers) => { - lastChallenge = challenge - return undefined - }, - orderChallenges: parameters.orderChallenges, + const authMethod = chargePlugin({ + account: parameters.account, + getClient: parameters.client ? () => parameters.client! : parameters.getClient, }) function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { @@ -157,6 +152,26 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa spent = spent > next ? spent : next } + function isZeroAuthChallenge(challenge: Challenge.Challenge): boolean { + return ( + challenge.method === 'tempo' && + challenge.intent === 'charge' && + challenge.request.amount === '0' + ) + } + + function withAuthorizationHeader( + headers: RequestInit['headers'], + credential: string, + ): Record { + const normalized = Fetch.normalizeHeaders(headers) + for (const key of Object.keys(normalized)) { + if (key.toLowerCase() === 'authorization') delete normalized[key] + } + normalized.Authorization = credential + return normalized + } + function reconcileReceipt(receipt: SessionReceipt | null | undefined) { if (!receipt) return if (channel && receipt.channelId === channel.channelId) { @@ -292,7 +307,60 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa init?: SessionRequestInit, ): Promise { lastUrl = input - const response = await wrappedFetch(input, init) + const { orderChallenges: requestOrderChallenges, ...fetchInit } = init ?? {} + let response = await fetchFn(input, fetchInit) + let attemptedZeroAuth = false + + for (let attempts = 0; response.status === 402 && attempts < 3; attempts++) { + const challenges = Challenge.fromResponseList(response) + const sessionCandidates = AcceptPayment.selectChallengeCandidates( + challenges, + [method] as const, + AcceptPayment.resolve([method] as const).entries, + ) + const orderedSessionCandidates = requestOrderChallenges + ? await requestOrderChallenges(sessionCandidates) + : parameters.orderChallenges + ? await parameters.orderChallenges(sessionCandidates) + : sessionCandidates + const sessionChallenge = orderedSessionCandidates[0]?.challenge + if (sessionChallenge) lastChallenge = sessionChallenge + else if (challenges[0]) lastChallenge = challenges[0] + + const zeroAuthChallenge = + !channel && !attemptedZeroAuth ? challenges.find(isZeroAuthChallenge) : undefined + + if (zeroAuthChallenge) { + attemptedZeroAuth = true + const credential = await authMethod.createCredential({ + challenge: zeroAuthChallenge as never, + context: {}, + }) + response = await fetchFn(input, { + ...fetchInit, + headers: withAuthorizationHeader(fetchInit.headers, credential), + }) + continue + } + + if (sessionCandidates.length > 0 && !sessionChallenge) { + throw new Error( + `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}`, + ) + } + if (!sessionChallenge) break + + const credential = await method.createCredential({ + challenge: sessionChallenge as never, + context: {}, + }) + response = await fetchFn(input, { + ...fetchInit, + headers: withAuthorizationHeader(fetchInit.headers, credential), + }) + } + + await method.onResponse?.(response) return toPaymentResponse(response) } diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index f3bccbd8..c5787184 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -46,6 +46,38 @@ const server = Mppx_server.create({ }) describe('tempo', () => { + describe('intent: charge; type: proof (zero-dollar auth)', () => { + test('default: end-to-end zero-dollar auth via SDK', async () => { + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + getClient: () => client, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ amount: '0', decimals: 6 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await mppx.fetch(httpServer.url) + expect(response.status).toBe(200) + + const receipt = Receipt.fromResponse(response) + expect(receipt.status).toBe('success') + expect(receipt.method).toBe('tempo') + expect(receipt.reference).toBeDefined() + + httpServer.close() + }) + }) + describe('intent: charge; type: hash', () => { test('default', async () => { const mppx = Mppx_client.create({ diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 03153059..e5eb1e65 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -3786,6 +3786,112 @@ describe.runIf(isLocalnet)('session', () => { }) describe('respond', () => { + test('request() discovers reusable channel hints from resolved payer source', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000ac' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 10_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 5_000_000n, + highestVoucher: { + channelId, + cumulativeAmount: 5_000_000n, + signature: '0xdeadbeef' as Hex, + }, + spent: 5_000_000n, + units: 5, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer({ + resolveSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, + }) + const request = await server.request!({ + credential: undefined, + input: new Request('http://localhost'), + request: { + ...makeRequest(), + amount: '1', + }, + }) + + expect(request.channelId).toBe(channelId) + expect(request.acceptedCumulative).toBe('5000000') + expect(request.deposit).toBe('10000000') + expect(request.requiredCumulative).toBe('6000000') + expect(request.spent).toBe('5000000') + }) + + test('request() keeps explicit channelId as the discovery fast path', async () => { + const explicitChannelId = + '0x00000000000000000000000000000000000000000000000000000000000000ad' as Hex + const discoveredChannelId = + '0x00000000000000000000000000000000000000000000000000000000000000ae' as Hex + + await store.updateChannel(explicitChannelId, () => ({ + channelId: explicitChannelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 8_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 4_000_000n, + highestVoucher: null, + spent: 4_000_000n, + units: 4, + closeRequestedAt: 0n, + finalized: false, + createdAt: '2025-01-01T00:00:00.000Z', + })) + await store.updateChannel(discoveredChannelId, () => ({ + channelId: discoveredChannelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 12_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 6_000_000n, + highestVoucher: null, + spent: 6_000_000n, + units: 6, + closeRequestedAt: 0n, + finalized: false, + createdAt: '2025-02-01T00:00:00.000Z', + })) + + const server = createServer({ + resolveSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, + }) + const request = await server.request!({ + credential: undefined, + input: new Request('http://localhost'), + request: { + ...makeRequest(), + amount: '1', + channelId: explicitChannelId, + }, + }) + + expect(request.channelId).toBe(explicitChannelId) + expect(request.acceptedCumulative).toBe('4000000') + expect(request.requiredCumulative).toBe('5000000') + expect(request.spent).toBe('4000000') + }) + test('request() adds reusable channel hints to challenge data', async () => { const channelId = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex await store.updateChannel(channelId, () => ({ diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index 452e7dff..7165319f 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -31,7 +31,7 @@ import { VerificationFailedError, } from '../../Errors.js' import type { Challenge, Credential } from '../../index.js' -import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' +import type { LooseOmit, MaybePromise, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' @@ -39,6 +39,7 @@ import type * as z from '../../zod.js' import * as Account from '../internal/account.js' import * as defaults from '../internal/defaults.js' import * as FeePayer from '../internal/fee-payer.js' +import * as Proof from '../internal/proof.js' import type * as types from '../internal/types.js' import * as Methods from '../Methods.js' import { @@ -92,6 +93,35 @@ function createChallengeHints( } } +async function findRequestedChannel(parameters: { + amount: bigint + request: { channelId?: Hex | undefined; currency: Address; recipient: Address } + resolvedEscrow: Address + chainId: number + source: string | undefined + store: ChannelStore.ChannelStore +}): Promise { + const { amount, chainId, request, resolvedEscrow, source, store } = parameters + + if (request.channelId) { + return store.getChannel(request.channelId) + } + + if (!source) return null + + const payer = Proof.parsePkhSource(source) + if (!payer || payer.chainId !== chainId) return null + + return ChannelStore.findReusableChannel(store, { + amount, + chainId, + escrowContract: resolvedEscrow, + payee: request.recipient, + payer: payer.address, + token: request.currency, + }) +} + /** * Creates a session payment server using the Method.toServer() pattern. * @@ -169,7 +199,7 @@ export function session( transport: transport as never, // TODO: dedupe `{charge,session}.request` - async request({ credential, request }) { + async request({ credential, input, request }) { // Extract chainId from request or default. const chainId = await (async () => { if (request.chainId) return request.chainId @@ -194,9 +224,20 @@ export function session( defaults.escrowContract[chainId as keyof typeof defaults.escrowContract] const amount = parseUnits(request.amount, request.decimals ?? decimals) - const challengeHints = request.channelId - ? createChallengeHints(await store.getChannel(request.channelId as Hex), amount) - : undefined + const source = await parameters.resolveSource?.({ credential, input, request }) + const requestedChannel = await findRequestedChannel({ + amount, + chainId: chainId as number, + request: { + channelId: request.channelId as Hex | undefined, + currency: request.currency as Address, + recipient: request.recipient as Address, + }, + resolvedEscrow: resolvedEscrow as Address, + source, + store, + }) + const challengeHints = createChallengeHints(requestedChannel, amount) // Extract feePayer. const resolvedFeePayer = (() => { @@ -211,8 +252,11 @@ export function session( return { ...request, ...challengeHints, - chainId, - escrowContract: resolvedEscrow, + ...(!request.channelId && requestedChannel + ? { channelId: requestedChannel.channelId } + : {}), + chainId: chainId as number, + escrowContract: resolvedEscrow as Address, feePayer: resolvedFeePayer, } }, @@ -370,6 +414,22 @@ export declare namespace session { feePayerPolicy?: FeePayerPolicy | undefined /** Minimum voucher delta to accept (numeric string, default: "0"). */ minVoucherDelta?: string | undefined + /** + * Resolves the authenticated payer identity used for stateless channel + * discovery when the client does not provide a channelId. + * + * Return the zero-dollar proof source DID (for example + * `did:pkh:eip155:4217:0x...`) from your server-managed auth/session + * context, such as a cookie-backed login established by `tempo.charge` + * proof auth. + */ + resolveSource?: + | ((options: { + credential?: Credential.Credential | null | undefined + input?: globalThis.Request | undefined + request: Method.RequestDefaults + }) => MaybePromise) + | undefined /** * Whether to wait for the open transaction to confirm on-chain before * responding. @default true diff --git a/src/tempo/session/ChannelStore.test.ts b/src/tempo/session/ChannelStore.test.ts index 6fe9944d..aae7e88e 100644 --- a/src/tempo/session/ChannelStore.test.ts +++ b/src/tempo/session/ChannelStore.test.ts @@ -252,6 +252,49 @@ describe('channelStore', () => { expect(resolved).toBe(true) }) }) + + describe('findReusableChannel', () => { + test('selects the newest viable channel for matching payer and session dimensions', async () => { + const cs = ChannelStore.fromStore(Store.memory()) + + await cs.updateChannel(channelId, () => + makeChannel({ + channelId, + createdAt: '2025-01-01T00:00:00.000Z', + highestVoucherAmount: 6_000_000n, + spent: 5_000_000n, + }), + ) + await cs.updateChannel(channelId2, () => + makeChannel({ + channelId: channelId2, + closeRequestedAt: 1n, + createdAt: '2025-02-01T00:00:00.000Z', + }), + ) + + const channelId3 = '0x0000000000000000000000000000000000000000000000000000000000000003' as Hex + await cs.updateChannel(channelId3, () => + makeChannel({ + channelId: channelId3, + createdAt: '2025-03-01T00:00:00.000Z', + highestVoucherAmount: 7_000_000n, + spent: 5_000_000n, + }), + ) + + const reusable = await ChannelStore.findReusableChannel(cs, { + amount: 1_000_000n, + chainId: 42431, + escrowContract: escrowContractDefaults[chainId.testnet] as Address, + payee: '0x0000000000000000000000000000000000000002' as Address, + payer: '0x0000000000000000000000000000000000000001' as Address, + token: '0x0000000000000000000000000000000000000003' as Address, + }) + + expect(reusable?.channelId).toBe(channelId3) + }) + }) }) // ---------- ChannelStore.deductFromChannel ---------- diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts index 30bf9a6a..c796d6db 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -105,6 +105,23 @@ export type ChannelStore = { channelId: Hex, fn: (current: State | null) => Store.Change, ): Promise + + /** + * Finds the best reusable channel for a payer and session dimensions. + * + * Implementations may return `null` when no reusable channel exists or when + * reverse lookup is not supported by the backing store. + */ + findReusableChannel?(options: ReusableChannelQuery): Promise +} + +export type ReusableChannelQuery = { + amount?: bigint | undefined + chainId?: number | undefined + escrowContract: Address + payee: Address + payer: Address + token: Address } export type DeductResult = { ok: true; channel: State } | { ok: false; channel: State } @@ -176,6 +193,65 @@ export async function deductFromChannel( return result ?? { ok: false, channel } } +export async function findReusableChannel( + store: ChannelStore, + options: ReusableChannelQuery, +): Promise { + if (!store.findReusableChannel) return null + return store.findReusableChannel(options) +} + +function payerIndexKey(payer: Address): `mppx:session:payer:${string}` { + return `mppx:session:payer:${payer.toLowerCase()}` +} + +function compareHexDesc(left: Hex, right: Hex): number { + return right.localeCompare(left) +} + +function compareBigIntDesc(left: bigint, right: bigint): number { + if (left === right) return 0 + return left > right ? -1 : 1 +} + +function compareNumberDesc(left: number, right: number): number { + if (left === right) return 0 + return left > right ? -1 : 1 +} + +function createdAtScore(channel: State): number { + const timestamp = Date.parse(channel.createdAt) + return Number.isNaN(timestamp) ? 0 : timestamp +} + +function isReusableChannel(channel: State, options: ReusableChannelQuery): boolean { + if (channel.finalized || channel.deposit === 0n || channel.closeRequestedAt !== 0n) return false + if (channel.payer.toLowerCase() !== options.payer.toLowerCase()) return false + if (channel.payee.toLowerCase() !== options.payee.toLowerCase()) return false + if (channel.token.toLowerCase() !== options.token.toLowerCase()) return false + if (channel.escrowContract.toLowerCase() !== options.escrowContract.toLowerCase()) return false + if (options.chainId !== undefined && channel.chainId !== options.chainId) return false + + if (options.amount !== undefined) { + const requiredCumulative = + channel.spent + options.amount > channel.highestVoucherAmount + ? channel.spent + options.amount + : channel.highestVoucherAmount + if (requiredCumulative > channel.deposit) return false + } + + return true +} + +function compareReusableChannels(left: State, right: State): number { + return ( + compareNumberDesc(createdAtScore(left), createdAtScore(right)) || + compareBigIntDesc(left.highestVoucherAmount, right.highestVoucherAmount) || + compareBigIntDesc(left.spent, right.spent) || + compareHexDesc(left.channelId, right.channelId) + ) +} + /** * Wraps a generic {@link Store} into the internal {@link Store} * interface used by server handlers and the SSE metering loop. @@ -204,6 +280,25 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore const waiters = new Map void>>() const locks = new Map>() + async function withLock(key: string, fn: () => Promise): Promise { + while (locks.has(key)) await locks.get(key) + + let release!: () => void + locks.set( + key, + new Promise((r) => { + release = r + }), + ) + + try { + return await fn() + } finally { + locks.delete(key) + release() + } + } + function notify(channelId: string) { const set = waiters.get(channelId) if (!set) return @@ -211,6 +306,46 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore waiters.delete(channelId) } + async function updatePayerIndex( + payer: Address, + update: (current: readonly Hex[]) => readonly Hex[], + ): Promise { + const key = payerIndexKey(payer) + await withLock(key, async () => { + const current = ((await store.get(key as never)) as Hex[] | null) ?? [] + const next = [...new Set(update(current).map((channelId) => channelId.toLowerCase() as Hex))] + if (next.length === 0) { + await store.delete(key as never) + return + } + await store.put(key as never, next as never) + }) + } + + async function syncPayerIndex( + channelId: Hex, + current: State | null, + next: State | null, + ): Promise { + const normalizedChannelId = channelId.toLowerCase() as Hex + const currentPayer = current?.payer.toLowerCase() as Address | undefined + const nextPayer = next?.payer.toLowerCase() as Address | undefined + + if (currentPayer && currentPayer !== nextPayer) { + await updatePayerIndex(currentPayer, (entries) => + entries.filter((entry) => entry.toLowerCase() !== normalizedChannelId), + ) + } + + if (!nextPayer) return + + await updatePayerIndex(nextPayer, (entries) => + entries.some((entry) => entry.toLowerCase() === normalizedChannelId) + ? entries + : [...entries, normalizedChannelId], + ) + } + async function update( channelId: Hex, fn: (current: State | null) => State | null, @@ -228,10 +363,12 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore ): Promise { const normalizedChannelId = normalizeChannelId(channelId) let change: Store.Change | undefined + let currentState: State | null = null if (atomicUpdate) { const result = await atomicUpdate(normalizedChannelId, (current) => { - change = fn(normalizeMaybeState(normalizedChannelId, (current as State | null) ?? null)) + currentState = normalizeMaybeState(normalizedChannelId, (current as State | null) ?? null) + change = fn(currentState) if (change.op === 'set') { change = { ...change, @@ -241,7 +378,14 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore if (change.op !== 'set') return change return { ...change, value: change.value as never } }) - if (change?.op !== 'noop') notify(normalizedChannelId) + if (change && change.op !== 'noop') { + await syncPayerIndex( + normalizedChannelId, + currentState, + change.op === 'set' ? change.value : null, + ) + notify(normalizedChannelId) + } return result } @@ -269,7 +413,14 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore await store.put(normalizedChannelId, change.value as never) } if (change.op === 'delete') await store.delete(normalizedChannelId) - if (change.op !== 'noop') notify(normalizedChannelId) + if (change.op !== 'noop') { + await syncPayerIndex( + normalizedChannelId, + current, + change.op === 'set' ? change.value : null, + ) + notify(normalizedChannelId) + } return change.result } finally { locks.delete(normalizedChannelId) @@ -299,6 +450,27 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore set.add(resolve) }) }, + async findReusableChannel(options) { + const key = payerIndexKey(options.payer) + const channelIds = ((await store.get(key as never)) as Hex[] | null) ?? [] + if (channelIds.length === 0) return null + + const channels = await Promise.all(channelIds.map((channelId) => cs.getChannel(channelId))) + const missing = channelIds.filter((_channelId, index) => !channels[index]) + if (missing.length > 0) { + const missingSet = new Set(missing.map((channelId) => channelId.toLowerCase())) + await updatePayerIndex(options.payer, (entries) => + entries.filter((entry) => !missingSet.has(entry.toLowerCase())), + ) + } + + const reusable = channels + .filter((channel): channel is State => channel !== null) + .filter((channel) => isReusableChannel(channel, options)) + .sort(compareReusableChannels) + + return reusable[0] ?? null + }, } cs.updateChannelResult = updateResult From cd070208b3e458443feb75182c903087010b05ee Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:26:25 -0700 Subject: [PATCH 4/6] fix: harden session hint reconciliation --- src/tempo/client/ChannelOps.test.ts | 17 ++++ src/tempo/client/ChannelOps.ts | 16 ++- src/tempo/client/Session.test.ts | 130 +++++++++++++++++++++--- src/tempo/client/Session.ts | 72 ++++++++----- src/tempo/client/SessionManager.test.ts | 4 +- src/tempo/client/SessionManager.ts | 3 + 6 files changed, 194 insertions(+), 48 deletions(-) diff --git a/src/tempo/client/ChannelOps.test.ts b/src/tempo/client/ChannelOps.test.ts index 11529fb3..3b010f01 100644 --- a/src/tempo/client/ChannelOps.test.ts +++ b/src/tempo/client/ChannelOps.test.ts @@ -172,6 +172,23 @@ describe('createClosePayload', () => { }) describe('reconcileChannelEntry', () => { + test('hinted entries do not treat server snapshots as local authorization', () => { + const entry = createHintedChannelEntry({ + chainId, + channelId, + escrowContract, + hints: { + acceptedCumulative: '6000000', + deposit: '10000000', + spent: '4000000', + }, + }) + + expect(entry.acceptedCumulative).toBe(6_000_000n) + expect(entry.cumulativeAmount).toBe(0n) + expect(entry.spent).toBe(4_000_000n) + }) + test('does not move channel state backwards for stale snapshots', () => { const entry = createHintedChannelEntry({ chainId, diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index bcecaa2f..c34311d8 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -29,14 +29,18 @@ import type { import { signVoucher } from '../session/Voucher.js' export type ChannelEntry = { + /** Highest voucher amount observed from server accounting hints or receipts. */ acceptedCumulative: bigint chainId: number channelId: Hex.Hex + /** Highest cumulative amount the client itself has signed for this channel. */ cumulativeAmount: bigint + /** Latest known deposit ceiling. */ deposit?: bigint | undefined escrowContract: Address opened: boolean salt: Hex.Hex + /** Latest server-reported spent amount for the session. */ spent: bigint } @@ -77,6 +81,7 @@ export function serializeCredential( } type ChannelSnapshot = { + /** Advisory server snapshot: not safe to treat as client authorization. */ acceptedCumulative?: bigint | string | undefined deposit?: bigint | string | undefined spent?: bigint | string | undefined @@ -103,7 +108,8 @@ export function createHintedChannelEntry(options: { acceptedCumulative, chainId: options.chainId, channelId: options.channelId, - cumulativeAmount: acceptedCumulative, + // Hints are advisory only. Start signing from locally authorized state. + cumulativeAmount: 0n, ...(options.hints.deposit !== undefined && { deposit: BigInt(options.hints.deposit) }), escrowContract: options.escrowContract, opened: true, @@ -121,10 +127,6 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap entry.acceptedCumulative = acceptedCumulative changed = true } - if (acceptedCumulative > entry.cumulativeAmount) { - entry.cumulativeAmount = acceptedCumulative - changed = true - } } if (snapshot.spent !== undefined) { @@ -137,10 +139,6 @@ export function reconcileChannelEntry(entry: ChannelEntry, snapshot: ChannelSnap entry.acceptedCumulative = spent changed = true } - if (snapshot.acceptedCumulative === undefined && entry.cumulativeAmount < spent) { - entry.cumulativeAmount = spent - changed = true - } } if (snapshot.deposit !== undefined) { diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index f9bde131..ffb7d87c 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -70,7 +70,7 @@ describe('session (pure)', () => { describe('server-authored hints', () => { const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex - test('prefers requiredCumulative and hydrates channel from challenge hints', async () => { + test('hydrates accounting hints without inflating the next signed voucher', async () => { const method = session({ getClient: () => pureClient, account: pureAccount, @@ -96,11 +96,11 @@ describe('session (pure)', () => { expect(cred.payload.action).toBe('voucher') if (cred.payload.action === 'voucher') { expect(cred.payload.channelId).toBe(channelId) - expect(cred.payload.cumulativeAmount).toBe('6000000') + expect(cred.payload.cumulativeAmount).toBe('1000000') } }) - test('does not let stale requiredCumulative move local cumulative backwards', async () => { + test('keeps cumulative strictly local across repeated hinted requests', async () => { const method = session({ getClient: () => pureClient, account: pureAccount, @@ -125,10 +125,49 @@ describe('session (pure)', () => { expect(first.payload.action).toBe('voucher') expect(second.payload.action).toBe('voucher') if (first.payload.action === 'voucher') { - expect(first.payload.cumulativeAmount).toBe('6000000') + expect(first.payload.cumulativeAmount).toBe('1000000') } if (second.payload.action === 'voucher') { - expect(second.payload.cumulativeAmount).toBe('7000000') + expect(second.payload.cumulativeAmount).toBe('2000000') + } + }) + + test('keeps the current local channel when a server-supplied replacement cannot be verified', async () => { + const channelIdA = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex + const channelIdB = '0x00000000000000000000000000000000000000000000000000000000000000bb' as Hex + const method = session({ + getClient: () => pureClient, + account: pureAccount, + deposit: '10', + }) + + const challengeA = makeChallenge({ + methodDetails: { + acceptedCumulative: '5000000', + chainId: 42431, + channelId: channelIdA, + deposit: '10000000', + escrowContract: escrowAddress, + spent: '4000000', + }, + }) + const challengeB = makeChallenge({ + methodDetails: { + chainId: 42431, + channelId: channelIdB, + escrowContract: escrowAddress, + }, + }) + + await method.createCredential({ challenge: challengeA, context: {} }) + const result = deserializePayload( + await method.createCredential({ challenge: challengeB, context: {} }), + ) + + expect(result.payload.action).toBe('voucher') + if (result.payload.action === 'voucher') { + expect(result.payload.channelId).toBe(channelIdA) + expect(result.payload.cumulativeAmount).toBe('2000000') } }) }) @@ -539,7 +578,7 @@ describe.runIf(isLocalnet)('session (on-chain)', () => { ).rejects.toThrow('cannot be reused') }) - test('falls back to opening a new channel when hints omit cumulative state', async () => { + test('throws when a server-supplied channelId cannot be recovered', async () => { const hintedChannelId = '0x0000000000000000000000000000000000000000000000000000000000000bad' as Hex const method = session({ @@ -558,12 +597,77 @@ describe.runIf(isLocalnet)('session (on-chain)', () => { }, }) - const result = await method.createCredential({ challenge, context: {} }) - const cred = deserializePayload(result) + await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow( + 'cannot be reused', + ) + }) - expect(cred.payload.action).toBe('open') - if (cred.payload.action === 'open') { - expect(cred.payload.channelId).not.toBe(hintedChannelId) + test('ignores stale receipts after rebinding to a newly recovered channel', async () => { + const { channelId: channelIdA } = await openChannel({ + escrow: escrowContract, + payer, + payee, + token: asset, + deposit: 10_000_000n, + salt: nextSalt(), + }) + const { channelId: channelIdB } = await openChannel({ + escrow: escrowContract, + payer, + payee, + token: asset, + deposit: 10_000_000n, + salt: nextSalt(), + }) + + const method = session({ + getClient: () => client, + account: payer, + deposit: '10', + escrowContract, + }) + + const challengeA = makeLiveChallenge({ + methodDetails: { + chainId: chain.id, + escrowContract, + channelId: channelIdA, + }, + }) + const challengeB = makeLiveChallenge({ + methodDetails: { + chainId: chain.id, + escrowContract, + channelId: channelIdB, + }, + }) + + await method.createCredential({ challenge: challengeA, context: {} }) + await method.createCredential({ challenge: challengeB, context: {} }) + + method.onResponse( + new Response(null, { + headers: { + 'Payment-Receipt': serializeSessionReceipt( + createSessionReceipt({ + challengeId: challengeA.id, + channelId: channelIdA, + acceptedCumulative: 9_000_000n, + spent: 9_000_000n, + }), + ), + }, + }), + ) + + const result = deserializePayload( + await method.createCredential({ challenge: challengeB, context: {} }), + ) + + expect(result.payload.action).toBe('voucher') + if (result.payload.action === 'voucher') { + expect(result.payload.channelId).toBe(channelIdB) + expect(result.payload.cumulativeAmount).toBe('2000000') } }) }) @@ -603,7 +707,7 @@ describe.runIf(isLocalnet)('session (on-chain)', () => { expect(updates[1]!.cumulativeAmount).toBe(2_000_000n) }) - test('reconciles local cumulative from Payment-Receipt before the next voucher', async () => { + test('does not let Payment-Receipt inflate the next voucher amount', async () => { const method = session({ getClient: () => client, account: payer, @@ -635,7 +739,7 @@ describe.runIf(isLocalnet)('session (on-chain)', () => { const secondCred = deserializePayload(second) expect(secondCred.payload.action).toBe('voucher') if (secondCred.payload.action === 'voucher') { - expect(secondCred.payload.cumulativeAmount).toBe('6000000') + expect(secondCred.payload.cumulativeAmount).toBe('2000000') } }) }) diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index 575cf500..e19a7576 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -119,6 +119,10 @@ export function session(parameters: session.Parameters = {}) { } function rememberChannel(key: string, entry: ChannelEntry) { + const previous = channels.get(key) + if (previous && previous.channelId !== entry.channelId) { + channelIdToKey.delete(previous.channelId) + } channels.set(key, entry) channelIdToKey.set(entry.channelId, key) escrowContractMap.set(entry.channelId, entry.escrowContract) @@ -168,9 +172,39 @@ export function session(parameters: session.Parameters = {}) { escrowContract: Address key: string suggestedChannelId: Hex.Hex + allowHintHydration?: boolean | undefined }): Promise { - const { challenge, chainId, client, context, escrowContract, key, suggestedChannelId } = - parameters + const { + challenge, + chainId, + client, + context, + escrowContract, + key, + suggestedChannelId, + allowHintHydration = false, + } = parameters + + const recovered = await tryRecoverChannel(client, escrowContract, suggestedChannelId, chainId) + if (recovered) { + const contextCumulative = getContextCumulative(context) + if (contextCumulative !== undefined) recovered.cumulativeAmount = contextCumulative + if ( + reconcileChannelEntry(recovered, { + acceptedCumulative: getChallengeHints(challenge)?.acceptedCumulative, + deposit: getChallengeHints(challenge)?.deposit, + spent: getChallengeHints(challenge)?.spent, + }) + ) { + // Preserve the locally recoverable signing baseline even when server + // accounting hints advance independently. + } + rememberChannel(key, recovered) + notifyUpdate(recovered) + return recovered + } + + if (!allowHintHydration) return undefined const hinted = hydrateChannelFromHints( suggestedChannelId, @@ -178,22 +212,13 @@ export function session(parameters: session.Parameters = {}) { escrowContract, getChallengeHints(challenge), ) - if (hinted) { - const contextCumulative = getContextCumulative(context) - if (contextCumulative !== undefined) hinted.cumulativeAmount = contextCumulative - rememberChannel(key, hinted) - notifyUpdate(hinted) - return hinted - } - - const recovered = await tryRecoverChannel(client, escrowContract, suggestedChannelId, chainId) - if (!recovered) return undefined + if (!hinted) return undefined const contextCumulative = getContextCumulative(context) - if (contextCumulative !== undefined) recovered.cumulativeAmount = contextCumulative - rememberChannel(key, recovered) - notifyUpdate(recovered) - return recovered + if (contextCumulative !== undefined) hinted.cumulativeAmount = contextCumulative + rememberChannel(key, hinted) + notifyUpdate(hinted) + return hinted } function reconcileReceipt(receipt: SessionReceipt) { @@ -201,7 +226,7 @@ export function session(parameters: session.Parameters = {}) { if (!key) return const entry = channels.get(key) - if (!entry) return + if (!entry || entry.channelId !== receipt.channelId) return if (reconcileChannelReceipt(entry, receipt)) notifyUpdate(entry) } @@ -242,7 +267,7 @@ export function session(parameters: session.Parameters = {}) { const suggestedChannelId = (context?.channelId ?? md?.channelId) as Hex.Hex | undefined if (entry && suggestedChannelId && entry.channelId !== suggestedChannelId) { - entry = await resolveSuggestedChannel({ + const rebound = await resolveSuggestedChannel({ challenge, chainId, client, @@ -251,6 +276,7 @@ export function session(parameters: session.Parameters = {}) { key, suggestedChannelId, }) + if (rebound) entry = rebound } if (!entry) { @@ -263,11 +289,12 @@ export function session(parameters: session.Parameters = {}) { escrowContract, key, suggestedChannelId, + allowHintHydration: true, }) - if (!entry && context?.channelId) { + if (!entry) { throw new Error( - `Channel ${context.channelId} cannot be reused (closed or not found on-chain).`, + `Channel ${suggestedChannelId} cannot be reused (closed or not found on-chain).`, ) } } @@ -287,10 +314,7 @@ export function session(parameters: session.Parameters = {}) { let payload: SessionCredentialPayload if (entry?.opened) { - const nextCumulative = entry.cumulativeAmount + amount - const requiredCumulative = md?.requiredCumulative ? BigInt(md.requiredCumulative) : 0n - entry.cumulativeAmount = - nextCumulative > requiredCumulative ? nextCumulative : requiredCumulative + entry.cumulativeAmount += amount payload = await createVoucherPayload( client, account, diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index df27d2ad..7e8db759 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -243,7 +243,7 @@ describe('Session', () => { expect(credential.payload.action).toBe('voucher') if (credential.payload.action === 'voucher') { expect(credential.payload.channelId).toBe(channelId) - expect(credential.payload.cumulativeAmount).toBe('6000000') + expect(credential.payload.cumulativeAmount).toBe('1000000') } return makeReceiptResponse( @@ -268,7 +268,7 @@ describe('Session', () => { expect(response.status).toBe(200) expect(response.receipt?.acceptedCumulative).toBe('6000000') expect(s.channelId).toBe(channelId) - expect(s.cumulative).toBe(6_000_000n) + expect(s.cumulative).toBe(1_000_000n) expect(mockFetch).toHaveBeenCalledTimes(3) }) }) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 1af205b1..56691a97 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -310,6 +310,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const { orderChallenges: requestOrderChallenges, ...fetchInit } = init ?? {} let response = await fetchFn(input, fetchInit) let attemptedZeroAuth = false + let attemptedSession = false for (let attempts = 0; response.status === 402 && attempts < 3; attempts++) { const challenges = Challenge.fromResponseList(response) @@ -349,7 +350,9 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa ) } if (!sessionChallenge) break + if (attemptedSession) break + attemptedSession = true const credential = await method.createCredential({ challenge: sessionChallenge as never, context: {}, From fa391787ce463b1843728c8e951195d1dd3a764f Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:46:06 -0700 Subject: [PATCH 5/6] fix: simplify session resume reconciliation --- src/tempo/client/ChannelOps.ts | 12 +++- src/tempo/client/Session.test.ts | 11 +++- src/tempo/client/Session.ts | 90 ++++++++++-------------------- src/tempo/client/SessionManager.ts | 55 +++++++----------- src/tempo/session/ChannelStore.ts | 15 ++++- 5 files changed, 84 insertions(+), 99 deletions(-) diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index c34311d8..e065d9c5 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -80,10 +80,20 @@ export function serializeCredential( }) } +/** + * Server-provided advisory channel state from receipts or hints. + * + * Values are monotonically reconciled into the local {@link ChannelEntry} — + * only upward adjustments are applied. These are **never** used directly for + * signing authorization; they inform the client's view of server-side + * accounting only. + */ type ChannelSnapshot = { - /** Advisory server snapshot: not safe to treat as client authorization. */ + /** Server-acknowledged cumulative voucher amount. */ acceptedCumulative?: bigint | string | undefined + /** Current on-chain deposit as observed by the server. */ deposit?: bigint | string | undefined + /** Cumulative amount the server considers consumed. */ spent?: bigint | string | undefined } diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index ffb7d87c..0d118dfe 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -132,13 +132,17 @@ describe('session (pure)', () => { } }) - test('keeps the current local channel when a server-supplied replacement cannot be verified', async () => { + test('does not apply replacement hints to the current local channel when a server-supplied replacement cannot be verified', async () => { const channelIdA = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex const channelIdB = '0x00000000000000000000000000000000000000000000000000000000000000bb' as Hex + const updates: { channelId: Hex; spent: bigint }[] = [] const method = session({ getClient: () => pureClient, account: pureAccount, deposit: '10', + onChannelUpdate(entry) { + updates.push({ channelId: entry.channelId, spent: entry.spent }) + }, }) const challengeA = makeChallenge({ @@ -153,9 +157,12 @@ describe('session (pure)', () => { }) const challengeB = makeChallenge({ methodDetails: { + acceptedCumulative: '9000000', chainId: 42431, channelId: channelIdB, + deposit: '12000000', escrowContract: escrowAddress, + spent: '9000000', }, }) @@ -169,6 +176,8 @@ describe('session (pure)', () => { expect(result.payload.channelId).toBe(channelIdA) expect(result.payload.cumulativeAmount).toBe('2000000') } + + expect(updates[updates.length - 1]).toEqual({ channelId: channelIdA, spent: 4_000_000n }) }) }) diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index e19a7576..b623d6f0 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -165,60 +165,39 @@ export function session(parameters: session.Parameters = {}) { } async function resolveSuggestedChannel(parameters: { - challenge: Challenge.Challenge chainId: number client: Awaited> context?: SessionContext | undefined escrowContract: Address key: string + snapshot: Pick suggestedChannelId: Hex.Hex allowHintHydration?: boolean | undefined }): Promise { const { - challenge, chainId, client, context, escrowContract, key, + snapshot, suggestedChannelId, allowHintHydration = false, } = parameters - const recovered = await tryRecoverChannel(client, escrowContract, suggestedChannelId, chainId) - if (recovered) { - const contextCumulative = getContextCumulative(context) - if (contextCumulative !== undefined) recovered.cumulativeAmount = contextCumulative - if ( - reconcileChannelEntry(recovered, { - acceptedCumulative: getChallengeHints(challenge)?.acceptedCumulative, - deposit: getChallengeHints(challenge)?.deposit, - spent: getChallengeHints(challenge)?.spent, - }) - ) { - // Preserve the locally recoverable signing baseline even when server - // accounting hints advance independently. - } - rememberChannel(key, recovered) - notifyUpdate(recovered) - return recovered + let entry = await tryRecoverChannel(client, escrowContract, suggestedChannelId, chainId) + if (!entry && allowHintHydration) { + entry = hydrateChannelFromHints(suggestedChannelId, chainId, escrowContract, snapshot) } - - if (!allowHintHydration) return undefined - - const hinted = hydrateChannelFromHints( - suggestedChannelId, - chainId, - escrowContract, - getChallengeHints(challenge), - ) - if (!hinted) return undefined + if (!entry) return undefined const contextCumulative = getContextCumulative(context) - if (contextCumulative !== undefined) hinted.cumulativeAmount = contextCumulative - rememberChannel(key, hinted) - notifyUpdate(hinted) - return hinted + if (contextCumulative !== undefined) entry.cumulativeAmount = contextCumulative + + reconcileChannelEntry(entry, snapshot) + rememberChannel(key, entry) + notifyUpdate(entry) + return entry } function reconcileReceipt(receipt: SessionReceipt) { @@ -265,48 +244,35 @@ export function session(parameters: session.Parameters = {}) { const key = channelKey(payee, currency, escrowContract) let entry = channels.get(key) const suggestedChannelId = (context?.channelId ?? md?.channelId) as Hex.Hex | undefined + const snapshot = { + acceptedCumulative: md?.acceptedCumulative, + deposit: md?.deposit, + spent: md?.spent, + } - if (entry && suggestedChannelId && entry.channelId !== suggestedChannelId) { - const rebound = await resolveSuggestedChannel({ - challenge, + if (suggestedChannelId && (!entry || entry.channelId !== suggestedChannelId)) { + const resolved = await resolveSuggestedChannel({ chainId, client, context, escrowContract, key, + snapshot, suggestedChannelId, + allowHintHydration: !entry, }) - if (rebound) entry = rebound - } - - if (!entry) { - if (suggestedChannelId) { - entry = await resolveSuggestedChannel({ - challenge, - chainId, - client, - context, - escrowContract, - key, - suggestedChannelId, - allowHintHydration: true, - }) - - if (!entry) { - throw new Error( - `Channel ${suggestedChannelId} cannot be reused (closed or not found on-chain).`, - ) - } + if (resolved) entry = resolved + else if (!entry) { + throw new Error( + `Channel ${suggestedChannelId} cannot be reused (closed or not found on-chain).`, + ) } } if ( entry && - reconcileChannelEntry(entry, { - acceptedCumulative: md?.acceptedCumulative, - deposit: md?.deposit, - spent: md?.spent, - }) + (!suggestedChannelId || entry.channelId === suggestedChannelId) && + reconcileChannelEntry(entry, snapshot) ) { notifyUpdate(entry) } diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 56691a97..75d87c56 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -112,7 +112,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa let channel: ChannelEntry | null = null let lastChallenge: Challenge.Challenge | null = null let lastUrl: RequestInfo | URL | null = null - let spent = 0n let activeSocketChallenge: Challenge.Challenge | null = null let activeSocketChannelId: Hex.Hex | null = null let activeSocket: WebSocket | null = null @@ -131,9 +130,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa decimals: parameters.decimals, maxDeposit: parameters.maxDeposit, onChannelUpdate(entry) { - if (entry.channelId !== channel?.channelId) spent = 0n channel = entry - spent = entry.spent }, }) const authMethod = chargePlugin({ @@ -172,24 +169,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa return normalized } - function reconcileReceipt(receipt: SessionReceipt | null | undefined) { - if (!receipt) return - if (channel && receipt.channelId === channel.channelId) { - updateSpentFromReceipt(receipt) - return - } - if (channel) return - spent = BigInt(receipt.spent) - } - - function reconcileResponse(response: Response): SessionReceipt | undefined { + function readReceipt(response: Response): SessionReceipt | undefined { const receiptHeader = response.headers.get('Payment-Receipt') if (!receiptHeader) return undefined try { - const receipt = deserializeSessionReceipt(receiptHeader) - reconcileReceipt(receipt) - return receipt + return deserializeSessionReceipt(receiptHeader) } catch { return undefined } @@ -268,20 +253,13 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const cumulative = channel?.channelId === channelId ? channel.cumulativeAmount : 0n - // For WS sessions, use delivered chunk count × tick cost as a tight spend - // estimate. Without this, a socket death before close-ready would cause - // the client to sign for the full cumulative voucher authorization — - // potentially orders of magnitude more than what was actually consumed. - // The estimate may undercount by at most 1 chunk (if the server committed - // a charge but the socket died before delivering the message). if (wsTickCost > 0n) { const deliveryEstimate = wsDeliveredChunks * wsTickCost - const bestSpent = spent > deliveryEstimate ? spent : deliveryEstimate + const bestSpent = channel?.spent ?? 0n > deliveryEstimate ? channel?.spent ?? 0n : deliveryEstimate return (bestSpent > cumulative ? cumulative : bestSpent).toString() } - // SSE/HTTP: spent is kept in sync by inline receipts, use it directly. - return spent.toString() + return (channel?.spent ?? 0n).toString() } function assertVoucherWithinLocalLimit(cumulativeAmount: bigint) { @@ -292,8 +270,17 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa ) } - function toPaymentResponse(response: Response): PaymentResponse { - const receipt = reconcileResponse(response) ?? null + async function syncResponse(response: Response): Promise { + await method.onResponse?.(response) + return readReceipt(response) ?? null + } + + function reconcileReceiptEvent(receipt: SessionReceipt | null | undefined) { + if (!receipt || !channel || receipt.channelId !== channel.channelId) return + reconcileChannelReceipt(channel, receipt) + } + + function toPaymentResponse(response: Response, receipt: SessionReceipt | null): PaymentResponse { return Object.assign(response, { receipt, challenge: lastChallenge, @@ -363,8 +350,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }) } - await method.onResponse?.(response) - return toPaymentResponse(response) + const receipt = await syncResponse(response) + return toPaymentResponse(response, receipt) } function createManagedSocket(socket: WebSocket) { @@ -531,7 +518,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa `Open request failed with status ${response.status}${body ? `: ${body}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`, ) } - reconcileResponse(response) + await syncResponse(response) }, fetch: doFetch, @@ -607,12 +594,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (!voucherResponse.ok) { throw new Error(`Voucher POST failed with status ${voucherResponse.status}`) } - reconcileResponse(voucherResponse) + await syncResponse(voucherResponse) break } case 'payment-receipt': - reconcileReceipt(event.data) + reconcileReceiptEvent(event.data) onReceipt?.(event.data) break } @@ -939,7 +926,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa `Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`, ) } - const receipt = reconcileResponse(response) + const receipt = (await syncResponse(response)) ?? undefined return receipt }, diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts index c796d6db..14372b94 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -205,6 +205,18 @@ function payerIndexKey(payer: Address): `mppx:session:payer:${string}` { return `mppx:session:payer:${payer.toLowerCase()}` } +function normalizeChannelIds(channelIds: readonly Hex[]): Hex[] { + return [...new Set(channelIds.map((channelId) => channelId.toLowerCase() as Hex))] +} + +function sameChannelIds(left: readonly Hex[], right: readonly Hex[]): boolean { + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index++) { + if (left[index]!.toLowerCase() !== right[index]!.toLowerCase()) return false + } + return true +} + function compareHexDesc(left: Hex, right: Hex): number { return right.localeCompare(left) } @@ -313,7 +325,8 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore const key = payerIndexKey(payer) await withLock(key, async () => { const current = ((await store.get(key as never)) as Hex[] | null) ?? [] - const next = [...new Set(update(current).map((channelId) => channelId.toLowerCase() as Hex))] + const next = normalizeChannelIds(update(current)) + if (sameChannelIds(current, next)) return if (next.length === 0) { await store.delete(key as never) return From 86341f0cd11ba88c66c7c90dd82fdd2bdc4f38ce Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:39:42 -0700 Subject: [PATCH 6/6] fix: harden session source resolution --- src/Method.ts | 1 + src/client/internal/Fetch.ts | 1 + src/server/Mppx.ts | 2 +- src/tempo/client/ChannelOps.ts | 5 + src/tempo/client/Session.ts | 6 + src/tempo/client/SessionManager.ts | 66 ++++---- src/tempo/server/Session.test.ts | 203 ++++++++++++++++++++++++- src/tempo/server/Session.ts | 88 ++++++++--- src/tempo/session/ChannelStore.test.ts | 35 +++++ src/tempo/session/ChannelStore.ts | 36 +++-- 10 files changed, 382 insertions(+), 61 deletions(-) diff --git a/src/Method.ts b/src/Method.ts index efad9ba5..1d5c6539 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -96,6 +96,7 @@ export type VerifiedChallengeEnvelope< export type RequestContext = { capturedRequest?: CapturedRequest credential?: Credential.Credential | null | undefined + /** Incoming HTTP request when the server transport can provide one. */ input?: globalThis.Request | undefined request: z.input } diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 0770c46a..9cfd7d60 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -14,6 +14,7 @@ type WrappedFetch = typeof globalThis.fetch & { [MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch } +/** Client method extension used to reconcile successful HTTP responses. */ type ResponseAwareClient = Method.AnyClient & { onResponse?: ((response: Response) => Promise | void) | undefined } diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 38a07163..643fd5bd 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -824,7 +824,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R method, request: parameters.request, }) as never, - ) + ) return response } diff --git a/src/tempo/client/ChannelOps.ts b/src/tempo/client/ChannelOps.ts index e065d9c5..8dbbc3c8 100644 --- a/src/tempo/client/ChannelOps.ts +++ b/src/tempo/client/ChannelOps.ts @@ -31,14 +31,19 @@ import { signVoucher } from '../session/Voucher.js' export type ChannelEntry = { /** Highest voucher amount observed from server accounting hints or receipts. */ acceptedCumulative: bigint + /** Chain ID the channel belongs to. */ chainId: number + /** Unique on-chain channel identifier. */ channelId: Hex.Hex /** Highest cumulative amount the client itself has signed for this channel. */ cumulativeAmount: bigint /** Latest known deposit ceiling. */ deposit?: bigint | undefined + /** Escrow contract used for recovery, opening, and voucher signing. */ escrowContract: Address + /** Whether the channel has been opened, recovered, or hydrated as reusable. */ opened: boolean + /** Channel creation salt; hint-hydrated channels use `0x` because they are not locally opened. */ salt: Hex.Hex /** Latest server-reported spent amount for the session. */ spent: bigint diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index b623d6f0..b5805929 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -118,6 +118,7 @@ export function session(parameters: session.Parameters = {}) { return resolveEscrow(challenge, chainId, parameters.escrowContract) } + /** Stores a channel and keeps the reverse channelId lookup in sync. */ function rememberChannel(key: string, entry: ChannelEntry) { const previous = channels.get(key) if (previous && previous.channelId !== entry.channelId) { @@ -128,12 +129,14 @@ export function session(parameters: session.Parameters = {}) { escrowContractMap.set(entry.channelId, entry.escrowContract) } + /** Narrows session methodDetails to the optional channel reuse hints. */ function getChallengeHints( challenge: Challenge.Challenge, ): SessionChallengeMethodDetails | undefined { return challenge.request.methodDetails as SessionChallengeMethodDetails | undefined } + /** Converts caller-provided cumulative overrides into raw token units. */ function getContextCumulative(context?: SessionContext): bigint | undefined { return context?.cumulativeAmountRaw ? BigInt(context.cumulativeAmountRaw) @@ -142,6 +145,7 @@ export function session(parameters: session.Parameters = {}) { : undefined } + /** Creates an advisory reusable channel only when the server supplied accounting hints. */ function hydrateChannelFromHints( channelId: Hex.Hex, chainId: number, @@ -164,6 +168,7 @@ export function session(parameters: session.Parameters = {}) { }) } + /** Resolves a server-suggested channel from chain state, with opt-in hint hydration fallback. */ async function resolveSuggestedChannel(parameters: { chainId: number client: Awaited> @@ -200,6 +205,7 @@ export function session(parameters: session.Parameters = {}) { return entry } + /** Applies session receipts to the cached channel entry that produced the request. */ function reconcileReceipt(receipt: SessionReceipt) { const key = channelIdToKey.get(receipt.channelId) if (!key) return diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 75d87c56..d78c10ff 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -94,10 +94,10 @@ export type PaymentResponse = Response & { * the session is lost and a new on-chain channel will be opened on the next * request — the previous channel's deposit is orphaned until manually closed. * - * When the server includes session hints in the 402 challenge `methodDetails`, - * the client resumes from those authoritative values first. If only a - * `channelId` is available, it falls back to reading on-chain state via - * `getOnChainChannel()` and resumes from the on-chain settled amount. + * When the server includes a `channelId` in the 402 challenge `methodDetails`, + * the client first tries to recover trusted channel state on-chain. If recovery + * is unavailable, advisory server hints can hydrate accounting state without + * increasing the next client-signed voucher amount. */ export function sessionManager(parameters: sessionManager.Parameters): SessionManager { const fetchFn = parameters.fetch ?? globalThis.fetch @@ -137,16 +137,13 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa account: parameters.account, getClient: parameters.client ? () => parameters.client! : parameters.getClient, }) + const sessionMethods = [method] as const + const sessionPreferences = AcceptPayment.resolve(sessionMethods).entries function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { if (!receipt || receipt.channelId !== channel?.channelId) return assertReceiptWithinLocalState(receipt) - if (reconcileChannelReceipt(channel, receipt)) { - spent = channel.spent - return - } - const next = BigInt(receipt.spent) - spent = spent > next ? spent : next + reconcileChannelReceipt(channel, receipt) } function isZeroAuthChallenge(challenge: Challenge.Challenge): boolean { @@ -187,12 +184,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (receiptSpent > acceptedCumulative) { throw new Error('receipt spent exceeds accepted cumulative voucher amount') } - if (acceptedCumulative > channel.cumulativeAmount) { - throw new Error('receipt accepted cumulative exceeds local voucher state') - } - if (receiptSpent > channel.cumulativeAmount) { - throw new Error('receipt spent exceeds local voucher state') - } assertVoucherWithinLocalLimit(acceptedCumulative) assertVoucherWithinLocalLimit(receiptSpent) } @@ -255,13 +246,21 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (wsTickCost > 0n) { const deliveryEstimate = wsDeliveredChunks * wsTickCost - const bestSpent = channel?.spent ?? 0n > deliveryEstimate ? channel?.spent ?? 0n : deliveryEstimate + const currentSpent = channel?.spent ?? 0n + const bestSpent = currentSpent > deliveryEstimate ? currentSpent : deliveryEstimate return (bestSpent > cumulative ? cumulative : bestSpent).toString() } return (channel?.spent ?? 0n).toString() } + function getLocalAuthorizedAmount(): bigint { + if (!channel) return 0n + const spent = + channel.spent > channel.acceptedCumulative ? channel.spent : channel.acceptedCumulative + return channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent + } + function assertVoucherWithinLocalLimit(cumulativeAmount: bigint) { if (maxVoucherCumulative === null) return if (cumulativeAmount <= maxVoucherCumulative) return @@ -272,7 +271,9 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa async function syncResponse(response: Response): Promise { await method.onResponse?.(response) - return readReceipt(response) ?? null + const receipt = readReceipt(response) ?? null + updateSpentFromReceipt(receipt) + return receipt } function reconcileReceiptEvent(receipt: SessionReceipt | null | undefined) { @@ -303,15 +304,15 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const challenges = Challenge.fromResponseList(response) const sessionCandidates = AcceptPayment.selectChallengeCandidates( challenges, - [method] as const, - AcceptPayment.resolve([method] as const).entries, + sessionMethods, + sessionPreferences, ) - const orderedSessionCandidates = requestOrderChallenges - ? await requestOrderChallenges(sessionCandidates) - : parameters.orderChallenges - ? await parameters.orderChallenges(sessionCandidates) - : sessionCandidates - const sessionChallenge = orderedSessionCandidates[0]?.challenge + const sessionChallenge = ( + await resolveSessionChallengeOrder( + sessionCandidates, + requestOrderChallenges ?? parameters.orderChallenges, + ) + )[0]?.challenge if (sessionChallenge) lastChallenge = sessionChallenge else if (challenges[0]) lastChallenge = challenges[0] @@ -636,8 +637,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const candidates = AcceptPayment.selectChallengeCandidates( Challenge.fromResponseList(probe), - [method] as const, - AcceptPayment.resolve([method] as const).entries, + sessionMethods, + sessionPreferences, ) const challenge = ( await resolveSessionChallengeOrder( @@ -847,7 +848,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa return waitForCloseReady() })()) const readySpent = BigInt(ready.spent) - if (readySpent > (channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent)) { + const localAuthorized = getLocalAuthorizedAmount() + if (readySpent > localAuthorized) { throw new Error('close-ready spent exceeds local voucher state') } @@ -888,7 +890,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa channelId: closeChannelId, cumulativeAmountRaw: (() => { const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId)) - if (closeAmount > channel.cumulativeAmount) { + const localAuthorized = getLocalAuthorizedAmount() + if (closeAmount > localAuthorized) { throw new Error('fallback close amount exceeds local voucher state') } assertVoucherWithinLocalLimit(closeAmount) @@ -960,6 +963,5 @@ async function resolveSessionChallengeOrder( candidates: readonly AcceptPayment.ChallengeCandidate[], override: SessionOrderChallenges | undefined, ): Promise[]> { - const orderChallenges = override - return orderChallenges ? orderChallenges(candidates) : candidates + return override ? override(candidates) : candidates } diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index e5eb1e65..72b78509 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -3786,6 +3786,51 @@ describe.runIf(isLocalnet)('session', () => { }) describe('respond', () => { + test('request() ignores forged credential.source during payer discovery', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000af' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 10_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 5_000_000n, + highestVoucher: null, + spent: 5_000_000n, + units: 5, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer({ + resolvePayerSource: (options) => + (options as { credential?: { source?: string | undefined } }).credential?.source, + }) + const request = await server.request!({ + credential: { + challenge: makeChallenge({ channelId }), + payload: { action: 'voucher', channelId, cumulativeAmount: '1', signature: '0x' }, + source: `did:pkh:eip155:${chain.id}:${payer.address}`, + } as never, + input: new Request('http://localhost'), + request: { + ...makeRequest(), + amount: '1', + }, + }) + + expect(request.channelId).toBeUndefined() + expect(request.acceptedCumulative).toBeUndefined() + expect(request.deposit).toBeUndefined() + expect(request.requiredCumulative).toBeUndefined() + expect(request.spent).toBeUndefined() + }) + test('request() discovers reusable channel hints from resolved payer source', async () => { const channelId = '0x00000000000000000000000000000000000000000000000000000000000000ac' as Hex await store.updateChannel(channelId, () => ({ @@ -3812,7 +3857,7 @@ describe.runIf(isLocalnet)('session', () => { })) const server = createServer({ - resolveSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, + resolvePayerSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, }) const request = await server.request!({ credential: undefined, @@ -3830,6 +3875,81 @@ describe.runIf(isLocalnet)('session', () => { expect(request.spent).toBe('5000000') }) + test('request() supports deprecated resolveSource alias', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000bd' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 10_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 5_000_000n, + highestVoucher: null, + spent: 5_000_000n, + units: 5, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer({ + resolveSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, + }) + const request = await server.request!({ + credential: undefined, + input: new Request('http://localhost'), + request: { + ...makeRequest(), + amount: '1', + }, + }) + + expect(request.channelId).toBe(channelId) + expect(request.acceptedCumulative).toBe('5000000') + }) + + test('request() prefers resolvePayerSource over deprecated resolveSource alias', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000be' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 10_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 5_000_000n, + highestVoucher: null, + spent: 5_000_000n, + units: 5, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer({ + resolvePayerSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, + resolveSource: () => `did:pkh:eip155:${chain.id}:${accounts[3].address}`, + }) + const request = await server.request!({ + credential: undefined, + input: new Request('http://localhost'), + request: { + ...makeRequest(), + amount: '1', + }, + }) + + expect(request.channelId).toBe(channelId) + expect(request.acceptedCumulative).toBe('5000000') + }) + test('request() keeps explicit channelId as the discovery fast path', async () => { const explicitChannelId = '0x00000000000000000000000000000000000000000000000000000000000000ad' as Hex @@ -3874,7 +3994,7 @@ describe.runIf(isLocalnet)('session', () => { })) const server = createServer({ - resolveSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, + resolvePayerSource: () => `did:pkh:eip155:${chain.id}:${payer.address}`, }) const request = await server.request!({ credential: undefined, @@ -3892,6 +4012,85 @@ describe.runIf(isLocalnet)('session', () => { expect(request.spent).toBe('4000000') }) + test('request() omits explicit channel hints when resolved payer does not own the channel', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000b0' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: recipient, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 8_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 4_000_000n, + highestVoucher: null, + spent: 4_000_000n, + units: 4, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer({ + resolvePayerSource: () => `did:pkh:eip155:${chain.id}:${accounts[3].address}`, + }) + const request = await server.request!({ + credential: undefined, + input: new Request('http://localhost'), + request: { + ...makeRequest(), + amount: '1', + channelId, + }, + }) + + expect(request.channelId).toBe(channelId) + expect(request.acceptedCumulative).toBeUndefined() + expect(request.deposit).toBeUndefined() + expect(request.requiredCumulative).toBeUndefined() + expect(request.spent).toBeUndefined() + }) + + test('request() omits explicit channel hints when the stored channel does not match the route dimensions', async () => { + const channelId = '0x00000000000000000000000000000000000000000000000000000000000000b1' as Hex + await store.updateChannel(channelId, () => ({ + channelId, + payer: payer.address, + payee: accounts[3].address, + token: currency, + authorizedSigner: payer.address, + chainId: chain.id, + escrowContract, + deposit: 8_000_000n, + settledOnChain: 0n, + highestVoucherAmount: 4_000_000n, + highestVoucher: null, + spent: 4_000_000n, + units: 4, + closeRequestedAt: 0n, + finalized: false, + createdAt: new Date().toISOString(), + })) + + const server = createServer() + const request = await server.request!({ + credential: undefined, + request: { + ...makeRequest(), + amount: '1', + channelId, + }, + }) + + expect(request.channelId).toBe(channelId) + expect(request.acceptedCumulative).toBeUndefined() + expect(request.deposit).toBeUndefined() + expect(request.requiredCumulative).toBeUndefined() + expect(request.spent).toBeUndefined() + }) + test('request() adds reusable channel hints to challenge data', async () => { const channelId = '0x00000000000000000000000000000000000000000000000000000000000000aa' as Hex await store.updateChannel(channelId, () => ({ diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index 7165319f..19c61c79 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -62,12 +62,15 @@ import { parseVoucherFromPayload, verifyVoucher } from '../session/Voucher.js' import { captureRequestBodyProbe, isSessionContentRequest } from './internal/request-body.js' import * as Transport from './internal/transport.js' -/** Challenge methodDetails shape for session methods. */ +/** Session challenge methodDetails including required network and escrow routing fields. */ type SessionMethodDetails = SessionChallengeMethodDetails & { + /** Escrow contract clients should use for this challenge. */ escrowContract: Address + /** Chain ID clients should use for this challenge. */ chainId: number } +/** Builds advisory reuse hints for a live channel; unusable channels are omitted. */ function createChallengeHints( channel: ChannelStore.State | null, amount: bigint | undefined, @@ -93,6 +96,38 @@ function createChallengeHints( } } +/** Parses an authenticated payer source and rejects sources bound to another chain. */ +function resolveRequestedPayer( + source: string | undefined, + chainId: number, +): Address | null | undefined { + if (!source) return undefined + + const payer = Proof.parsePkhSource(source) + if (!payer || payer.chainId !== chainId) return null + return payer.address +} + +/** Checks that a reusable channel matches the requested payment dimensions and optional payer. */ +function matchesRequestedChannel(parameters: { + channel: ChannelStore.State + request: { currency: Address; recipient: Address } + resolvedEscrow: Address + chainId: number + payer?: Address | undefined +}): boolean { + const { channel, chainId, payer, request, resolvedEscrow } = parameters + + if (channel.chainId !== chainId) return false + if (channel.escrowContract.toLowerCase() !== resolvedEscrow.toLowerCase()) return false + if (channel.payee.toLowerCase() !== request.recipient.toLowerCase()) return false + if (channel.token.toLowerCase() !== request.currency.toLowerCase()) return false + if (payer && channel.payer.toLowerCase() !== payer.toLowerCase()) return false + + return true +} + +/** Finds an explicit requested channel first, then falls back to payer-index reuse. */ async function findRequestedChannel(parameters: { amount: bigint request: { channelId?: Hex | undefined; currency: Address; recipient: Address } @@ -102,22 +137,34 @@ async function findRequestedChannel(parameters: { store: ChannelStore.ChannelStore }): Promise { const { amount, chainId, request, resolvedEscrow, source, store } = parameters + const payer = resolveRequestedPayer(source, chainId) + if (source && !payer) return null if (request.channelId) { - return store.getChannel(request.channelId) + const channel = await store.getChannel(request.channelId) + if (!channel) return null + if ( + !matchesRequestedChannel({ + channel, + chainId, + ...(payer ? { payer } : {}), + request, + resolvedEscrow, + }) + ) { + return null + } + return channel } - if (!source) return null - - const payer = Proof.parsePkhSource(source) - if (!payer || payer.chainId !== chainId) return null + if (!payer) return null return ChannelStore.findReusableChannel(store, { amount, chainId, escrowContract: resolvedEscrow, payee: request.recipient, - payer: payer.address, + payer, token: request.currency, }) } @@ -161,7 +208,7 @@ export function session( const lastOnChainVerified = new Map() - const store = ChannelStore.fromStore(rawStore) + const store = ChannelStore.fromStore(rawStore, parameters.storeOptions) const { account, recipient, feePayer, feePayerUrl } = Account.resolve(parameters) @@ -199,6 +246,7 @@ export function session( transport: transport as never, // TODO: dedupe `{charge,session}.request` + // `input` is optional request context; resolvePayerSource uses it for app-level auth. async request({ credential, input, request }) { // Extract chainId from request or default. const chainId = await (async () => { @@ -224,7 +272,8 @@ export function session( defaults.escrowContract[chainId as keyof typeof defaults.escrowContract] const amount = parseUnits(request.amount, request.decimals ?? decimals) - const source = await parameters.resolveSource?.({ credential, input, request }) + const resolvePayerSource = parameters.resolvePayerSource ?? parameters.resolveSource + const source = await resolvePayerSource?.({ input, request }) const requestedChannel = await findRequestedChannel({ amount, chainId: chainId as number, @@ -406,6 +455,10 @@ export declare namespace session { > type FeePayerPolicy = Partial + type ResolvePayerSource = (options: { + input?: globalThis.Request | undefined + request: Method.RequestDefaults + }) => MaybePromise type Parameters = { /** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 5_000 */ @@ -415,7 +468,7 @@ export declare namespace session { /** Minimum voucher delta to accept (numeric string, default: "0"). */ minVoucherDelta?: string | undefined /** - * Resolves the authenticated payer identity used for stateless channel + * Resolves the authenticated payer source used for stateless channel * discovery when the client does not provide a channelId. * * Return the zero-dollar proof source DID (for example @@ -423,13 +476,12 @@ export declare namespace session { * context, such as a cookie-backed login established by `tempo.charge` * proof auth. */ - resolveSource?: - | ((options: { - credential?: Credential.Credential | null | undefined - input?: globalThis.Request | undefined - request: Method.RequestDefaults - }) => MaybePromise) - | undefined + resolvePayerSource?: ResolvePayerSource | undefined + /** + * @deprecated Use {@link resolvePayerSource}. Kept for compatibility with + * prerelease stateless-resume integrations. + */ + resolveSource?: ResolvePayerSource | undefined /** * Whether to wait for the open transaction to confirm on-chain before * responding. @default true @@ -450,6 +502,8 @@ export declare namespace session { * local single-process usage. */ store?: Store.AtomicStore | undefined + /** Options for adapting `store` into a channel store. */ + storeOptions?: ChannelStore.fromStore.Options | undefined /** * Enable SSE streaming. * diff --git a/src/tempo/session/ChannelStore.test.ts b/src/tempo/session/ChannelStore.test.ts index aae7e88e..298f3f1c 100644 --- a/src/tempo/session/ChannelStore.test.ts +++ b/src/tempo/session/ChannelStore.test.ts @@ -254,6 +254,41 @@ describe('channelStore', () => { }) describe('findReusableChannel', () => { + test('uses payer index prefixes to isolate reusable channel lookups', async () => { + const backingStore = Store.memory() + const defaultStore = ChannelStore.fromStore(backingStore) + const prefixedStore = ChannelStore.fromStore(backingStore, { + payerIndexPrefix: 'tenant:a:session:payer:', + }) + + await prefixedStore.updateChannel(channelId, () => makeChannel()) + + const query = { + amount: 1_000_000n, + chainId: 42431, + escrowContract: escrowContractDefaults[chainId.testnet] as Address, + payee: '0x0000000000000000000000000000000000000002' as Address, + payer: '0x0000000000000000000000000000000000000001' as Address, + token: '0x0000000000000000000000000000000000000003' as Address, + } + + expect(await ChannelStore.findReusableChannel(defaultStore, query)).toBeNull() + expect((await ChannelStore.findReusableChannel(prefixedStore, query))?.channelId).toBe( + channelId, + ) + }) + + test('caches store adapters per payer index prefix', () => { + const backingStore = Store.memory() + + expect(ChannelStore.fromStore(backingStore, { payerIndexPrefix: 'a:' })).toBe( + ChannelStore.fromStore(backingStore, { payerIndexPrefix: 'a:' }), + ) + expect(ChannelStore.fromStore(backingStore, { payerIndexPrefix: 'a:' })).not.toBe( + ChannelStore.fromStore(backingStore, { payerIndexPrefix: 'b:' }), + ) + }) + test('selects the newest viable channel for matching payer and session dimensions', async () => { const cs = ChannelStore.fromStore(Store.memory()) diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts index 14372b94..abcfe84a 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -201,8 +201,10 @@ export async function findReusableChannel( return store.findReusableChannel(options) } -function payerIndexKey(payer: Address): `mppx:session:payer:${string}` { - return `mppx:session:payer:${payer.toLowerCase()}` +const defaultPayerIndexPrefix = 'mppx:session:payer:' + +function payerIndexKey(payer: Address, prefix = defaultPayerIndexPrefix): string { + return `${prefix}${payer.toLowerCase()}` } function normalizeChannelIds(channelIds: readonly Hex[]): Hex[] { @@ -281,10 +283,15 @@ function compareReusableChannels(left: State, right: State): number { * Backends that need true atomicity (e.g., Durable Objects, D1) * should implement {@link Store} directly. */ -const storeCache = new WeakMap() - -export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore { - const cached = storeCache.get(store) +const storeCache = new WeakMap>() + +export function fromStore( + store: Store.Store | Store.AtomicStore, + options?: fromStore.Options, +): ChannelStore { + const { payerIndexPrefix = defaultPayerIndexPrefix } = options ?? {} + let cachedByPrefix = storeCache.get(store) + const cached = cachedByPrefix?.get(payerIndexPrefix) if (cached) return cached const atomicUpdate = 'update' in store ? (store as Store.AtomicStore).update : undefined @@ -322,7 +329,7 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore payer: Address, update: (current: readonly Hex[]) => readonly Hex[], ): Promise { - const key = payerIndexKey(payer) + const key = payerIndexKey(payer, payerIndexPrefix) await withLock(key, async () => { const current = ((await store.get(key as never)) as Hex[] | null) ?? [] const next = normalizeChannelIds(update(current)) @@ -464,7 +471,7 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore }) }, async findReusableChannel(options) { - const key = payerIndexKey(options.payer) + const key = payerIndexKey(options.payer, payerIndexPrefix) const channelIds = ((await store.get(key as never)) as Hex[] | null) ?? [] if (channelIds.length === 0) return null @@ -488,6 +495,17 @@ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore cs.updateChannelResult = updateResult - storeCache.set(store, cs) + if (!cachedByPrefix) { + cachedByPrefix = new Map() + storeCache.set(store, cachedByPrefix) + } + cachedByPrefix.set(payerIndexPrefix, cs) return cs } + +export declare namespace fromStore { + type Options = { + /** Key prefix for payer-to-channel reverse indexes. @default `'mppx:session:payer:'` */ + payerIndexPrefix?: string | undefined + } +}