From 0aa11b87da9d50832af24ad316fe865538ff357d Mon Sep 17 00:00:00 2001 From: slokh Date: Fri, 20 Feb 2026 00:05:11 -0500 Subject: [PATCH 1/6] fix --- src/tempo/client/SessionManager.ts | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index c0e33427..bc2f8306 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -24,6 +24,14 @@ export type SessionManager = { signal?: AbortSignal | undefined }, ): Promise> + ws( + input: string | URL, + init?: { + protocols?: string | string[] + onReceipt?: ((receipt: SessionReceipt) => void) | undefined + signal?: AbortSignal | undefined + }, + ): Promise close(): Promise } @@ -239,6 +247,100 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa return iterate() }, + async ws(input, init) { + const { onReceipt, signal, protocols } = init ?? {} + + // Convert ws:// → http:// for the 402 challenge/channel-open flow + const wsUrl = new URL(input.toString()) + const httpUrl = new URL(wsUrl.toString()) + httpUrl.protocol = wsUrl.protocol === 'wss:' ? 'https:' : 'http:' + + // Trigger 402 → channel open via HTTP (reuses existing fetch flow) + const httpResponse = await doFetch(httpUrl.toString()) + const wsChallenge = httpResponse.challenge + + if (!wsChallenge) { + throw new Error( + 'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.', + ) + } + + // Open WebSocket + const ws = new WebSocket(wsUrl.toString(), protocols) + + await new Promise((resolve, reject) => { + const onOpen = () => { + ws.removeEventListener('error', onError) + resolve() + } + const onError = (e: Event) => { + ws.removeEventListener('open', onOpen) + reject(e) + } + ws.addEventListener('open', onOpen, { once: true }) + ws.addEventListener('error', onError, { once: true }) + }) + + // Send initial credential as first message (in-band auth) + if (channel && wsChallenge) { + const credential = await method.createCredential({ + challenge: wsChallenge as never, + context: { + action: 'voucher', + channelId: channel.channelId, + cumulativeAmountRaw: channel.cumulativeAmount.toString(), + }, + }) + ws.send(JSON.stringify({ mpp: 'credential', mppVersion: '1', authorization: credential })) + } + + // Intercept payment messages (need-voucher, receipt) + // stopImmediatePropagation prevents MPP messages from reaching user listeners. + ws.addEventListener('message', async (event) => { + const raw = typeof event.data === 'string' ? event.data : undefined + if (!raw) return + try { + const msg = JSON.parse(raw) + if (!msg.mpp) return + + event.stopImmediatePropagation() + + if (msg.mpp === 'need-voucher' && channel && wsChallenge) { + const required = BigInt(msg.data.requiredCumulative) + channel.cumulativeAmount = + channel.cumulativeAmount > required ? channel.cumulativeAmount : required + + try { + const credential = await method.createCredential({ + challenge: wsChallenge as never, + context: { + action: 'voucher', + channelId: channel.channelId, + cumulativeAmountRaw: channel.cumulativeAmount.toString(), + }, + }) + ws.send( + JSON.stringify({ mpp: 'voucher', mppVersion: '1', authorization: credential }), + ) + } catch { + ws.close(1011, 'Failed to create payment credential') + } + } else if (msg.mpp === 'receipt') { + updateSpentFromReceipt(msg.data) + onReceipt?.(msg.data) + } + } catch { + // Not JSON — not an MPP message, ignore + } + }) + + if (signal) { + signal.addEventListener('abort', () => ws.close(), { once: true }) + } + + return ws + }, + async close() { if (!channel?.opened || !lastChallenge) return undefined From d7942f4d81590e2b5d8089b124ae8a425e5f2b9c Mon Sep 17 00:00:00 2001 From: slokh Date: Fri, 20 Feb 2026 12:31:33 -0500 Subject: [PATCH 2/6] fix --- src/tempo/client/SessionManager.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index bc2f8306..a4024965 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -307,8 +307,23 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (msg.mpp === 'need-voucher' && channel && wsChallenge) { const required = BigInt(msg.data.requiredCumulative) + const accepted = BigInt(msg.data.acceptedCumulative ?? '0') + const deposit = BigInt(msg.data.deposit ?? '0') + + // Pre-authorize a batch of chunks to reduce round-trips. + // On first need-voucher (accepted=0), use `required` as the unit price + // since the server tells us exactly what one unit costs. + // On subsequent vouchers, derive unit price from the delta. + const unitPrice = accepted === 0n + ? (required > 0n ? required : 1n) + : (required > accepted ? required - accepted : 1n) + const batchTarget = required + unitPrice * 99n // ~100 chunks total + // Cap at deposit to avoid exceeding on-chain balance + const capped = deposit > 0n && batchTarget > deposit ? deposit : batchTarget + const newCumulative = capped > required ? capped : required + channel.cumulativeAmount = - channel.cumulativeAmount > required ? channel.cumulativeAmount : required + channel.cumulativeAmount > newCumulative ? channel.cumulativeAmount : newCumulative try { const credential = await method.createCredential({ @@ -322,7 +337,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa ws.send( JSON.stringify({ mpp: 'voucher', mppVersion: '1', authorization: credential }), ) - } catch { + } catch (err) { + console.error('[mppx] ws voucher creation failed:', err) ws.close(1011, 'Failed to create payment credential') } } else if (msg.mpp === 'receipt') { From e09a2a9192aee2a3b7f92a51f2a7deae96995e7f Mon Sep 17 00:00:00 2001 From: slokh Date: Thu, 19 Mar 2026 11:29:39 -0700 Subject: [PATCH 3/6] fix --- src/tempo/client/SessionManager.test.ts | 75 ++++++++++++++++++++++++- src/tempo/client/SessionManager.ts | 8 +-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 92dece91..95053a97 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from 'vitest' import * as Challenge from '../../Challenge.js' import { formatNeedVoucherEvent, parseEvent } from '../session/Sse.js' import type { NeedVoucherEvent, SessionReceipt } from '../session/Types.js' -import { sessionManager } from './SessionManager.js' +import { WS_MPP_VERSION, WsMessageType, sessionManager } from './SessionManager.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex const challengeId = 'test-challenge-1' @@ -207,6 +207,79 @@ describe('Session', () => { }) }) + describe('.ws()', () => { + function createMockWebSocket() { + const listeners = new Map void)[]>() + const sent: string[] = [] + const ws = { + send: vi.fn((data: string) => sent.push(data)), + close: vi.fn(), + addEventListener: vi.fn((type: string, fn: (...args: any[]) => void, opts?: any) => { + if (!listeners.has(type)) listeners.set(type, []) + listeners.get(type)!.push(fn) + if (type === 'open') setTimeout(() => fn(), 0) + }), + removeEventListener: vi.fn(), + } + return { ws, sent, listeners, dispatch: (type: string, data: any) => { + for (const fn of listeners.get(type) ?? []) fn(data) + }} + } + + test('throws when no challenge received from HTTP endpoint', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeOkResponse()) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + await expect(s.ws('ws://api.example.com/stream')).rejects.toThrow( + 'No payment challenge received', + ) + }) + + test('converts ws:// to http:// for the 402 handshake', async () => { + const mockFetch = vi.fn().mockResolvedValue(make402Response()) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + // Will throw because no maxDeposit — but we can verify the URL was converted + await expect(s.ws('ws://api.example.com/stream')).rejects.toThrow() + const calledUrl = mockFetch.mock.calls[0]?.[0] + expect(calledUrl).toContain('http://api.example.com/stream') + }) + + test('converts wss:// to https:// for the 402 handshake', async () => { + const mockFetch = vi.fn().mockResolvedValue(make402Response()) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + await expect(s.ws('wss://api.example.com/stream')).rejects.toThrow() + const calledUrl = mockFetch.mock.calls[0]?.[0] + expect(calledUrl).toContain('https://api.example.com/stream') + }) + }) + + describe('WsMessageType constants', () => { + test('has expected values', () => { + expect(WsMessageType.credential).toBe('credential') + expect(WsMessageType.voucher).toBe('voucher') + expect(WsMessageType.needVoucher).toBe('need-voucher') + expect(WsMessageType.receipt).toBe('receipt') + }) + + test('WS_MPP_VERSION is "1"', () => { + expect(WS_MPP_VERSION).toBe('1') + }) + }) + describe('.close()', () => { test('is no-op when not opened', async () => { const mockFetch = vi.fn() diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index a4024965..b4c74f79 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -291,7 +291,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa cumulativeAmountRaw: channel.cumulativeAmount.toString(), }, }) - ws.send(JSON.stringify({ mpp: 'credential', mppVersion: '1', authorization: credential })) + ws.send(JSON.stringify({ mpp: WsMessageType.credential, mppVersion: WS_MPP_VERSION, authorization: credential })) } // Intercept payment messages (need-voucher, receipt) @@ -305,7 +305,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa event.stopImmediatePropagation() - if (msg.mpp === 'need-voucher' && channel && wsChallenge) { + if (msg.mpp === WsMessageType.needVoucher && channel && wsChallenge) { const required = BigInt(msg.data.requiredCumulative) const accepted = BigInt(msg.data.acceptedCumulative ?? '0') const deposit = BigInt(msg.data.deposit ?? '0') @@ -335,13 +335,13 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }, }) ws.send( - JSON.stringify({ mpp: 'voucher', mppVersion: '1', authorization: credential }), + JSON.stringify({ mpp: WsMessageType.voucher, mppVersion: WS_MPP_VERSION, authorization: credential }), ) } catch (err) { console.error('[mppx] ws voucher creation failed:', err) ws.close(1011, 'Failed to create payment credential') } - } else if (msg.mpp === 'receipt') { + } else if (msg.mpp === WsMessageType.receipt) { updateSpentFromReceipt(msg.data) onReceipt?.(msg.data) } From c9799abad497025829753647ad40100c22014b13 Mon Sep 17 00:00:00 2001 From: slokh Date: Thu, 19 Mar 2026 11:30:11 -0700 Subject: [PATCH 4/6] fix --- src/tempo/client/SessionManager.test.ts | 15 +-------------- src/tempo/client/SessionManager.ts | 8 ++++---- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 95053a97..3d55f035 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from 'vitest' import * as Challenge from '../../Challenge.js' import { formatNeedVoucherEvent, parseEvent } from '../session/Sse.js' import type { NeedVoucherEvent, SessionReceipt } from '../session/Types.js' -import { WS_MPP_VERSION, WsMessageType, sessionManager } from './SessionManager.js' +import { sessionManager } from './SessionManager.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex const challengeId = 'test-challenge-1' @@ -267,19 +267,6 @@ describe('Session', () => { }) }) - describe('WsMessageType constants', () => { - test('has expected values', () => { - expect(WsMessageType.credential).toBe('credential') - expect(WsMessageType.voucher).toBe('voucher') - expect(WsMessageType.needVoucher).toBe('need-voucher') - expect(WsMessageType.receipt).toBe('receipt') - }) - - test('WS_MPP_VERSION is "1"', () => { - expect(WS_MPP_VERSION).toBe('1') - }) - }) - describe('.close()', () => { test('is no-op when not opened', async () => { const mockFetch = vi.fn() diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index b4c74f79..a4024965 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -291,7 +291,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa cumulativeAmountRaw: channel.cumulativeAmount.toString(), }, }) - ws.send(JSON.stringify({ mpp: WsMessageType.credential, mppVersion: WS_MPP_VERSION, authorization: credential })) + ws.send(JSON.stringify({ mpp: 'credential', mppVersion: '1', authorization: credential })) } // Intercept payment messages (need-voucher, receipt) @@ -305,7 +305,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa event.stopImmediatePropagation() - if (msg.mpp === WsMessageType.needVoucher && channel && wsChallenge) { + if (msg.mpp === 'need-voucher' && channel && wsChallenge) { const required = BigInt(msg.data.requiredCumulative) const accepted = BigInt(msg.data.acceptedCumulative ?? '0') const deposit = BigInt(msg.data.deposit ?? '0') @@ -335,13 +335,13 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }, }) ws.send( - JSON.stringify({ mpp: WsMessageType.voucher, mppVersion: WS_MPP_VERSION, authorization: credential }), + JSON.stringify({ mpp: 'voucher', mppVersion: '1', authorization: credential }), ) } catch (err) { console.error('[mppx] ws voucher creation failed:', err) ws.close(1011, 'Failed to create payment credential') } - } else if (msg.mpp === WsMessageType.receipt) { + } else if (msg.mpp === 'receipt') { updateSpentFromReceipt(msg.data) onReceipt?.(msg.data) } From 2f8ec3c2c89f162028d8de7ecebc25dfea53c224 Mon Sep 17 00:00:00 2001 From: slokh Date: Thu, 19 Mar 2026 11:30:47 -0700 Subject: [PATCH 5/6] fix --- src/tempo/client/SessionManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index a4024965..8e87f185 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -305,7 +305,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa event.stopImmediatePropagation() - if (msg.mpp === 'need-voucher' && channel && wsChallenge) { + if (msg.mpp === 'payment-need-voucher' && channel && wsChallenge) { const required = BigInt(msg.data.requiredCumulative) const accepted = BigInt(msg.data.acceptedCumulative ?? '0') const deposit = BigInt(msg.data.deposit ?? '0') @@ -341,7 +341,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa console.error('[mppx] ws voucher creation failed:', err) ws.close(1011, 'Failed to create payment credential') } - } else if (msg.mpp === 'receipt') { + } else if (msg.mpp === 'payment-receipt') { updateSpentFromReceipt(msg.data) onReceipt?.(msg.data) } From d70261b4b764d3d68ee503f88ccbed5b62a14402 Mon Sep 17 00:00:00 2001 From: slokh Date: Thu, 19 Mar 2026 11:31:27 -0700 Subject: [PATCH 6/6] fix --- src/tempo/client/SessionManager.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 3d55f035..22c3278a 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -208,24 +208,6 @@ describe('Session', () => { }) describe('.ws()', () => { - function createMockWebSocket() { - const listeners = new Map void)[]>() - const sent: string[] = [] - const ws = { - send: vi.fn((data: string) => sent.push(data)), - close: vi.fn(), - addEventListener: vi.fn((type: string, fn: (...args: any[]) => void, opts?: any) => { - if (!listeners.has(type)) listeners.set(type, []) - listeners.get(type)!.push(fn) - if (type === 'open') setTimeout(() => fn(), 0) - }), - removeEventListener: vi.fn(), - } - return { ws, sent, listeners, dispatch: (type: string, data: any) => { - for (const fn of listeners.get(type) ?? []) fn(data) - }} - } - test('throws when no challenge received from HTTP endpoint', async () => { const mockFetch = vi.fn().mockResolvedValue(makeOkResponse())