Skip to content

Commit 67aa317

Browse files
committed
fix(session): track delivered chunks to prevent overpayment on WS fallback close
1 parent 03ab8b6 commit 67aa317

File tree

3 files changed

+122
-0
lines changed

3 files changed

+122
-0
lines changed

pnpm-workspace.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,5 @@ overrides:
2828
yaml@<2.8.3: '>=2.8.3'
2929
brace-expansion@<5.0.5: '>=5.0.5'
3030
lodash@<=4.17.23: '>=4.18.0'
31+
32+
nodeOptions: '--disable-warning=ExperimentalWarning --disable-warning=DeprecationWarning'

src/tempo/client/SessionManager.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
111111
let closeReadyWaiter: CloseReadyWaiter | null = null
112112
let expectedSocketCloseAmount: string | null = null
113113
let receiptWaiter: ReceiptWaiter | null = null
114+
let wsDeliveredChunks = 0n
115+
let wsTickCost = 0n
114116

115117
const method = sessionPlugin({
116118
account: parameters.account,
@@ -195,6 +197,19 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
195197
}
196198

197199
const cumulative = channel?.channelId === channelId ? channel.cumulativeAmount : 0n
200+
201+
// For WS sessions, use delivered chunk count × tick cost as a tight spend
202+
// estimate. Without this, a socket death before close-ready would cause
203+
// the client to sign for the full cumulative voucher authorization —
204+
// potentially orders of magnitude more than what was actually consumed.
205+
// The estimate may undercount by at most 1 chunk (if the server committed
206+
// a charge but the socket died before delivering the message).
207+
if (wsTickCost > 0n) {
208+
const deliveryEstimate = wsDeliveredChunks * wsTickCost
209+
const bestSpent = spent > deliveryEstimate ? spent : deliveryEstimate
210+
return (bestSpent > cumulative ? cumulative : bestSpent).toString()
211+
}
212+
198213
return (cumulative > spent ? cumulative : spent).toString()
199214
}
200215

@@ -495,6 +510,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
495510

496511
closeReadyReceipt = null
497512
activeSocketChallenge = challenge
513+
wsDeliveredChunks = 0n
514+
wsTickCost = BigInt(challenge.request.amount as string)
498515
const openCredential = PaymentCredential.deserialize<SessionCredentialPayload>(credential)
499516
activeSocketChannelId = openCredential.payload.channelId
500517
const rawSocket = new WebSocketImpl(wsUrl, protocols)
@@ -562,6 +579,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
562579
case 'authorization':
563580
break
564581
case 'message':
582+
wsDeliveredChunks += 1n
565583
managedSocket.emit('message', { data: message.data, type: 'message' })
566584
break
567585
case 'payment-close-ready':

src/tempo/server/Session.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3744,6 +3744,108 @@ describe.runIf(isLocalnet)('session', () => {
37443744
}
37453745
})
37463746

3747+
test('fallback close after socket death signs for delivered amount, not full voucher', async () => {
3748+
const backingStore = Store.memory()
3749+
const routeHandler = Mppx_server.create({
3750+
methods: [
3751+
tempo_server.session({
3752+
store: backingStore,
3753+
getClient: () => client,
3754+
account: recipientAccount,
3755+
currency,
3756+
escrowContract,
3757+
chainId: chain.id,
3758+
}),
3759+
],
3760+
realm: 'api.example.com',
3761+
secretKey: 'secret',
3762+
}).session({ amount: '1', decimals: 6, unitType: 'token' })
3763+
3764+
const route = (request: Request) => routeHandler(request)
3765+
3766+
const httpHandler = NodeRequest.toNodeListener(async (request) => {
3767+
const result = await route(request)
3768+
if (result.status === 402) return result.challenge
3769+
return result.withReceipt(new Response('ok'))
3770+
})
3771+
3772+
const nodeServer = node_http.createServer(httpHandler)
3773+
const wsServer = new WebSocketServer({ noServer: true })
3774+
let serverSocket: import('ws').WebSocket | null = null
3775+
3776+
await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3777+
const { port } = nodeServer.address() as { port: number }
3778+
const server = Http.wrapServer(nodeServer, { port, url: `http://localhost:${port}` })
3779+
3780+
wsServer.on('connection', (socket: import('ws').WebSocket) => {
3781+
serverSocket = socket
3782+
void TempoWs.serve({
3783+
socket,
3784+
store: backingStore,
3785+
url: `${server.url}/ws`,
3786+
route,
3787+
generate: async function* (stream: TempoWs.SessionController) {
3788+
await stream.charge()
3789+
yield 'chunk-1'
3790+
await stream.charge()
3791+
yield 'chunk-2'
3792+
await stream.charge()
3793+
yield 'chunk-3'
3794+
await new Promise((resolve) => setTimeout(resolve, 60_000))
3795+
},
3796+
})
3797+
})
3798+
3799+
nodeServer.on('upgrade', (req, socket, head) => {
3800+
if (req.url !== '/ws') {
3801+
socket.destroy()
3802+
return
3803+
}
3804+
3805+
wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3806+
wsServer.emit('connection', websocket, req)
3807+
})
3808+
})
3809+
3810+
try {
3811+
const manager = sessionManager({
3812+
account: payer,
3813+
client,
3814+
escrowContract,
3815+
fetch: globalThis.fetch,
3816+
maxDeposit: '3',
3817+
})
3818+
3819+
const ws = await manager.ws(`ws://localhost:${port}/ws`)
3820+
const chunks: string[] = []
3821+
3822+
await new Promise<void>((resolve) => {
3823+
ws.addEventListener('message', (event) => {
3824+
if (typeof event.data !== 'string') return
3825+
chunks.push(event.data)
3826+
if (chunks.length === 3) serverSocket?.terminate()
3827+
})
3828+
ws.addEventListener('close', () => resolve(), { once: true })
3829+
})
3830+
3831+
expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
3832+
3833+
const closeReceipt = await manager.close()
3834+
expect(closeReceipt).toBeDefined()
3835+
3836+
const settledAmount = BigInt(closeReceipt!.spent)
3837+
const expectedCost = 3n * 1000000n
3838+
expect(settledAmount).toBeLessThanOrEqual(expectedCost)
3839+
expect(settledAmount).toBeGreaterThan(0n)
3840+
3841+
const fullDeposit = 3000000n
3842+
expect(settledAmount).toBeLessThan(fullDeposit)
3843+
} finally {
3844+
wsServer.close()
3845+
server.close()
3846+
}
3847+
})
3848+
37473849
test('rejects tx-bearing open receipts replayed during websocket close', async () => {
37483850
const routeHandler = Mppx_server.create({
37493851
methods: [

0 commit comments

Comments
 (0)