diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 0bc0f650..83642a80 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -1,7 +1,15 @@ import type { z } from 'mppx' -import { Challenge } from 'mppx' +import { Challenge, Credential } from 'mppx' import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' -import { type Address, createClient, type Hex } from 'viem' +import { Base64 } from 'ox' +import { + type Address, + createClient, + type Hex, + parseSignature, + serializeCompactSignature, + signatureToCompactSignature, +} from 'viem' import { waitForTransactionReceipt } from 'viem/actions' import { Addresses } from 'viem/tempo' import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vp/test' @@ -9,6 +17,7 @@ import { nodeEnv } from '~test/config.js' const isLocalnet = nodeEnv === 'localnet' import { + closeChannelOnChain, deployEscrow, requestCloseChannel, signOpenChannel, @@ -24,6 +33,7 @@ import { InvalidSignatureError, } from '../../Errors.js' import * as Store from '../../Store.js' +import { sessionManager } from '../client/SessionManager.js' import { chainId as chainIdDefaults, escrowContract as escrowContractDefaults, @@ -35,6 +45,7 @@ import { signVoucher } from '../session/Voucher.js' import { charge, session, settle } from './Session.js' const payer = accounts[2] +const delegatedSigner = accounts[4] const recipientAccount = accounts[0] const recipient = accounts[0].address const currency = asset @@ -70,6 +81,39 @@ describe.runIf(isLocalnet)('session', () => { } as session.Parameters) } + function createServerWithStore( + customStore: Store.Store, + overrides: Partial = {}, + ) { + return session({ + store: customStore, + getClient: () => client, + account: recipientAccount, + currency, + escrowContract, + chainId: chain.id, + ...overrides, + } as session.Parameters) + } + + function createHandler(overrides: Partial = {}) { + return Mppx_server.create({ + methods: [ + tempo_server.session({ + store: rawStore, + getClient: () => client, + account: recipientAccount, + currency, + escrowContract, + chainId: chain.id, + ...overrides, + } as session.Parameters), + ], + realm: 'api.example.com', + secretKey: 'secret', + }) + } + describe('open', () => { test('accepts a valid open with voucher', async () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) @@ -1391,6 +1435,185 @@ describe.runIf(isLocalnet)('session', () => { expect(chAfter).not.toBeNull() expect(chAfter!.highestVoucherAmount).toBe(7000000n) }) + + test('supports delegated signer end-to-end (open -> voucher -> close)', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n, { + authorizedSigner: delegatedSigner.address, + }) + const server = createServer() + + const openReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-delegated', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n, delegatedSigner), + }, + }, + request: makeRequest(), + }) + expect(openReceipt.status).toBe('success') + + const channel = await store.getChannel(channelId) + expect(channel?.authorizedSigner).toBe(delegatedSigner.address) + + const voucherReceipt = (await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-delegated', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n, delegatedSigner), + }, + }, + request: makeRequest(), + })) as SessionReceipt + expect(voucherReceipt.acceptedCumulative).toBe('2000000') + + const closeReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-delegated', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n, delegatedSigner), + }, + }, + request: makeRequest(), + }) + expect(closeReceipt.status).toBe('success') + }) + + test('open -> topUp -> topUp -> voucher/charge -> close', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(4000000n) + const server = createServer() + + const openReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-multi-topup', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + expect(openReceipt.status).toBe('success') + + await charge(store, channelId, 1000000n) + await expect(charge(store, channelId, 1000000n)).rejects.toThrow('requested') + + const topUp1Amount = 2000000n + const { serializedTransaction: topUp1 } = await signTopUpChannel({ + escrow: escrowContract, + payer, + channelId, + token: currency, + amount: topUp1Amount, + }) + + const topUp1Receipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'topup-1', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUp1, + additionalDeposit: topUp1Amount.toString(), + }, + }, + request: makeRequest(), + }) + expect(topUp1Receipt.status).toBe('success') + expect((await store.getChannel(channelId))?.deposit).toBe(6000000n) + + const voucher1 = (await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-after-topup-1', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '3000000', + signature: await signTestVoucher(channelId, 3000000n), + }, + }, + request: makeRequest(), + })) as SessionReceipt + expect(voucher1.acceptedCumulative).toBe('3000000') + + await charge(store, channelId, 2000000n) + await expect(charge(store, channelId, 1000000n)).rejects.toThrow('requested') + + const topUp2Amount = 2000000n + const { serializedTransaction: topUp2 } = await signTopUpChannel({ + escrow: escrowContract, + payer, + channelId, + token: currency, + amount: topUp2Amount, + }) + + const topUp2Receipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'topup-2', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUp2, + additionalDeposit: topUp2Amount.toString(), + }, + }, + request: makeRequest(), + }) + expect(topUp2Receipt.status).toBe('success') + expect((await store.getChannel(channelId))?.deposit).toBe(8000000n) + + const voucher2 = (await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-after-topup-2', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, + }, + request: makeRequest(), + })) as SessionReceipt + expect(voucher2.acceptedCumulative).toBe('5000000') + + await charge(store, channelId, 2000000n) + + const closeReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-multi-topup', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, + }, + request: makeRequest(), + }) + expect(closeReceipt.status).toBe('success') + + const finalized = await store.getChannel(channelId) + expect(finalized?.spent).toBe(5000000n) + expect(finalized?.finalized).toBe(true) + }) }) describe('charge', () => { @@ -1873,77 +2096,558 @@ describe.runIf(isLocalnet)('session', () => { }) }) - describe('structured errors', () => { - test('ChannelNotFoundError on unknown channel', async () => { - const { channelId } = await createSignedOpenTransaction(10000000n) + describe('signature compatibility', () => { + test('rejects malformed compact signatures', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() - try { - await server.verify({ + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-baseline', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + const compact = toCompactSignature(await signTestVoucher(channelId, 2000000n)) + await expect( + server.verify({ credential: { - challenge: makeChallenge({ channelId }), + challenge: makeChallenge({ id: 'voucher-invalid-compact', channelId }), payload: { action: 'voucher' as const, channelId, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + cumulativeAmount: '2000000', + signature: mutateSignature(compact), }, }, request: makeRequest(), - }) - expect.unreachable() - } catch (e) { - expect(e).toBeInstanceOf(ChannelNotFoundError) - expect((e as ChannelNotFoundError).status).toBe(410) - } + }), + ).rejects.toThrow('invalid voucher signature') }) + }) - test('InvalidSignatureError has status 402', async () => { + describe('session-level concurrency', () => { + test('concurrent voucher submissions linearize to monotonic final state', async () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() - try { - await server.verify({ - credential: { - challenge: makeChallenge({ channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: `0x${'ab'.repeat(65)}` as Hex, - }, + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-concurrency', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), }, - request: makeRequest(), - }) - expect.unreachable() - } catch (e) { - expect(e).toBeInstanceOf(InvalidSignatureError) - expect((e as InvalidSignatureError).status).toBe(402) - } + }, + request: makeRequest(), + }) + + const amounts = [2000000n, 3000000n, 4000000n, 5000000n] + const results = await Promise.allSettled( + amounts.map(async (amount, index) => + server.verify({ + credential: { + challenge: makeChallenge({ id: `voucher-concurrency-${index}`, channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: amount.toString(), + signature: await signTestVoucher(channelId, amount), + }, + }, + request: makeRequest(), + }), + ), + ) + + const fulfilled = results.filter((result) => result.status === 'fulfilled') + expect(fulfilled.length).toBeGreaterThan(0) + + const channel = await store.getChannel(channelId) + expect(channel?.highestVoucherAmount).toBe(5000000n) + expect(channel?.spent).toBe(0n) }) }) - describe('respond', () => { - test('returns 204 for POST with open action', () => { - const server = createServer() - const result = server.respond!({ + describe('fault tolerance', () => { + test('recovers after open write crash by replaying open against on-chain state', async () => { + const baseStore = Store.memory() + const faultStore = withFaultHooks(baseStore, { failPutAt: 1 }) + const faultServer = createServerWithStore(faultStore) + + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const openPayload = { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + } + + await expect( + faultServer.verify({ + credential: { + challenge: makeChallenge({ id: 'open-crash-1', channelId }), + payload: openPayload, + }, + request: makeRequest(), + }), + ).rejects.toThrow('simulated store crash before persisting') + + const afterCrashStore = ChannelStore.fromStore(baseStore) + expect(await afterCrashStore.getChannel(channelId)).toBeNull() + + const healthyServer = createServerWithStore(baseStore) + const recovered = await healthyServer.verify({ credential: { - challenge: makeChallenge({ - channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - }), - payload: { action: 'open' }, + challenge: makeChallenge({ id: 'open-crash-retry', channelId }), + payload: openPayload, }, - input: new Request('http://localhost', { method: 'POST' }), - } as any) - expect(result).toBeInstanceOf(Response) - expect((result as Response).status).toBe(204) + request: makeRequest(), + }) + + expect(recovered.status).toBe('success') + const channel = await afterCrashStore.getChannel(channelId) + expect(channel?.highestVoucherAmount).toBe(1000000n) + expect(channel?.deposit).toBe(10000000n) }) - test('returns 204 for POST with topUp action', () => { - const server = createServer() - const result = server.respond!({ + test('recovers stale deposit after topUp write crash by reopening from on-chain state', async () => { + const baseStore = Store.memory() + const healthyServer = createServerWithStore(baseStore) + const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n) + + await healthyServer.verify({ + credential: { + challenge: makeChallenge({ id: 'open-before-topup-crash', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + const additionalDeposit = 2000000n + const { serializedTransaction: topUpTransaction } = await signTopUpChannel({ + escrow: escrowContract, + payer, + channelId, + token: currency, + amount: additionalDeposit, + }) + + const faultStore = withFaultHooks(baseStore, { failPutAt: 1 }) + const faultServer = createServerWithStore(faultStore) + + await expect( + faultServer.verify({ + credential: { + challenge: makeChallenge({ id: 'topup-crash', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUpTransaction, + additionalDeposit: additionalDeposit.toString(), + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('simulated store crash before persisting') + + const staleStore = ChannelStore.fromStore(baseStore) + expect((await staleStore.getChannel(channelId))?.deposit).toBe(5000000n) + + await healthyServer.verify({ + credential: { + challenge: makeChallenge({ id: 'reopen-after-topup-crash', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n), + }, + }, + request: makeRequest(), + }) + + const recoveredChannel = await staleStore.getChannel(channelId) + expect(recoveredChannel?.deposit).toBe(7000000n) + }) + + test('voucher rejects when channel disappears between read and update', async () => { + const baseStore = Store.memory() + const hooks = withReadDropHooks(baseStore) + const server = createServerWithStore(hooks.store) + + const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n) + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-racy-voucher', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + hooks.dropOnRead(channelId, 1) + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-racy-missing', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n), + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('channel not found') + + const persisted = await ChannelStore.fromStore(baseStore).getChannel(channelId) + expect(persisted).not.toBeNull() + }) + + test('close still returns a receipt when channel disappears before final write', async () => { + const baseStore = Store.memory() + const hooks = withReadDropHooks(baseStore) + const server = createServerWithStore(hooks.store) + + const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n) + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-racy-close', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + hooks.dropOnRead(channelId, 1) + const closeReceipt = (await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-racy-missing', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + })) as SessionReceipt + + expect(closeReceipt.status).toBe('success') + expect(closeReceipt.spent).toBe('0') + const persisted = await ChannelStore.fromStore(baseStore).getChannel(channelId) + expect(persisted).toBeNull() + }) + + test('settle returns txHash even when channel disappears before settle write', async () => { + const baseStore = Store.memory() + const hooks = withReadDropHooks(baseStore) + const server = createServerWithStore(hooks.store) + + const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n) + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-racy-settle', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + hooks.dropOnRead(channelId, 1) + const txHash = await settle(ChannelStore.fromStore(hooks.store), client, channelId, { + escrowContract, + }) + + expect(txHash).toBeDefined() + const persisted = await ChannelStore.fromStore(baseStore).getChannel(channelId) + expect(persisted).toBeNull() + }) + + test('close rejects when channel was already finalized on-chain', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n) + const server = createServer() + + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-before-external-close', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + const closeSignature = await signTestVoucher(channelId, 1000000n) + await closeChannelOnChain({ + escrow: escrowContract, + payee: accounts[0], + channelId, + cumulativeAmount: 1000000n, + signature: closeSignature, + }) + + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-after-external-finalize', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: closeSignature, + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('channel is finalized on-chain') + }) + }) + + describe('protocol compatibility', () => { + test('HEAD voucher management request falls through to content handler', () => { + const server = createServer() + const response = server.respond!({ + credential: { + challenge: makeChallenge({ + channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + }), + payload: { action: 'voucher' }, + }, + input: new Request('https://api.example.com/resource', { method: 'HEAD' }), + } as never) + + expect(response).toBeUndefined() + }) + + test('ignores unknown challenge and credential fields for forward compatibility', async () => { + const challenge = Challenge.from({ + id: 'forward-compat', + realm: 'api.example.com', + method: 'tempo', + intent: 'session', + request: { + amount: '1000000', + currency: '0x20c0000000000000000000000000000000000001', + recipient: '0x0000000000000000000000000000000000000002', + unitType: 'token', + }, + }) + const parsed = Challenge.deserialize(`${Challenge.serialize(challenge)}, future="v1"`) + expect(parsed.id).toBe(challenge.id) + + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const handler = createHandler() + const route = handler.session({ amount: '1', decimals: 6, unitType: 'token' }) + + const first = await route(new Request('https://api.example.com/resource')) + if (first.status !== 402) throw new Error('expected challenge') + const issuedChallenge = Challenge.fromResponse(first.challenge) + const signature = await signTestVoucher(channelId, 1000000n) + + const header = Credential.serialize({ + challenge: issuedChallenge, + payload: { + action: 'open', + type: 'transaction', + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature, + }, + }) + const encoded = header.replace(/^Payment\s+/i, '') + const decoded = JSON.parse(Base64.toString(encoded)) as Record + decoded.payload.futureField = { enabled: true } + decoded.unrecognized = 'ignored' + const mutatedHeader = `Payment ${Base64.fromString(JSON.stringify(decoded), { url: true, pad: false })}` + + const second = await route( + new Request('https://api.example.com/resource', { + headers: { Authorization: mutatedHeader }, + }), + ) + expect(second.status).toBe(200) + }) + + test('does not return Payment-Receipt on verification errors', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const handler = createHandler() + const route = handler.session({ amount: '1', decimals: 6, unitType: 'token' }) + + const first = await route(new Request('https://api.example.com/resource')) + if (first.status !== 402) throw new Error('expected challenge') + const issuedChallenge = Challenge.fromResponse(first.challenge) + + const invalidCredential = Credential.serialize({ + challenge: issuedChallenge, + payload: { + action: 'open', + type: 'transaction', + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: `0x${'ab'.repeat(65)}`, + }, + }) + + const second = await route( + new Request('https://api.example.com/resource', { + headers: { Authorization: invalidCredential }, + }), + ) + + expect(second.status).toBe(402) + if (second.status !== 402) throw new Error('expected challenge') + expect(second.challenge.headers.get('Payment-Receipt')).toBeNull() + }) + + test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => { + const handler = createHandler() + const route = handler.session({ + amount: '0.000000000000000001', + suggestedDeposit: '0.000000000000000002', + minVoucherDelta: '0.000000000000000001', + decimals: 18, + unitType: 'token', + }) + + const result = await route(new Request('https://api.example.com/resource')) + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error('expected challenge') + + const challenge = Challenge.fromResponse(result.challenge) + const request = challenge.request as { + amount: string + suggestedDeposit: string + methodDetails: { minVoucherDelta: string } + } + expect(request.amount).toBe('1') + expect(request.suggestedDeposit).toBe('2') + expect(request.methodDetails.minVoucherDelta).toBe('1') + }) + }) + + describe('structured errors', () => { + test('ChannelNotFoundError on unknown channel', async () => { + const { channelId } = await createSignedOpenTransaction(10000000n) + const server = createServer() + + try { + await server.verify({ + credential: { + challenge: makeChallenge({ channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + expect.unreachable() + } catch (e) { + expect(e).toBeInstanceOf(ChannelNotFoundError) + expect((e as ChannelNotFoundError).status).toBe(410) + } + }) + + test('InvalidSignatureError has status 402', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer() + + try { + await server.verify({ + credential: { + challenge: makeChallenge({ channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: `0x${'ab'.repeat(65)}` as Hex, + }, + }, + request: makeRequest(), + }) + expect.unreachable() + } catch (e) { + expect(e).toBeInstanceOf(InvalidSignatureError) + expect((e as InvalidSignatureError).status).toBe(402) + } + }) + }) + + describe('respond', () => { + test('returns 204 for POST with open action', () => { + const server = createServer() + const result = server.respond!({ + credential: { + challenge: makeChallenge({ + channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + }), + payload: { action: 'open' }, + }, + input: new Request('http://localhost', { method: 'POST' }), + } as any) + expect(result).toBeInstanceOf(Response) + expect((result as Response).status).toBe(204) + }) + + test('returns 204 for POST with topUp action', () => { + const server = createServer() + const result = server.respond!({ credential: { challenge: makeChallenge({ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, @@ -2203,6 +2907,158 @@ describe.runIf(isLocalnet)('session', () => { } }) + test('open -> stream -> need-voucher -> resume -> close', async () => { + const backingStore = Store.memory() + const routeHandler = Mppx_server.create({ + methods: [ + tempo_server.session({ + store: backingStore, + getClient: () => client, + account: recipientAccount, + currency, + escrowContract, + chainId: chain.id, + sse: true, + }), + ], + realm: 'api.example.com', + secretKey: 'secret', + }).session({ amount: '1', decimals: 6, unitType: 'token' }) + + let voucherPosts = 0 + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + let action: 'open' | 'topUp' | 'voucher' | 'close' | undefined + + if (request.method === 'POST' && request.headers.has('Authorization')) { + try { + const credential = Credential.fromRequest(request) + action = credential.payload?.action + if (action === 'voucher') voucherPosts++ + } catch {} + } + + const result = await routeHandler(request) + if (result.status === 402) return result.challenge + + if (action === 'voucher') { + return new Response(null, { status: 200 }) + } + + if (request.headers.get('Accept')?.includes('text/event-stream')) { + return result.withReceipt(async function* (stream) { + await stream.charge() + yield 'chunk-1' + await stream.charge() + yield 'chunk-2' + await stream.charge() + yield 'chunk-3' + }) + } + + return result.withReceipt(new Response('ok')) + } + + const manager = sessionManager({ + account: payer, + client, + escrowContract, + fetch, + maxDeposit: '3', + }) + + const chunks: string[] = [] + const stream = await manager.sse('https://api.example.com/stream') + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3']) + expect(voucherPosts).toBeGreaterThan(0) + + const closeReceipt = await manager.close() + expect(closeReceipt?.status).toBe('success') + expect(closeReceipt?.spent).toBe('3000000') + + const channelId = manager.channelId + expect(channelId).toBeTruthy() + + const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId!) + expect(persisted?.finalized).toBe(true) + }) + + test('handles repeated exhaustion/resume cycles within one stream', async () => { + const backingStore = Store.memory() + const routeHandler = Mppx_server.create({ + methods: [ + tempo_server.session({ + store: backingStore, + getClient: () => client, + account: recipientAccount, + currency, + escrowContract, + chainId: chain.id, + sse: true, + }), + ], + realm: 'api.example.com', + secretKey: 'secret', + }).session({ amount: '1', decimals: 6, unitType: 'token' }) + + let voucherPosts = 0 + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + let action: 'open' | 'topUp' | 'voucher' | 'close' | undefined + + if (request.method === 'POST' && request.headers.has('Authorization')) { + try { + const credential = Credential.fromRequest(request) + action = credential.payload?.action + if (action === 'voucher') voucherPosts++ + } catch {} + } + + const result = await routeHandler(request) + if (result.status === 402) return result.challenge + + if (action === 'voucher') { + return new Response(null, { status: 200 }) + } + + if (request.headers.get('Accept')?.includes('text/event-stream')) { + return result.withReceipt(async function* (stream) { + await stream.charge() + yield 'chunk-1' + await stream.charge() + yield 'chunk-2' + await stream.charge() + yield 'chunk-3' + await stream.charge() + yield 'chunk-4' + }) + } + + return result.withReceipt(new Response('ok')) + } + + const manager = sessionManager({ + account: payer, + client, + escrowContract, + fetch, + maxDeposit: '4', + }) + + const chunks: string[] = [] + const stream = await manager.sse('https://api.example.com/stream') + for await (const chunk of stream) chunks.push(chunk) + + expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3', 'chunk-4']) + expect(voucherPosts).toBeGreaterThanOrEqual(2) + + const closeReceipt = await manager.close() + expect(closeReceipt?.status).toBe('success') + expect(closeReceipt?.spent).toBe('4000000') + }) + test('behavior: charge withReceipt returns Response', async () => { const handler = Mppx_server.create({ methods: [tempo_server.charge({ account: accounts[0], currency: asset })], @@ -2313,6 +3169,166 @@ describe('monotonicity and TOCTOU (unit tests)', () => { }) }) +describe('session request and verify guardrails', () => { + const accountOne = accounts[0] + const addressTwo = '0x0000000000000000000000000000000000000002' as Address + const defaultCurrency = '0x20c0000000000000000000000000000000000000' + const defaultEscrow = '0x0000000000000000000000000000000000000003' + + function createMockClient(chainId: number) { + return createClient({ + chain: { + id: chainId, + name: `Mock Chain ${chainId}`, + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['http://localhost:1'] } }, + }, + transport: http('http://localhost:1'), + }) + } + + function makeRequest(overrides: Partial> = {}) { + return { + amount: '1', + unitType: 'token', + currency: defaultCurrency, + decimals: 6, + recipient: addressTwo, + chainId: 4217, + ...overrides, + } + } + + test('request throws when no client exists for requested chain', async () => { + const server = session({ + store: Store.memory(), + account: accountOne, + currency: defaultCurrency, + getClient: async () => { + throw new Error('unreachable chain') + }, + } as session.Parameters) + + await expect( + server.request!({ + credential: null, + request: makeRequest({ chainId: 31337 }), + } as never), + ).rejects.toThrow('No client configured with chainId 31337.') + }) + + test('request throws when resolved client chain mismatches requested chain', async () => { + const wrongChainClient = createMockClient(42431) + const server = session({ + store: Store.memory(), + account: accountOne, + currency: defaultCurrency, + getClient: async () => wrongChainClient, + } as session.Parameters) + + await expect( + server.request!({ + credential: null, + request: makeRequest({ chainId: 4217 }), + } as never), + ).rejects.toThrow('Client not configured with chainId 4217.') + }) + + test('request normalizes fee-payer to boolean for challenge issuance and account for verification', async () => { + const client = createMockClient(4217) + const server = session({ + store: Store.memory(), + account: accountOne, + currency: defaultCurrency, + feePayer: 'https://fee-payer.example.com', + getClient: async () => client, + } as session.Parameters) + + const challengeRequest = await server.request!({ + credential: null, + request: makeRequest(), + } as never) + expect(challengeRequest.feePayer).toBe(true) + + const verificationRequest = await server.request!({ + credential: { challenge: {}, payload: {} } as never, + request: makeRequest({ feePayer: accounts[1] }), + } as never) + expect(verificationRequest.feePayer).toBe(accounts[1]) + }) + + test('request allows callers to explicitly disable fee-payer', async () => { + const client = createMockClient(4217) + const server = session({ + store: Store.memory(), + account: accountOne, + currency: defaultCurrency, + feePayer: 'https://fee-payer.example.com', + getClient: async () => client, + } as session.Parameters) + + const normalized = await server.request!({ + credential: null, + request: makeRequest({ feePayer: false }), + } as never) + expect(normalized.feePayer).toBeUndefined() + }) + + test('request leaves escrowContract undefined when chain has no configured default', async () => { + const unknownChainId = 999_999 + const client = createMockClient(unknownChainId) + const server = session({ + store: Store.memory(), + account: accountOne, + currency: defaultCurrency, + getClient: async () => client, + } as session.Parameters) + + const normalized = await server.request!({ + credential: null, + request: makeRequest({ chainId: unknownChainId }), + } as never) + + expect(normalized.escrowContract).toBeUndefined() + }) + + test('verify rejects unknown session actions', async () => { + const client = createMockClient(4217) + const server = session({ + store: Store.memory(), + account: accountOne, + currency: defaultCurrency, + getClient: async () => client, + escrowContract: defaultEscrow, + chainId: 4217, + } as session.Parameters) + + await expect( + server.verify({ + credential: { + challenge: { + id: 'guard-unknown-action', + realm: 'api.example.com', + method: 'tempo', + intent: 'session', + request: { + amount: '1000000', + currency: defaultCurrency, + recipient: addressTwo, + methodDetails: { + chainId: 4217, + escrowContract: defaultEscrow, + }, + }, + }, + payload: { action: 'rewind' }, + }, + request: makeRequest(), + } as never), + ).rejects.toThrow('unknown action: rewind') + }) +}) + describe('session default currency resolution', () => { const mockAccount = accounts[0] const mockClient = createClient({ transport: http('http://localhost:1') }) @@ -2537,10 +3553,14 @@ function makeRequest() { } } -async function signTestVoucher(channelId: Hex, amount: bigint) { +async function signTestVoucher( + channelId: Hex, + amount: bigint, + account: (typeof accounts)[number] = payer, +) { return signVoucher( client, - payer, + account, { channelId, cumulativeAmount: amount }, escrowContract, chain.id, @@ -2563,3 +3583,53 @@ async function createSignedOpenTransaction( }) return { channelId, serializedTransaction } } + +function toCompactSignature(signature: Hex): Hex { + const compact = signatureToCompactSignature(parseSignature(signature)) + return serializeCompactSignature(compact) +} + +function mutateSignature(signature: Hex): Hex { + const last = signature.at(-1) + const replacement = last === '0' ? '1' : '0' + return `${signature.slice(0, -1)}${replacement}` as Hex +} + +function withFaultHooks(store: Store.Store, options: { failPutAt: number }) { + let putCalls = 0 + return Store.from({ + get: (key) => store.get(key), + delete: (key) => store.delete(key), + put: async (key, value) => { + putCalls++ + if (putCalls === options.failPutAt) + throw new Error(`simulated store crash before persisting key ${key}`) + await store.put(key, value) + }, + }) +} + +function withReadDropHooks(store: Store.Store) { + const pending = new Map() + const wrapped = Store.from({ + async get(key) { + const remaining = pending.get(key) + if (remaining !== undefined) { + if (remaining === 0) { + pending.delete(key) + return null + } + pending.set(key, remaining - 1) + } + return store.get(key) + }, + put: (key, value) => store.put(key, value), + delete: (key) => store.delete(key), + }) + return { + store: wrapped, + dropOnRead(channelId: Hex, readsBeforeDrop = 0) { + pending.set(channelId, readsBeforeDrop) + }, + } +} diff --git a/src/tempo/server/internal/transport.test.ts b/src/tempo/server/internal/transport.test.ts index 1207af33..94f2de28 100644 --- a/src/tempo/server/internal/transport.test.ts +++ b/src/tempo/server/internal/transport.test.ts @@ -82,6 +82,19 @@ function makeReceipt() { } } +async function readResponseText(response: Response): Promise { + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let result = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + result += decoder.decode(value, { stream: true }) + } + return result +} + describe('sse transport', () => { test('getCredential returns null when no Authorization header', () => { const store = memoryStore() @@ -152,6 +165,19 @@ describe('sse transport', () => { challengeId, }) expect(response.headers.get('Content-Type')).toContain('text/event-stream') + + const body = await readResponseText(response) + const receiptRaw = body.split('event: payment-receipt\ndata: ')[1]?.split('\n\n')[0] + const terminalReceipt = JSON.parse(receiptRaw!) + + expect(response.headers.get('Payment-Receipt')).toBeNull() + expect(body).toContain('event: message\ndata: hello\n\n') + expect(body).toContain('event: message\ndata: world\n\n') + expect(body).toContain('event: payment-receipt\n') + expect(terminalReceipt.challengeId).toBe(challengeId) + expect(terminalReceipt.channelId).toBe(channelId) + expect(terminalReceipt.units).toBe(2) + expect(terminalReceipt.spent).toBe('2000000') }) test('respondReceipt with AsyncGeneratorFunction passes stream controller', async () => { @@ -197,6 +223,12 @@ describe('sse transport', () => { challengeId, }) expect(response.headers.get('Content-Type')).toContain('text/event-stream') + + const body = await readResponseText(response) + expect(response.headers.get('Payment-Receipt')).toBeNull() + expect(body).toContain('event: message\ndata: chunk1\n\n') + expect(body).toContain('event: message\ndata: chunk2\n\n') + expect(body).toContain('event: payment-receipt\n') }) test('respondReceipt with plain Response delegates to base http transport', () => { diff --git a/src/tempo/session/Chain.test.ts b/src/tempo/session/Chain.test.ts index 8fb4228c..f26009da 100644 --- a/src/tempo/session/Chain.test.ts +++ b/src/tempo/session/Chain.test.ts @@ -539,6 +539,41 @@ describe.runIf(isLocalnet)('on-chain', () => { ).rejects.toThrow('topUp transaction amount') }) + test('rejects when post-broadcast deposit does not exceed declared previousDeposit', async () => { + const salt = nextSalt() + const deposit = 5_000_000n + const topUpAmount = 1_000_000n + + const { channelId } = await openChannel({ + escrow: escrowContract, + payer, + payee: recipient, + token: currency, + deposit, + salt, + }) + + const { serializedTransaction } = await signTopUpChannel({ + escrow: escrowContract, + payer, + channelId, + token: currency, + amount: topUpAmount, + }) + + await expect( + broadcastTopUpTransaction({ + client, + serializedTransaction, + escrowContract, + channelId, + currency: asset, + declaredDeposit: topUpAmount, + previousDeposit: deposit + topUpAmount, + }), + ).rejects.toThrow('channel deposit did not increase after topUp') + }) + test('successful broadcast returns txHash and newDeposit', async () => { const salt = nextSalt() const deposit = 5_000_000n diff --git a/src/tempo/session/Sse.test.ts b/src/tempo/session/Sse.test.ts index d5a976c3..b7e48c2c 100644 --- a/src/tempo/session/Sse.test.ts +++ b/src/tempo/session/Sse.test.ts @@ -366,6 +366,37 @@ describe('serve', () => { expect(receipt.challengeId).toBe(challengeId) }) + test('emits exactly one terminal payment-receipt event at stream end', async () => { + const storage = memoryStore() + await seedChannel(storage, 2000000n) + + const stream = serve({ + store: storage, + channelId, + challengeId, + tickCost: 1000000n, + generate: generate(['one', 'two']), + }) + + const output = await readStream(stream) + const events = output + .trim() + .split('\n\n') + .filter((chunk) => chunk.length > 0) + .map((chunk) => parseEvent(`${chunk}\n\n`)) + .filter((event): event is NonNullable => event !== null) + + const terminal = events.at(-1) + expect(terminal?.type).toBe('payment-receipt') + if (terminal?.type !== 'payment-receipt') throw new Error('expected terminal payment receipt') + + expect(events.filter((event) => event.type === 'payment-receipt')).toHaveLength(1) + expect(terminal.data.challengeId).toBe(challengeId) + expect(terminal.data.channelId).toBe(channelId) + expect(terminal.data.units).toBe(2) + expect(terminal.data.spent).toBe('2000000') + }) + test('handles empty generator', async () => { const storage = memoryStore() await seedChannel(storage, 1000000n)