From 00cecd90e6d4b89b1e29f68bc0d4f1f1ad15d51d Mon Sep 17 00:00:00 2001 From: Brendan Ryan 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 | 23 +++- 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 | 46 ++++--- src/tempo/server/Session.test.ts | 41 ++++++ src/tempo/server/Session.ts | 43 +++++- src/tempo/session/Types.ts | 18 +++ 11 files changed, 550 insertions(+), 70 deletions(-) diff --git a/src/client/internal/Fetch.test.ts b/src/client/internal/Fetch.test.ts index ea25ad1b..858f3a16 100644 --- a/src/client/internal/Fetch.test.ts +++ b/src/client/internal/Fetch.test.ts @@ -451,6 +451,20 @@ describe('Fetch.from: init passthrough (non-402)', () => { expect(receivedInits[0]).toBe(customInit) } }) + + 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', () => { @@ -501,6 +515,25 @@ describe('Fetch.from: 402 retry path', () => { expect(headers.Authorization).toBe('credential') }) + 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 4cd0093e..ee882a1f 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -11,6 +11,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 /** @@ -46,7 +50,10 @@ export function from( // Pass init through untouched to preserve object identity for non-402 responses. const response = await baseFetch(input, 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 @@ -81,10 +88,12 @@ export function from( const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context)) validateCredentialHeaderValue(credential) - return baseFetch(input, { + const retryResponse = await baseFetch(input, { ...fetchInit, headers: withAuthorizationHeader(fetchInit.headers, credential), }) + await handleResponse(methods, retryResponse) + return retryResponse } // Record the wrapped target so future polyfill() / restore() calls can detect origin @@ -240,6 +249,16 @@ 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 2aefbc28..e264cf67 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -208,4 +208,24 @@ 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') + }) }) diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index cbb66a8f..7d7338bc 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -134,11 +134,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( @@ -148,18 +150,24 @@ 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(), }), z.transform( ({ + acceptedCumulative, amount, chainId, channelId, decimals, + deposit, escrowContract, feePayer, minVoucherDelta, + requiredCumulative, + spent, suggestedDeposit, ...rest }) => ({ @@ -171,13 +179,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 33ab13a1..0062e8c9 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, @@ -181,12 +269,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', @@ -221,12 +312,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 b5ffcce6..dbcc62d7 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -11,6 +11,7 @@ const isLocalnet = nodeEnv === 'localnet' import * as Challenge from '../../Challenge.js' import * as Credential from '../../Credential.js' import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js' +import { createSessionReceipt, serializeSessionReceipt } from '../session/Receipt.js' import type { SessionCredentialPayload } from '../session/Types.js' import { session } from './Session.js' @@ -65,6 +66,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 @@ -451,6 +486,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 99ad988e..bf3a3682 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 0445b434..78b9275c 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -8,7 +8,7 @@ import type * as Client from '../../viem/Client.js' import { deserializeSessionReceipt } from '../session/Receipt.js' import { parseEvent } from '../session/Sse.js' import type { SessionReceipt } from '../session/Types.js' -import type { ChannelEntry } from './ChannelOps.js' +import { reconcileChannelReceipt, type ChannelEntry } from './ChannelOps.js' import { session as sessionPlugin } from './Session.js' export type SessionManager = { @@ -49,10 +49,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 @@ -72,6 +72,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa onChannelUpdate(entry) { if (entry.channelId !== channel?.channelId) spent = 0n channel = entry + spent = entry.spent }, }) @@ -84,16 +85,30 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }, }) - function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { - if (!receipt || receipt.channelId !== channel?.channelId) 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) { + if (reconcileChannelReceipt(channel, receipt)) spent = channel.spent + return + } + spent = BigInt(receipt.spent) } - function toPaymentResponse(response: Response): PaymentResponse { + function reconcileResponse(response: Response): SessionReceipt | undefined { const receiptHeader = response.headers.get('Payment-Receipt') - const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : null - updateSpentFromReceipt(receipt) + if (!receiptHeader) return undefined + + try { + const receipt = deserializeSessionReceipt(receiptHeader) + reconcileReceipt(receipt) + return receipt + } catch { + return undefined + } + } + + function toPaymentResponse(response: Response): PaymentResponse { + const receipt = reconcileResponse(response) ?? null return Object.assign(response, { receipt, challenge: lastChallenge, @@ -148,6 +163,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, @@ -222,11 +238,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 } @@ -258,8 +275,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa method: 'POST', headers: { Authorization: credential }, }) - const receiptHeader = response.headers.get('Payment-Receipt') - if (receiptHeader) receipt = deserializeSessionReceipt(receiptHeader) + receipt = reconcileResponse(response) } return receipt diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 83642a80..d5965e87 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -2630,6 +2630,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 1d78332e..d0cb757f 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -49,17 +49,44 @@ 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 * 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(), + } } /** @@ -162,6 +189,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 = (() => { const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer @@ -173,6 +205,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 90957c1dbfa92f048a0c0af016cfde93ef9a4f64 Mon Sep 17 00:00:00 2001 From: Brendan Ryan 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 7b2f5b32..b784135e 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 0062e8c9..593e2ed8 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 dbcc62d7..f90fb53e 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -98,6 +98,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', () => { @@ -450,6 +482,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 bf3a3682..178977ec 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 7abbdb0b..64e878b4 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', () => { @@ -247,5 +272,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 78b9275c..71d18eb9 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -91,6 +91,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (reconcileChannelReceipt(channel, receipt)) spent = channel.spent 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 d5965e87..88220cd4 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -2671,6 +2671,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 6ce8625a0d8e2f9c722b1660abe4d2892df634af Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 14:17:45 -0700 Subject: [PATCH 3/6] feat: add stateless session resume --- src/Method.ts | 1 + src/server/Mppx.ts | 2 +- src/tempo/client/SessionManager.test.ts | 78 +++++++++++ src/tempo/client/SessionManager.ts | 87 ++++++++++-- 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 | 174 ++++++++++++++++++++++-- 9 files changed, 561 insertions(+), 36 deletions(-) diff --git a/src/Method.ts b/src/Method.ts index 1b196fad..5e4d1f00 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -95,6 +95,7 @@ export type CreateCredentialFn = ( /** Request transform function for a single method. */ export type RequestFn = (options: { credential?: Credential.Credential | null | undefined + input?: globalThis.Request | undefined request: z.input }) => MaybePromise> diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 59c5b4d2..0ae423d6 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -279,7 +279,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R // Transform request if method provides a `request` function. const request = ( parameters.request - ? await parameters.request({ credential, request: merged } as never) + ? await parameters.request({ credential, input, request: merged } as never) : merged ) as never diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 64e878b4..be7a5702 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -136,6 +136,84 @@ describe('Session', () => { 'no `deposit` or `maxDeposit` configured', ) }) + + 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('.open()', () => { diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 71d18eb9..b6087ad9 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -1,7 +1,7 @@ import type { Hex } from 'ox' import type { Address } from 'viem' -import type * as Challenge from '../../Challenge.js' +import * as Challenge from '../../Challenge.js' import * as Fetch from '../../client/internal/Fetch.js' import type * as Account from '../../viem/Account.js' import type * as Client from '../../viem/Client.js' @@ -9,6 +9,7 @@ import { deserializeSessionReceipt } from '../session/Receipt.js' import { parseEvent } from '../session/Sse.js' import type { SessionReceipt } from '../session/Types.js' import { reconcileChannelReceipt, type ChannelEntry } from './ChannelOps.js' +import { charge as chargePlugin } from './Charge.js' import { session as sessionPlugin } from './Session.js' export type SessionManager = { @@ -39,9 +40,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 * @@ -75,16 +76,39 @@ 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 - }, + const authMethod = chargePlugin({ + account: parameters.account, + getClient: parameters.client ? () => parameters.client! : parameters.getClient, }) + function isZeroAuthChallenge(challenge: Challenge.Challenge): boolean { + return ( + challenge.method === 'tempo' && + challenge.intent === 'charge' && + challenge.request.amount === '0' + ) + } + + function findSessionChallenge( + challenges: readonly Challenge.Challenge[], + ): Challenge.Challenge | undefined { + return challenges.find( + (challenge) => challenge.method === 'tempo' && challenge.intent === 'session', + ) + } + + 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) { @@ -120,7 +144,44 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa async function doFetch(input: RequestInfo | URL, init?: RequestInit): Promise { lastUrl = input - const response = await wrappedFetch(input, init) + let response = await fetchFn(input, init) + let attemptedZeroAuth = false + + for (let attempts = 0; response.status === 402 && attempts < 3; attempts++) { + const challenges = Challenge.fromResponseList(response) + const sessionChallenge = findSessionChallenge(challenges) + 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, { + ...init, + headers: withAuthorizationHeader(init?.headers, credential), + }) + continue + } + + if (!sessionChallenge) break + + const credential = await method.createCredential({ + challenge: sessionChallenge as never, + context: {}, + }) + response = await fetchFn(input, { + ...init, + headers: withAuthorizationHeader(init?.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 2a7a831f..ba4dfc13 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -40,6 +40,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 88220cd4..e1ab037c 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -2630,6 +2630,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 d0cb757f..1ef1a608 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -31,12 +31,13 @@ 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' import * as Account from '../internal/account.js' import * as defaults from '../internal/defaults.js' +import * as Proof from '../internal/proof.js' import type * as types from '../internal/types.js' import * as Methods from '../Methods.js' import { @@ -89,6 +90,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.parseProofSource(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. * @@ -165,7 +195,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 @@ -190,9 +220,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 = (() => { @@ -206,8 +247,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, } }, @@ -330,6 +374,22 @@ export declare namespace session { channelStateTtl?: number | 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 a6377ee0..c2f13dea 100644 --- a/src/tempo/session/ChannelStore.test.ts +++ b/src/tempo/session/ChannelStore.test.ts @@ -210,6 +210,49 @@ describe('channelStore', () => { expect(ch1Resolved).toBe(false) }) }) + + 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 005c526d..ff3712d8 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -90,6 +90,23 @@ export type ChannelStore = { * When not implemented, callers fall back to polling. */ waitForUpdate?(channelId: Hex): 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 } @@ -121,6 +138,65 @@ export async function deductFromChannel( return { ok: deducted, 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. @@ -147,6 +223,25 @@ export function fromStore(store: Store.Store): 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 @@ -154,30 +249,58 @@ export function fromStore(store: Store.Store): ChannelStore { waiters.delete(channelId) } - async function update( + 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, - fn: (current: State | null) => State | null, - ): Promise { - while (locks.has(channelId)) await locks.get(channelId) + 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 - let release!: () => void - locks.set( - channelId, - new Promise((r) => { - release = r - }), + 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], ) + } - try { + async function update( + channelId: Hex, + fn: (current: State | null) => State | null, + ): Promise { + return withLock(channelId, async () => { const current = (await store.get(channelId)) as State | null const next = fn(current) if (next) await store.put(channelId, next as never) else await store.delete(channelId) + await syncPayerIndex(channelId, current, next) return next - } finally { - locks.delete(channelId) - release() - } + }) } const cs: ChannelStore = { @@ -199,6 +322,27 @@ export function fromStore(store: Store.Store): 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 + }, } storeCache.set(store, cs) From 70e81808ae91fb52d0c483de2386b1671c6ad75b Mon Sep 17 00:00:00 2001 From: Brendan Ryan 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 b784135e..4ee527db 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 593e2ed8..1ff38d6c 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 f90fb53e..97f45381 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -69,7 +69,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, @@ -95,11 +95,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, @@ -124,10 +124,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') } }) }) @@ -483,7 +522,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({ @@ -502,12 +541,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') } }) }) @@ -547,7 +651,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, @@ -579,7 +683,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 178977ec..d74f352c 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 be7a5702..2690399f 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -186,7 +186,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( @@ -211,7 +211,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 b6087ad9..913089fd 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -146,6 +146,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa lastUrl = input let response = await fetchFn(input, init) let attemptedZeroAuth = false + let attemptedSession = false for (let attempts = 0; response.status === 402 && attempts < 3; attempts++) { const challenges = Challenge.fromResponseList(response) @@ -170,7 +171,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 95fab25db9c62e3c01dd1f331ef4f83e73b35a6d Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 14:46:06 -0700 Subject: [PATCH 5/6] fix: simplify session resume reconciliation --- src/tempo/client/Session.test.ts | 11 +++- src/tempo/client/Session.ts | 90 ++++++++++-------------------- src/tempo/client/SessionManager.ts | 46 +++++++-------- src/tempo/session/ChannelStore.ts | 15 ++++- 4 files changed, 72 insertions(+), 90 deletions(-) diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index 97f45381..a5470559 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -131,13 +131,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({ @@ -152,9 +156,12 @@ describe('session (pure)', () => { }) const challengeB = makeChallenge({ methodDetails: { + acceptedCumulative: '9000000', chainId: 42431, channelId: channelIdB, + deposit: '12000000', escrowContract: escrowAddress, + spent: '9000000', }, }) @@ -168,6 +175,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 d74f352c..d8024344 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 913089fd..37347dc5 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -61,7 +61,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 const method = sessionPlugin({ account: parameters.account, @@ -71,9 +70,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({ @@ -109,31 +106,28 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa return normalized } - function reconcileReceipt(receipt: SessionReceipt | null | undefined) { - if (!receipt) return - if (channel && receipt.channelId === channel.channelId) { - if (reconcileChannelReceipt(channel, receipt)) spent = channel.spent - 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 } } - 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, @@ -184,8 +178,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }) } - await method.onResponse?.(response) - return toPaymentResponse(response) + const receipt = await syncResponse(response) + return toPaymentResponse(response, receipt) } const self: SessionManager = { @@ -228,7 +222,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, @@ -303,12 +297,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 } @@ -330,7 +324,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa context: { action: 'close', channelId: channel.channelId, - cumulativeAmountRaw: spent.toString(), + cumulativeAmountRaw: channel.spent.toString(), }, }) @@ -340,7 +334,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa method: 'POST', headers: { Authorization: credential }, }) - receipt = reconcileResponse(response) + receipt = (await syncResponse(response)) ?? undefined } return receipt diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts index ff3712d8..365fbe67 100644 --- a/src/tempo/session/ChannelStore.ts +++ b/src/tempo/session/ChannelStore.ts @@ -150,6 +150,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) } @@ -256,7 +268,8 @@ export function fromStore(store: Store.Store): 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 20652600538a08877093699f2f4383ea34841e05 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 15:39:42 -0700 Subject: [PATCH 6/6] fix: harden session source resolution --- src/tempo/server/Session.test.ts | 124 +++++++++++++++++++++++++++++++ src/tempo/server/Session.ts | 56 ++++++++++++-- 2 files changed, 172 insertions(+), 8 deletions(-) diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index e1ab037c..1fc5669d 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -2630,6 +2630,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({ + resolveSource: (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, () => ({ @@ -2736,6 +2781,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({ + 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', + 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 1ef1a608..b375aa55 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -90,6 +90,35 @@ function createChallengeHints( } } +function resolveRequestedPayer( + source: string | undefined, + chainId: number, +): Address | null | undefined { + if (!source) return undefined + + const payer = Proof.parseProofSource(source) + if (!payer || payer.chainId !== chainId) return null + return payer.address +} + +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 +} + async function findRequestedChannel(parameters: { amount: bigint request: { channelId?: Hex | undefined; currency: Address; recipient: Address } @@ -99,22 +128,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.parseProofSource(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, }) } @@ -220,7 +261,7 @@ 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 source = await parameters.resolveSource?.({ input, request }) const requestedChannel = await findRequestedChannel({ amount, chainId: chainId as number, @@ -385,7 +426,6 @@ export declare namespace session { */ resolveSource?: | ((options: { - credential?: Credential.Credential | null | undefined input?: globalThis.Request | undefined request: Method.RequestDefaults }) => MaybePromise)