Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/tempo/client/SessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionCredentialPayload>(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<SessionCredentialPayload>(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()
}
})
})
})
78 changes: 58 additions & 20 deletions src/tempo/client/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 ??
Expand All @@ -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')
}

Expand Down Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -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<Challenge.Challenge | undefined> {
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
}
Loading