From d7a9064b9e384a99f7f7070add230e42f017482b Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 24 Mar 2026 15:45:01 -0700 Subject: [PATCH 1/4] Add multi-top-up and repeated exhaustion session coverage tests --- src/tempo/server/Session.coverage.test.ts | 1003 +++++++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 src/tempo/server/Session.coverage.test.ts diff --git a/src/tempo/server/Session.coverage.test.ts b/src/tempo/server/Session.coverage.test.ts new file mode 100644 index 00000000..c5e57584 --- /dev/null +++ b/src/tempo/server/Session.coverage.test.ts @@ -0,0 +1,1003 @@ +import { Base64 } from 'ox' +import { Challenge, Credential } from 'mppx' +import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' +import { + type Address, + type Hex, + parseSignature, + serializeCompactSignature, + serializeSignature, + signatureToCompactSignature, +} from 'viem' +import { waitForTransactionReceipt } from 'viem/actions' +import { Addresses } from 'viem/tempo' +import { beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { nodeEnv } from '~test/config.js' +import { deployEscrow, signOpenChannel, signTopUpChannel } from '~test/tempo/session.js' +import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js' + +import * as Store from '../../Store.js' +import * as ChannelStore from '../session/ChannelStore.js' +import { signVoucher } from '../session/Voucher.js' +import { sessionManager } from '../client/SessionManager.js' +import { charge, session } from './Session.js' + +const isLocalnet = nodeEnv === 'localnet' +const payer = accounts[2] +const delegatedSigner = accounts[4] +const recipient = accounts[0].address +const currency = asset +const secp256k1N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141') + +let escrowContract: Address +let saltCounter = 0 + +beforeAll(async () => { + escrowContract = await deployEscrow() + await fundAccount({ address: payer.address, token: Addresses.pathUsd }) + await fundAccount({ address: payer.address, token: currency }) +}) + +describe.runIf(isLocalnet)('session coverage gaps', () => { + let rawStore: Store.Store + let store: ChannelStore.ChannelStore + + beforeEach(() => { + rawStore = Store.memory() + store = ChannelStore.fromStore(rawStore) + }) + + function createServer(parameters: Partial = {}) { + return session({ + store: rawStore, + getClient: () => client, + account: recipient, + currency, + escrowContract, + chainId: chain.id, + ...parameters, + } as session.Parameters) + } + + function createServerWithStore(rawStore: Store.Store, parameters: Partial = {}) { + return session({ + store: rawStore, + getClient: () => client, + account: recipient, + currency, + escrowContract, + chainId: chain.id, + ...parameters, + } as session.Parameters) + } + + function createHandler(parameters: Partial = {}) { + return Mppx_server.create({ + methods: [ + tempo_server.session({ + store: rawStore, + getClient: () => client, + account: recipient, + currency, + escrowContract, + chainId: chain.id, + ...parameters, + }), + ], + realm: 'api.example.com', + secretKey: 'secret', + }) + } + + function makeChallenge(parameters: { channelId: Hex; id?: string | undefined }) { + return { + id: parameters.id ?? 'challenge-1', + realm: 'api.example.com', + method: 'tempo' as const, + intent: 'session' as const, + request: { + amount: '1000000', + unitType: 'token', + currency: currency as string, + recipient: recipient as string, + methodDetails: { + escrowContract: escrowContract as string, + chainId: chain.id, + }, + }, + } + } + + function makeRequest(parameters?: { decimals?: number | undefined }) { + return { + amount: '1000000', + unitType: 'token', + currency: currency as string, + decimals: parameters?.decimals ?? 6, + recipient: recipient as string, + escrowContract: escrowContract as string, + chainId: chain.id, + } + } + + function nextSalt(): Hex { + saltCounter++ + return `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex + } + + async function createSignedOpenTransaction( + deposit: bigint, + options?: { payee?: Address | undefined; authorizedSigner?: Address | undefined }, + ) { + const { channelId, serializedTransaction } = await signOpenChannel({ + escrow: escrowContract, + payer, + payee: options?.payee ?? recipient, + token: currency, + deposit, + salt: nextSalt(), + ...(options?.authorizedSigner !== undefined && { authorizedSigner: options.authorizedSigner }), + }) + return { channelId, serializedTransaction } + } + + async function signVoucherFor( + account: (typeof accounts)[number], + channelId: Hex, + cumulativeAmount: bigint, + ) { + return signVoucher( + client, + account, + { channelId, cumulativeAmount }, + escrowContract, + chain.id, + ) + } + + 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 toHighSSignature(signature: Hex): Hex { + const parsed = parseSignature(signature) + const highS = secp256k1N - BigInt(parsed.s) + return serializeSignature({ + r: parsed.r, + s: `0x${highS.toString(16).padStart(64, '0')}`, + yParity: parsed.yParity === 0 ? 1 : 0, + }) + } + + 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) + }, + }) + } + + describe('PR3: signature and protocol behavior', () => { + test('accepts compact (EIP-2098) signatures for open and voucher', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) + const server = createServer() + + const openSignature = toCompactSignature(await signVoucherFor(payer, channelId, 1_000_000n)) + const openReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-compact', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: openSignature, + }, + }, + request: makeRequest(), + }) + expect(openReceipt.status).toBe('success') + + const voucherSignature = toCompactSignature(await signVoucherFor(payer, channelId, 2_000_000n)) + const voucherReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-compact', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: voucherSignature, + }, + }, + request: makeRequest(), + }) + expect(voucherReceipt.status).toBe('success') + expect(voucherReceipt.acceptedCumulative).toBe('2000000') + }) + + test('rejects malformed compact signatures', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) + const server = createServer() + + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + + const compact = toCompactSignature(await signVoucherFor(payer, channelId, 2_000_000n)) + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-invalid-compact', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: mutateSignature(compact), + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('invalid voucher signature') + }) + + test('rejects high-s malleable signatures in session voucher path', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) + const server = createServer() + + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-for-high-s', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + + const lowS = await signVoucherFor(payer, channelId, 2_000_000n) + const highS = toHighSSignature(lowS) + + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-high-s', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: highS, + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('invalid voucher signature') + }) + + test('supports delegated signer end-to-end (open -> voucher -> close)', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n, { + 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 signVoucherFor(delegatedSigner, channelId, 1_000_000n), + }, + }, + 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 signVoucherFor(delegatedSigner, channelId, 2_000_000n), + }, + }, + request: makeRequest(), + }) + 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 signVoucherFor(delegatedSigner, channelId, 2_000_000n), + }, + }, + request: makeRequest(), + }) + expect(closeReceipt.status).toBe('success') + }) + + 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(10_000_000n) + 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 signVoucherFor(payer, channelId, 1_000_000n) + + 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(10_000_000n) + 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) + 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) + expect(challenge.request.amount).toBe('1') + expect(challenge.request.suggestedDeposit).toBe('2') + expect(challenge.request.methodDetails.minVoucherDelta).toBe('1') + }) + + test('documents idempotency semantics for duplicate open/voucher/close requests', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) + const server = createServer() + + const openSignature = await signVoucherFor(payer, channelId, 1_000_000n) + const firstOpen = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-first', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: openSignature, + }, + }, + request: makeRequest(), + }) + expect(firstOpen.status).toBe('success') + + const secondOpen = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-duplicate', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: openSignature, + }, + }, + request: makeRequest(), + }) + expect(secondOpen.status).toBe('success') + + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-duplicate', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '1000000', + signature: openSignature, + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('strictly greater') + + const closeSignature = await signVoucherFor(payer, channelId, 1_000_000n) + const closeReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-first', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: closeSignature, + }, + }, + request: makeRequest(), + }) + expect(closeReceipt.status).toBe('success') + + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-duplicate', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: closeSignature, + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow('already finalized') + }) + }) + + describe('PR4: session-level concurrency', () => { + test('concurrent voucher submissions linearize to monotonic final state', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) + const server = createServer() + + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + + const amounts = [2_000_000n, 3_000_000n, 4_000_000n, 5_000_000n] + 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 signVoucherFor(payer, 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(5_000_000n) + expect(channel?.spent).toBe(0n) + }) + }) + + describe('PR6: durability and recovery fault hooks', () => { + 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(10_000_000n) + const openPayload = { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signVoucherFor(payer, channelId, 1_000_000n), + } + + 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({ id: 'open-crash-retry', channelId }), + payload: openPayload, + }, + request: makeRequest(), + }) + + expect(recovered.status).toBe('success') + const channel = await afterCrashStore.getChannel(channelId) + expect(channel?.highestVoucherAmount).toBe(1_000_000n) + expect(channel?.deposit).toBe(10_000_000n) + }) + + 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(5_000_000n) + + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + + const additionalDeposit = 2_000_000n + 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(5_000_000n) + + 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 signVoucherFor(payer, channelId, 2_000_000n), + }, + }, + request: makeRequest(), + }) + + const recoveredChannel = await staleStore.getChannel(channelId) + expect(recoveredChannel?.deposit).toBe(7_000_000n) + }) + }) + + describe('PR7: multi top-up continuity', () => { + test('open -> topUp -> topUp -> voucher/charge -> close', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(4_000_000n) + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + expect(openReceipt.status).toBe('success') + + await charge(store, channelId, 1_000_000n) + await expect(charge(store, channelId, 1_000_000n)).rejects.toThrow('requested') + + const topUp1Amount = 2_000_000n + 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(6_000_000n) + + const voucher1 = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-after-topup-1', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '3000000', + signature: await signVoucherFor(payer, channelId, 3_000_000n), + }, + }, + request: makeRequest(), + }) + expect(voucher1.acceptedCumulative).toBe('3000000') + + await charge(store, channelId, 2_000_000n) + await expect(charge(store, channelId, 1_000_000n)).rejects.toThrow('requested') + + const topUp2Amount = 2_000_000n + 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(8_000_000n) + + const voucher2 = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-after-topup-2', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signVoucherFor(payer, channelId, 5_000_000n), + }, + }, + request: makeRequest(), + }) + expect(voucher2.acceptedCumulative).toBe('5000000') + + await charge(store, channelId, 2_000_000n) + + const closeReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-multi-topup', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signVoucherFor(payer, channelId, 5_000_000n), + }, + }, + request: makeRequest(), + }) + expect(closeReceipt.status).toBe('success') + + const finalized = await store.getChannel(channelId) + expect(finalized?.spent).toBe(5_000_000n) + expect(finalized?.finalized).toBe(true) + }) + }) + + describe('PR7: e2e streaming loop', () => { + 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: recipient, + 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: recipient, + 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') + }) + }) +}) From 737e442c575f2f3297ebac92137119a38dfc4c83 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 24 Mar 2026 18:00:55 -0700 Subject: [PATCH 2/4] test(session): add guard, sse, fee-payer, and race coverage --- src/tempo/server/Session.coverage.test.ts | 186 +++++++++++++++++++- src/tempo/server/Session.test.ts | 160 +++++++++++++++++ src/tempo/server/internal/transport.test.ts | 32 ++++ src/tempo/session/Chain.test.ts | 60 ++++++- src/tempo/session/Sse.test.ts | 31 ++++ 5 files changed, 466 insertions(+), 3 deletions(-) diff --git a/src/tempo/server/Session.coverage.test.ts b/src/tempo/server/Session.coverage.test.ts index c5e57584..24f30815 100644 --- a/src/tempo/server/Session.coverage.test.ts +++ b/src/tempo/server/Session.coverage.test.ts @@ -13,14 +13,14 @@ import { waitForTransactionReceipt } from 'viem/actions' import { Addresses } from 'viem/tempo' import { beforeAll, beforeEach, describe, expect, test } from 'vitest' import { nodeEnv } from '~test/config.js' -import { deployEscrow, signOpenChannel, signTopUpChannel } from '~test/tempo/session.js' +import { closeChannelOnChain, deployEscrow, signOpenChannel, signTopUpChannel } from '~test/tempo/session.js' import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js' import * as Store from '../../Store.js' import * as ChannelStore from '../session/ChannelStore.js' import { signVoucher } from '../session/Voucher.js' import { sessionManager } from '../client/SessionManager.js' -import { charge, session } from './Session.js' +import { charge, session, settle } from './Session.js' const isLocalnet = nodeEnv === 'localnet' const payer = accounts[2] @@ -190,6 +190,31 @@ describe.runIf(isLocalnet)('session coverage gaps', () => { }) } + 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) + }, + } + } + describe('PR3: signature and protocol behavior', () => { test('accepts compact (EIP-2098) signatures for open and voucher', async () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) @@ -718,6 +743,163 @@ describe.runIf(isLocalnet)('session coverage gaps', () => { const recoveredChannel = await staleStore.getChannel(channelId) expect(recoveredChannel?.deposit).toBe(7_000_000n) }) + + 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(5_000_000n) + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + 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 signVoucherFor(payer, channelId, 2_000_000n), + }, + }, + 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(5_000_000n) + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + + 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(5_000_000n) + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + 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(5_000_000n) + 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 signVoucherFor(payer, channelId, 1_000_000n), + }, + }, + request: makeRequest(), + }) + + const closeSignature = await signVoucherFor(payer, channelId, 1_000_000n) + await closeChannelOnChain({ + escrow: escrowContract, + payee: accounts[0], + channelId, + cumulativeAmount: 1_000_000n, + 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('PR7: multi top-up continuity', () => { diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 72687ea9..9ca9f409 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -2253,6 +2253,166 @@ describe('monotonicity and TOCTOU (unit tests)', () => { }) }) +describe('session request and verify guardrails', () => { + const addressOne = '0x0000000000000000000000000000000000000001' as Address + 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: addressOne, + 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: addressOne, + 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: addressOne, + 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: addressOne, + 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: addressOne, + 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: addressOne, + 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 mockClient = createClient({ transport: http('http://localhost:1') }) const mockMainnetClient = createClient({ diff --git a/src/tempo/server/internal/transport.test.ts b/src/tempo/server/internal/transport.test.ts index b94e015c..ddf1a449 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 6ce56828..e8d11daf 100644 --- a/src/tempo/session/Chain.test.ts +++ b/src/tempo/session/Chain.test.ts @@ -1,4 +1,4 @@ -import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem' +import { type Account, type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem' import { waitForTransactionReceipt } from 'viem/actions' import { Addresses, Transaction } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vitest' @@ -71,6 +71,29 @@ describe('assertUint128 (via settleOnChain / closeOnChain)', () => { }), ).rejects.toThrow('no account available') }) + + test('closeOnChain fee-payer flow still requires client.account even with explicit account override', async () => { + const explicitAccount = { + address: '0x0000000000000000000000000000000000000010', + } as Account + const feePayer = { + address: '0x0000000000000000000000000000000000000020', + } as Account + + await expect( + closeOnChain( + mockClient, + dummyEscrow, + { + channelId: dummyChannelId, + cumulativeAmount: 1_000_000n, + signature: '0xsig' as Hex, + }, + explicitAccount, + feePayer, + ), + ).rejects.toThrow('no sender account available on client') + }) }) describe.runIf(isLocalnet)('on-chain', () => { @@ -539,6 +562,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 e7086b52..0d38fcc8 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) From 6103bfb9bec575571f4f1b27d95b302d75e652b3 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Sun, 29 Mar 2026 08:50:45 -0700 Subject: [PATCH 3/4] test(session): fold coverage cases into session tests --- src/tempo/server/Session.coverage.test.ts | 1185 --------------------- src/tempo/server/Session.test.ts | 1088 ++++++++++++++++++- 2 files changed, 1043 insertions(+), 1230 deletions(-) delete mode 100644 src/tempo/server/Session.coverage.test.ts diff --git a/src/tempo/server/Session.coverage.test.ts b/src/tempo/server/Session.coverage.test.ts deleted file mode 100644 index 24f30815..00000000 --- a/src/tempo/server/Session.coverage.test.ts +++ /dev/null @@ -1,1185 +0,0 @@ -import { Base64 } from 'ox' -import { Challenge, Credential } from 'mppx' -import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' -import { - type Address, - type Hex, - parseSignature, - serializeCompactSignature, - serializeSignature, - signatureToCompactSignature, -} from 'viem' -import { waitForTransactionReceipt } from 'viem/actions' -import { Addresses } from 'viem/tempo' -import { beforeAll, beforeEach, describe, expect, test } from 'vitest' -import { nodeEnv } from '~test/config.js' -import { closeChannelOnChain, deployEscrow, signOpenChannel, signTopUpChannel } from '~test/tempo/session.js' -import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js' - -import * as Store from '../../Store.js' -import * as ChannelStore from '../session/ChannelStore.js' -import { signVoucher } from '../session/Voucher.js' -import { sessionManager } from '../client/SessionManager.js' -import { charge, session, settle } from './Session.js' - -const isLocalnet = nodeEnv === 'localnet' -const payer = accounts[2] -const delegatedSigner = accounts[4] -const recipient = accounts[0].address -const currency = asset -const secp256k1N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141') - -let escrowContract: Address -let saltCounter = 0 - -beforeAll(async () => { - escrowContract = await deployEscrow() - await fundAccount({ address: payer.address, token: Addresses.pathUsd }) - await fundAccount({ address: payer.address, token: currency }) -}) - -describe.runIf(isLocalnet)('session coverage gaps', () => { - let rawStore: Store.Store - let store: ChannelStore.ChannelStore - - beforeEach(() => { - rawStore = Store.memory() - store = ChannelStore.fromStore(rawStore) - }) - - function createServer(parameters: Partial = {}) { - return session({ - store: rawStore, - getClient: () => client, - account: recipient, - currency, - escrowContract, - chainId: chain.id, - ...parameters, - } as session.Parameters) - } - - function createServerWithStore(rawStore: Store.Store, parameters: Partial = {}) { - return session({ - store: rawStore, - getClient: () => client, - account: recipient, - currency, - escrowContract, - chainId: chain.id, - ...parameters, - } as session.Parameters) - } - - function createHandler(parameters: Partial = {}) { - return Mppx_server.create({ - methods: [ - tempo_server.session({ - store: rawStore, - getClient: () => client, - account: recipient, - currency, - escrowContract, - chainId: chain.id, - ...parameters, - }), - ], - realm: 'api.example.com', - secretKey: 'secret', - }) - } - - function makeChallenge(parameters: { channelId: Hex; id?: string | undefined }) { - return { - id: parameters.id ?? 'challenge-1', - realm: 'api.example.com', - method: 'tempo' as const, - intent: 'session' as const, - request: { - amount: '1000000', - unitType: 'token', - currency: currency as string, - recipient: recipient as string, - methodDetails: { - escrowContract: escrowContract as string, - chainId: chain.id, - }, - }, - } - } - - function makeRequest(parameters?: { decimals?: number | undefined }) { - return { - amount: '1000000', - unitType: 'token', - currency: currency as string, - decimals: parameters?.decimals ?? 6, - recipient: recipient as string, - escrowContract: escrowContract as string, - chainId: chain.id, - } - } - - function nextSalt(): Hex { - saltCounter++ - return `0x${saltCounter.toString(16).padStart(64, '0')}` as Hex - } - - async function createSignedOpenTransaction( - deposit: bigint, - options?: { payee?: Address | undefined; authorizedSigner?: Address | undefined }, - ) { - const { channelId, serializedTransaction } = await signOpenChannel({ - escrow: escrowContract, - payer, - payee: options?.payee ?? recipient, - token: currency, - deposit, - salt: nextSalt(), - ...(options?.authorizedSigner !== undefined && { authorizedSigner: options.authorizedSigner }), - }) - return { channelId, serializedTransaction } - } - - async function signVoucherFor( - account: (typeof accounts)[number], - channelId: Hex, - cumulativeAmount: bigint, - ) { - return signVoucher( - client, - account, - { channelId, cumulativeAmount }, - escrowContract, - chain.id, - ) - } - - 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 toHighSSignature(signature: Hex): Hex { - const parsed = parseSignature(signature) - const highS = secp256k1N - BigInt(parsed.s) - return serializeSignature({ - r: parsed.r, - s: `0x${highS.toString(16).padStart(64, '0')}`, - yParity: parsed.yParity === 0 ? 1 : 0, - }) - } - - 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) - }, - } - } - - describe('PR3: signature and protocol behavior', () => { - test('accepts compact (EIP-2098) signatures for open and voucher', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) - const server = createServer() - - const openSignature = toCompactSignature(await signVoucherFor(payer, channelId, 1_000_000n)) - const openReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-compact', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: openSignature, - }, - }, - request: makeRequest(), - }) - expect(openReceipt.status).toBe('success') - - const voucherSignature = toCompactSignature(await signVoucherFor(payer, channelId, 2_000_000n)) - const voucherReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-compact', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: voucherSignature, - }, - }, - request: makeRequest(), - }) - expect(voucherReceipt.status).toBe('success') - expect(voucherReceipt.acceptedCumulative).toBe('2000000') - }) - - test('rejects malformed compact signatures', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) - const server = createServer() - - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - - const compact = toCompactSignature(await signVoucherFor(payer, channelId, 2_000_000n)) - await expect( - server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-invalid-compact', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: mutateSignature(compact), - }, - }, - request: makeRequest(), - }), - ).rejects.toThrow('invalid voucher signature') - }) - - test('rejects high-s malleable signatures in session voucher path', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) - const server = createServer() - - await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-for-high-s', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - - const lowS = await signVoucherFor(payer, channelId, 2_000_000n) - const highS = toHighSSignature(lowS) - - await expect( - server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-high-s', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: highS, - }, - }, - request: makeRequest(), - }), - ).rejects.toThrow('invalid voucher signature') - }) - - test('supports delegated signer end-to-end (open -> voucher -> close)', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n, { - 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 signVoucherFor(delegatedSigner, channelId, 1_000_000n), - }, - }, - 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 signVoucherFor(delegatedSigner, channelId, 2_000_000n), - }, - }, - request: makeRequest(), - }) - 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 signVoucherFor(delegatedSigner, channelId, 2_000_000n), - }, - }, - request: makeRequest(), - }) - expect(closeReceipt.status).toBe('success') - }) - - 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(10_000_000n) - 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 signVoucherFor(payer, channelId, 1_000_000n) - - 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(10_000_000n) - 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) - 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) - expect(challenge.request.amount).toBe('1') - expect(challenge.request.suggestedDeposit).toBe('2') - expect(challenge.request.methodDetails.minVoucherDelta).toBe('1') - }) - - test('documents idempotency semantics for duplicate open/voucher/close requests', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) - const server = createServer() - - const openSignature = await signVoucherFor(payer, channelId, 1_000_000n) - const firstOpen = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-first', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: openSignature, - }, - }, - request: makeRequest(), - }) - expect(firstOpen.status).toBe('success') - - const secondOpen = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-duplicate', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: openSignature, - }, - }, - request: makeRequest(), - }) - expect(secondOpen.status).toBe('success') - - await expect( - server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-duplicate', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '1000000', - signature: openSignature, - }, - }, - request: makeRequest(), - }), - ).rejects.toThrow('strictly greater') - - const closeSignature = await signVoucherFor(payer, channelId, 1_000_000n) - const closeReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'close-first', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '1000000', - signature: closeSignature, - }, - }, - request: makeRequest(), - }) - expect(closeReceipt.status).toBe('success') - - await expect( - server.verify({ - credential: { - challenge: makeChallenge({ id: 'close-duplicate', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '1000000', - signature: closeSignature, - }, - }, - request: makeRequest(), - }), - ).rejects.toThrow('already finalized') - }) - }) - - describe('PR4: session-level concurrency', () => { - test('concurrent voucher submissions linearize to monotonic final state', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10_000_000n) - const server = createServer() - - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - - const amounts = [2_000_000n, 3_000_000n, 4_000_000n, 5_000_000n] - 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 signVoucherFor(payer, 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(5_000_000n) - expect(channel?.spent).toBe(0n) - }) - }) - - describe('PR6: durability and recovery fault hooks', () => { - 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(10_000_000n) - const openPayload = { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signVoucherFor(payer, channelId, 1_000_000n), - } - - 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({ id: 'open-crash-retry', channelId }), - payload: openPayload, - }, - request: makeRequest(), - }) - - expect(recovered.status).toBe('success') - const channel = await afterCrashStore.getChannel(channelId) - expect(channel?.highestVoucherAmount).toBe(1_000_000n) - expect(channel?.deposit).toBe(10_000_000n) - }) - - 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(5_000_000n) - - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - - const additionalDeposit = 2_000_000n - 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(5_000_000n) - - 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 signVoucherFor(payer, channelId, 2_000_000n), - }, - }, - request: makeRequest(), - }) - - const recoveredChannel = await staleStore.getChannel(channelId) - expect(recoveredChannel?.deposit).toBe(7_000_000n) - }) - - 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(5_000_000n) - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - 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 signVoucherFor(payer, channelId, 2_000_000n), - }, - }, - 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(5_000_000n) - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - - 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(5_000_000n) - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - 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(5_000_000n) - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - - const closeSignature = await signVoucherFor(payer, channelId, 1_000_000n) - await closeChannelOnChain({ - escrow: escrowContract, - payee: accounts[0], - channelId, - cumulativeAmount: 1_000_000n, - 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('PR7: multi top-up continuity', () => { - test('open -> topUp -> topUp -> voucher/charge -> close', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(4_000_000n) - 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 signVoucherFor(payer, channelId, 1_000_000n), - }, - }, - request: makeRequest(), - }) - expect(openReceipt.status).toBe('success') - - await charge(store, channelId, 1_000_000n) - await expect(charge(store, channelId, 1_000_000n)).rejects.toThrow('requested') - - const topUp1Amount = 2_000_000n - 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(6_000_000n) - - const voucher1 = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-after-topup-1', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '3000000', - signature: await signVoucherFor(payer, channelId, 3_000_000n), - }, - }, - request: makeRequest(), - }) - expect(voucher1.acceptedCumulative).toBe('3000000') - - await charge(store, channelId, 2_000_000n) - await expect(charge(store, channelId, 1_000_000n)).rejects.toThrow('requested') - - const topUp2Amount = 2_000_000n - 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(8_000_000n) - - const voucher2 = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-after-topup-2', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '5000000', - signature: await signVoucherFor(payer, channelId, 5_000_000n), - }, - }, - request: makeRequest(), - }) - expect(voucher2.acceptedCumulative).toBe('5000000') - - await charge(store, channelId, 2_000_000n) - - const closeReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'close-multi-topup', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '5000000', - signature: await signVoucherFor(payer, channelId, 5_000_000n), - }, - }, - request: makeRequest(), - }) - expect(closeReceipt.status).toBe('success') - - const finalized = await store.getChannel(channelId) - expect(finalized?.spent).toBe(5_000_000n) - expect(finalized?.finalized).toBe(true) - }) - }) - - describe('PR7: e2e streaming loop', () => { - 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: recipient, - 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: recipient, - 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') - }) - }) -}) diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 9ca9f409..9bfc9ba8 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -1,14 +1,24 @@ 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, + serializeSignature, + signatureToCompactSignature, +} from 'viem' import { waitForTransactionReceipt } from 'viem/actions' import { Addresses } from 'viem/tempo' -import { beforeAll, beforeEach, describe, expect, test } from 'vitest' +import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vitest' import { nodeEnv } from '~test/config.js' const isLocalnet = nodeEnv === 'localnet' import { + closeChannelOnChain, deployEscrow, requestCloseChannel, signOpenChannel, @@ -24,6 +34,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,8 +46,10 @@ import { signVoucher } from '../session/Voucher.js' import { charge, session, settle } from './Session.js' const payer = accounts[2] +const delegatedSigner = accounts[4] const recipient = accounts[0].address const currency = asset +const secp256k1N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141') let escrowContract: Address let saltCounter = 0 @@ -69,6 +82,39 @@ describe.runIf(isLocalnet)('session', () => { } as session.Parameters) } + function createServerWithStore( + customStore: Store.Store, + overrides: Partial = {}, + ) { + return session({ + store: customStore, + getClient: () => client, + account: recipient, + 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: recipient, + 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) @@ -1331,6 +1377,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', () => { @@ -1813,71 +2038,628 @@ describe.runIf(isLocalnet)('session', () => { }) }) - describe('structured errors', () => { - test('ChannelNotFoundError on unknown channel', async () => { - const { channelId } = await createSignedOpenTransaction(10000000n) + describe('signature compatibility', () => { + test('accepts compact (EIP-2098) signatures for open and voucher', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() - try { - await server.verify({ + const openSignature = toCompactSignature(await signTestVoucher(channelId, 1000000n)) + const openReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-compact', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: openSignature, + }, + }, + request: makeRequest(), + }) + expect(openReceipt.status).toBe('success') + + const voucherSignature = toCompactSignature(await signTestVoucher(channelId, 2000000n)) + const voucherReceipt = (await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-compact', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: voucherSignature, + }, + }, + request: makeRequest(), + })) as SessionReceipt + expect(voucherReceipt.status).toBe('success') + expect(voucherReceipt.acceptedCumulative).toBe('2000000') + }) + + test('rejects malformed compact signatures', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer() + + 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 () => { + test('rejects high-s malleable signatures in session voucher path', async () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() - try { - await server.verify({ + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-for-high-s', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + const lowS = await signTestVoucher(channelId, 2000000n) + const highS = toHighSSignature(lowS) + + await expect( + server.verify({ credential: { - challenge: makeChallenge({ channelId }), + challenge: makeChallenge({ id: 'voucher-high-s', channelId }), payload: { - action: 'open' as const, - type: 'transaction' as const, + action: 'voucher' as const, channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: `0x${'ab'.repeat(65)}` as Hex, + cumulativeAmount: '2000000', + signature: highS, }, }, request: makeRequest(), - }) - expect.unreachable() - } catch (e) { - expect(e).toBeInstanceOf(InvalidSignatureError) - expect((e as InvalidSignatureError).status).toBe(402) - } + }), + ).rejects.toThrow('invalid voucher signature') }) }) - describe('respond', () => { - test('returns 204 for POST with open action', () => { + describe('session-level concurrency', () => { + test('concurrent voucher submissions linearize to monotonic final state', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() - const result = server.respond!({ + + await server.verify({ credential: { - challenge: makeChallenge({ - channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - }), - payload: { action: 'open' }, - }, - input: new Request('http://localhost', { method: 'POST' }), - } as any) - expect(result).toBeInstanceOf(Response) + 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(), + }) + + 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('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({ id: 'open-crash-retry', channelId }), + payload: openPayload, + }, + request: makeRequest(), + }) + + expect(recovered.status).toBe('success') + const channel = await afterCrashStore.getChannel(channelId) + expect(channel?.highestVoucherAmount).toBe(1000000n) + expect(channel?.deposit).toBe(10000000n) + }) + + 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) }) @@ -2143,6 +2925,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: recipient, + 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: recipient, + 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 })], @@ -2636,10 +3570,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, @@ -2662,3 +3600,63 @@ 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 toHighSSignature(signature: Hex): Hex { + const parsed = parseSignature(signature) + const highS = secp256k1N - BigInt(parsed.s) + return serializeSignature({ + r: parsed.r, + s: `0x${highS.toString(16).padStart(64, '0')}`, + yParity: parsed.yParity === 0 ? 1 : 0, + }) +} + +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) + }, + } +} From 9e7a59834e9e3c46aa54c3b7f76427325ff03345 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Sun, 29 Mar 2026 09:28:45 -0700 Subject: [PATCH 4/4] test: align session coverage with current session APIs --- src/tempo/server/Session.test.ts | 106 +++---------------------------- src/tempo/session/Chain.test.ts | 25 +------- 2 files changed, 10 insertions(+), 121 deletions(-) diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 6365bad9..83642a80 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -8,7 +8,6 @@ import { type Hex, parseSignature, serializeCompactSignature, - serializeSignature, signatureToCompactSignature, } from 'viem' import { waitForTransactionReceipt } from 'viem/actions' @@ -50,7 +49,6 @@ const delegatedSigner = accounts[4] const recipientAccount = accounts[0] const recipient = accounts[0].address const currency = asset -const secp256k1N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141') let escrowContract: Address let saltCounter = 0 @@ -2099,44 +2097,6 @@ describe.runIf(isLocalnet)('session', () => { }) describe('signature compatibility', () => { - test('accepts compact (EIP-2098) signatures for open and voucher', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) - const server = createServer() - - const openSignature = toCompactSignature(await signTestVoucher(channelId, 1000000n)) - const openReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-compact', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: openSignature, - }, - }, - request: makeRequest(), - }) - expect(openReceipt.status).toBe('success') - - const voucherSignature = toCompactSignature(await signTestVoucher(channelId, 2000000n)) - const voucherReceipt = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-compact', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: voucherSignature, - }, - }, - request: makeRequest(), - })) as SessionReceipt - expect(voucherReceipt.status).toBe('success') - expect(voucherReceipt.acceptedCumulative).toBe('2000000') - }) - test('rejects malformed compact signatures', async () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() @@ -2172,44 +2132,6 @@ describe.runIf(isLocalnet)('session', () => { }), ).rejects.toThrow('invalid voucher signature') }) - - test('rejects high-s malleable signatures in session voucher path', async () => { - const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) - const server = createServer() - - await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-for-high-s', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), - }, - }, - request: makeRequest(), - }) - - const lowS = await signTestVoucher(channelId, 2000000n) - const highS = toHighSSignature(lowS) - - await expect( - server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-high-s', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: highS, - }, - }, - request: makeRequest(), - }), - ).rejects.toThrow('invalid voucher signature') - }) }) describe('session-level concurrency', () => { @@ -2992,7 +2914,7 @@ describe.runIf(isLocalnet)('session', () => { tempo_server.session({ store: backingStore, getClient: () => client, - account: recipient, + account: recipientAccount, currency, escrowContract, chainId: chain.id, @@ -3070,7 +2992,7 @@ describe.runIf(isLocalnet)('session', () => { tempo_server.session({ store: backingStore, getClient: () => client, - account: recipient, + account: recipientAccount, currency, escrowContract, chainId: chain.id, @@ -3248,7 +3170,7 @@ describe('monotonicity and TOCTOU (unit tests)', () => { }) describe('session request and verify guardrails', () => { - const addressOne = '0x0000000000000000000000000000000000000001' as Address + const accountOne = accounts[0] const addressTwo = '0x0000000000000000000000000000000000000002' as Address const defaultCurrency = '0x20c0000000000000000000000000000000000000' const defaultEscrow = '0x0000000000000000000000000000000000000003' @@ -3280,7 +3202,7 @@ describe('session request and verify guardrails', () => { test('request throws when no client exists for requested chain', async () => { const server = session({ store: Store.memory(), - account: addressOne, + account: accountOne, currency: defaultCurrency, getClient: async () => { throw new Error('unreachable chain') @@ -3299,7 +3221,7 @@ describe('session request and verify guardrails', () => { const wrongChainClient = createMockClient(42431) const server = session({ store: Store.memory(), - account: addressOne, + account: accountOne, currency: defaultCurrency, getClient: async () => wrongChainClient, } as session.Parameters) @@ -3316,7 +3238,7 @@ describe('session request and verify guardrails', () => { const client = createMockClient(4217) const server = session({ store: Store.memory(), - account: addressOne, + account: accountOne, currency: defaultCurrency, feePayer: 'https://fee-payer.example.com', getClient: async () => client, @@ -3339,7 +3261,7 @@ describe('session request and verify guardrails', () => { const client = createMockClient(4217) const server = session({ store: Store.memory(), - account: addressOne, + account: accountOne, currency: defaultCurrency, feePayer: 'https://fee-payer.example.com', getClient: async () => client, @@ -3357,7 +3279,7 @@ describe('session request and verify guardrails', () => { const client = createMockClient(unknownChainId) const server = session({ store: Store.memory(), - account: addressOne, + account: accountOne, currency: defaultCurrency, getClient: async () => client, } as session.Parameters) @@ -3374,7 +3296,7 @@ describe('session request and verify guardrails', () => { const client = createMockClient(4217) const server = session({ store: Store.memory(), - account: addressOne, + account: accountOne, currency: defaultCurrency, getClient: async () => client, escrowContract: defaultEscrow, @@ -3673,16 +3595,6 @@ function mutateSignature(signature: Hex): Hex { return `${signature.slice(0, -1)}${replacement}` as Hex } -function toHighSSignature(signature: Hex): Hex { - const parsed = parseSignature(signature) - const highS = secp256k1N - BigInt(parsed.s) - return serializeSignature({ - r: parsed.r, - s: `0x${highS.toString(16).padStart(64, '0')}`, - yParity: parsed.yParity === 0 ? 1 : 0, - }) -} - function withFaultHooks(store: Store.Store, options: { failPutAt: number }) { let putCalls = 0 return Store.from({ diff --git a/src/tempo/session/Chain.test.ts b/src/tempo/session/Chain.test.ts index 1040e63b..f26009da 100644 --- a/src/tempo/session/Chain.test.ts +++ b/src/tempo/session/Chain.test.ts @@ -1,4 +1,4 @@ -import { type Account, type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem' +import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem' import { waitForTransactionReceipt } from 'viem/actions' import { Addresses, Transaction } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' @@ -71,29 +71,6 @@ describe('assertUint128 (via settleOnChain / closeOnChain)', () => { }), ).rejects.toThrow('no account available') }) - - test('closeOnChain fee-payer flow still requires client.account even with explicit account override', async () => { - const explicitAccount = { - address: '0x0000000000000000000000000000000000000010', - } as Account - const feePayer = { - address: '0x0000000000000000000000000000000000000020', - } as Account - - await expect( - closeOnChain( - mockClient, - dummyEscrow, - { - channelId: dummyChannelId, - cumulativeAmount: 1_000_000n, - signature: '0xsig' as Hex, - }, - explicitAccount, - feePayer, - ), - ).rejects.toThrow('no sender account available on client') - }) }) describe.runIf(isLocalnet)('on-chain', () => {