diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index e39fe15b..13c4043e 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -392,5 +392,86 @@ describe('Session', () => { await s.close() expect(mockFetch).not.toHaveBeenCalled() }) + + test('retries HTTP close with the fresh challenge from a 402 response', async () => { + vi.resetModules() + vi.doMock('viem/actions', () => ({ + prepareTransactionRequest: vi.fn(async () => ({})), + sendCallsSync: vi.fn(), + signTransaction: vi.fn(async () => '0xdeadbeef'), + signTypedData: vi.fn(), + })) + + try { + const { sessionManager: sessionManagerWithMocks } = await import('./SessionManager.js') + const account = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ) + const voucherSigner = TempoAccount.fromSecp256k1( + '0x0000000000000000000000000000000000000000000000000000000000000002', + { access: account }, + ) + const client = createClient({ + account, + transport: http('http://127.0.0.1'), + }) + const initialChallenge = makeChallenge({ + amount: '1000000', + recipient: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00', + methodDetails: { + escrowContract: '0x9d136eea063ede5418a6bc7beaff009bbb6cfa70', + chainId: 4217, + }, + }) + const closeChallenge = makeChallenge({ + amount: '2000000', + recipient: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00', + methodDetails: { + escrowContract: '0x9d136eea063ede5418a6bc7beaff009bbb6cfa70', + chainId: 4217, + }, + }) + const mockFetch = vi + .fn() + .mockResolvedValueOnce(make402Response(initialChallenge)) + .mockResolvedValueOnce(makeOkResponse()) + .mockImplementationOnce(async (_input, init) => { + const authorization = new Headers((init as RequestInit).headers).get('Authorization') + if (!authorization) throw new Error('missing close authorization') + const credential = PaymentCredential.deserialize(authorization) + expect(credential.challenge.id).toBe(initialChallenge.id) + expect(credential.challenge.request.amount).toBe('1000000') + expect(credential.payload.action).toBe('close') + return make402Response(closeChallenge) + }) + .mockImplementationOnce(async (_input, init) => { + const authorization = new Headers((init as RequestInit).headers).get('Authorization') + if (!authorization) throw new Error('missing retry close authorization') + const credential = PaymentCredential.deserialize(authorization) + expect(credential.challenge.id).toBe(closeChallenge.id) + expect(credential.challenge.request.amount).toBe('2000000') + expect(credential.payload.action).toBe('close') + return makeOkResponse() + }) + + const manager = sessionManagerWithMocks({ + account, + client, + fetch: mockFetch as typeof globalThis.fetch, + maxDeposit: '10', + voucherSigner, + }) + + await manager.fetch('https://api.example.com/data') + await manager.close() + + expect(mockFetch).toHaveBeenCalledTimes(4) + expect((mockFetch.mock.calls[2]![1] as RequestInit).method).toBe('POST') + expect((mockFetch.mock.calls[3]![1] as RequestInit).method).toBe('POST') + } finally { + vi.doUnmock('viem/actions') + vi.resetModules() + } + }) }) }) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index f12ff2c4..0f2eceaa 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -740,7 +740,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const closeChallenge = activeSocketChallenge ?? lastChallenge const closeChannelId = activeSocketChannelId ?? channel?.channelId - if (!channel?.opened) return undefined + const openedChannel = channel?.opened ? channel : null + if (!openedChannel) return undefined if (!closeChallenge) { throw new Error( @@ -753,6 +754,23 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa ) } + const createCloseCredential = async (challenge: Challenge.Challenge) => + method.createCredential({ + challenge: challenge as never, + context: { + action: 'close', + channelId: closeChannelId, + cumulativeAmountRaw: (() => { + const closeAmount = BigInt(getFallbackCloseAmount(challenge, closeChannelId)) + if (closeAmount > openedChannel.cumulativeAmount) { + throw new Error('fallback close amount exceeds local voucher state') + } + assertVoucherWithinLocalLimit(closeAmount) + return closeAmount.toString() + })(), + }, + }) + if (activeSocket?.readyState === WebSocketReadyState.OPEN) { const ready = closeReadyReceipt ?? @@ -761,7 +779,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa return waitForCloseReady() })()) const readySpent = BigInt(ready.spent) - if (readySpent > (channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent)) { + if ( + readySpent > + (openedChannel.cumulativeAmount > spent ? openedChannel.cumulativeAmount : spent) + ) { throw new Error('close-ready spent exceeds local voucher state') } @@ -795,32 +816,30 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa } } - const credential = await method.createCredential({ - challenge: closeChallenge as never, - context: { - action: 'close', - channelId: closeChannelId, - cumulativeAmountRaw: (() => { - const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId)) - if (closeAmount > channel.cumulativeAmount) { - throw new Error('fallback close amount exceeds local voucher state') - } - assertVoucherWithinLocalLimit(closeAmount) - return closeAmount.toString() - })(), - }, - }) - if (!lastUrl) { throw new Error( 'Cannot close session: no URL available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or call fetch()/sse() before close().', ) } - const response = await fetchFn(lastUrl, { + let response = await fetchFn(lastUrl, { method: 'POST', - headers: { Authorization: credential }, + headers: { Authorization: await createCloseCredential(closeChallenge) }, }) + if (response.status === 402) { + const retryChallenge = await selectRetryCloseChallenge( + response, + method, + parameters.orderChallenges, + ) + if (retryChallenge) { + lastChallenge = retryChallenge + response = await fetchFn(lastUrl, { + method: 'POST', + headers: { Authorization: await createCloseCredential(retryChallenge) }, + }) + } + } if (!response.ok) { const body = await response.text().catch(() => '') const detail = (() => { @@ -878,3 +897,22 @@ async function resolveSessionChallengeOrder( const orderChallenges = override return orderChallenges ? orderChallenges(candidates) : candidates } + +async function selectRetryCloseChallenge( + response: Response, + method: SessionMethod, + orderChallenges: SessionOrderChallenges | undefined, +): Promise { + let challenges: Challenge.Challenge[] + try { + challenges = Challenge.fromResponseList(response) + } catch { + return undefined + } + const candidates = AcceptPayment.selectChallengeCandidates( + challenges, + [method], + AcceptPayment.resolve([method]).entries, + ) + return (await resolveSessionChallengeOrder(candidates, orderChallenges))[0]?.challenge +}