From c8f7f160fae1622673aa062c982e6a1d5252c624 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 14 Apr 2026 16:35:06 -0700 Subject: [PATCH 1/2] fix: harden session settle and close access-key flows --- .changeset/settle-access-key-payee-check.md | 5 + src/tempo/client/SessionManager.ts | 18 ++ src/tempo/server/Session.test.ts | 267 +++++++++++++++++++- src/tempo/server/Session.ts | 30 +++ src/tempo/session/Chain.ts | 59 +++-- 5 files changed, 360 insertions(+), 19 deletions(-) create mode 100644 .changeset/settle-access-key-payee-check.md diff --git a/.changeset/settle-access-key-payee-check.md b/.changeset/settle-access-key-payee-check.md new file mode 100644 index 00000000..d1ec7d15 --- /dev/null +++ b/.changeset/settle-access-key-payee-check.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Validate session settle/close senders against the channel payee so raw delegated access-key accounts fail fast with a clear error, and use the raw Tempo transaction path for access-key-compatible settlement and close flows. diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 507b1275..b5f669bf 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -759,6 +759,24 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa method: 'POST', headers: { Authorization: credential }, }) + if (!response.ok) { + const body = await response.text().catch(() => '') + const detail = (() => { + if (!body) return '' + if (!response.headers.get('Content-Type')?.includes('application/problem+json')) { + return body + } + try { + const problem = JSON.parse(body) as { detail?: string } + return problem.detail ?? body + } catch { + return body + } + })() + throw new Error( + `Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}`, + ) + } const receiptHeader = response.headers.get('Payment-Receipt') if (receiptHeader) receipt = deserializeSessionReceipt(receiptHeader) } diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 171514ac..4f23a1e0 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -3,7 +3,7 @@ import * as node_http from 'node:http' import type { z } from 'mppx' import { Challenge, Credential } from 'mppx' import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' -import { Base64 } from 'ox' +import { Base64, Secp256k1 } from 'ox' import { type Address, createClient, @@ -13,7 +13,7 @@ import { signatureToCompactSignature, } from 'viem' import { waitForTransactionReceipt } from 'viem/actions' -import { Addresses } from 'viem/tempo' +import { Account as TempoAccount, Actions, Addresses } from 'viem/tempo' import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vp/test' import { WebSocketServer } from 'ws' import { nodeEnv } from '~test/config.js' @@ -45,7 +45,8 @@ import { } from '../internal/defaults.js' import type * as Methods from '../Methods.js' import * as ChannelStore from '../session/ChannelStore.js' -import type { SessionReceipt } from '../session/Types.js' +import { serializeSessionReceipt } from '../session/Receipt.js' +import type { SessionCredentialPayload, SessionReceipt } from '../session/Types.js' import { signVoucher } from '../session/Voucher.js' import * as TempoWs from '../session/Ws.js' import { charge, session, settle } from './Session.js' @@ -1727,6 +1728,77 @@ describe.runIf(isLocalnet)('session', () => { expect(ch!.settledOnChain).toBe(5000000n) }) + test('accepts a Tempo access-key account for settlement', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer() + + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'settle-access-key-open', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, + }, + request: makeRequest(), + }) + + const privateKey = Secp256k1.randomPrivateKey() + const accessKey = TempoAccount.fromSecp256k1(privateKey, { + access: recipientAccount, + }) + + await Actions.accessKey.authorizeSync(client, { + account: recipientAccount, + accessKey, + feeToken: currency, + }) + + const settleTxHash = await settle(store, client, channelId, { + escrowContract, + account: accessKey, + }) + expect(settleTxHash).toMatch(/^0x/) + + const ch = await store.getChannel(channelId) + expect(ch!.settledOnChain).toBe(5000000n) + }) + + test('rejects a raw delegated key account with a helpful error', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer() + + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'settle-raw-access-key-open', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, + }, + request: makeRequest(), + }) + + const rawAccessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey()) + + await expect( + settle(store, client, channelId, { + escrowContract, + account: rawAccessKey, + }), + ).rejects.toThrow( + `Cannot settle channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`, + ) + }) + test('settle rejects when no channel found', async () => { const fakeChannelId = '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex @@ -1736,6 +1808,195 @@ describe.runIf(isLocalnet)('session', () => { }) }) + describe('close account shapes', () => { + test('root payee account closes successfully', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer() + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-root-payee-open', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + const closeReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-root-payee', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + expect(closeReceipt.status).toBe('success') + expect((await store.getChannel(channelId))?.finalized).toBe(true) + }) + + test('payee access-key account closes successfully', async () => { + const accessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey(), { + access: recipientAccount, + }) + + await Actions.accessKey.authorizeSync(client, { + account: recipientAccount, + accessKey, + feeToken: currency, + }) + + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer({ account: accessKey }) + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-access-key-open', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + const closeReceipt = await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-access-key', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + expect(closeReceipt.status).toBe('success') + expect((await store.getChannel(channelId))?.finalized).toBe(true) + }) + + test('raw delegated server key fails clearly during close', async () => { + const rawAccessKey = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey()) + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + const server = createServer({ account: rawAccessKey, recipient }) + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-raw-access-key-open', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }) + + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-raw-access-key', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow( + `Cannot close channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`, + ) + }) + + test('sessionManager.close surfaces problem details from HTTP close failures', async () => { + const challenge = makeChallenge({ + id: 'close-http-failure', + channelId: + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + }) + let requests = 0 + + const fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + requests++ + + const authorization = new Headers(init?.headers).get('Authorization') + if (!authorization) { + return new Response(null, { + status: 402, + headers: { 'WWW-Authenticate': Challenge.serialize(challenge) }, + }) + } + + const credential = Credential.deserialize(authorization) + if (credential.payload.action === 'open') { + return new Response('ok', { + status: 200, + headers: { + 'Payment-Receipt': serializeSessionReceipt({ + method: 'tempo', + intent: 'session', + status: 'success', + timestamp: new Date().toISOString(), + reference: credential.payload.channelId, + challengeId: credential.challenge.id, + channelId: credential.payload.channelId, + acceptedCumulative: credential.payload.cumulativeAmount, + spent: credential.payload.cumulativeAmount, + units: 1, + }), + }, + }) + } + + if (credential.payload.action === 'close') { + return new Response( + JSON.stringify({ detail: 'raw delegated key is not the payee wallet' }), + { + status: 400, + headers: { 'Content-Type': 'application/problem+json' }, + }, + ) + } + + throw new Error(`unexpected payment action ${(credential.payload as { action: string }).action}`) + } + + const manager = sessionManager({ + account: payer, + client, + escrowContract, + fetch, + maxDeposit: '1', + }) + + const response = await manager.fetch('https://api.example.com/resource') + expect(response.status).toBe(200) + + await expect(manager.close()).rejects.toThrow( + 'Close request failed with status 400: raw delegated key is not the payee wallet', + ) + expect(requests).toBe(3) + }) + }) + describe('non-persistent storage recovery', () => { test('open on existing on-chain channel initializes settledOnChain from chain', async () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index 94aa8131..04f44779 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -341,6 +341,22 @@ export declare namespace session { } } +function assertSettlementSender(parameters: { + operation: 'close' | 'settle' + channelId: Hex + payee: Address + sender: Address | undefined +}) { + const { operation, channelId, payee, sender } = parameters + if (!sender) return + if (sender.toLowerCase() === payee.toLowerCase()) return + throw new BadRequestError({ + reason: + `Cannot ${operation} channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}. ` + + 'If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.', + }) +} + /** * One-shot settle: reads highest voucher from store and submits on-chain. */ @@ -365,6 +381,13 @@ export async function settle( defaults.escrowContract[chainId as keyof typeof defaults.escrowContract] if (!resolvedEscrow) throw new Error(`No escrow contract for chainId ${chainId}.`) + assertSettlementSender({ + operation: 'settle', + channelId, + payee: channel.payee, + sender: options?.account?.address ?? client.account?.address, + }) + const settledAmount = channel.highestVoucher.cumulativeAmount const txHash = await settleOnChain(client, resolvedEscrow, channel.highestVoucher, { ...(options?.feePayer && options?.account @@ -863,6 +886,13 @@ async function handleClose( throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) } + assertSettlementSender({ + operation: 'close', + channelId: payload.channelId, + payee: onChain.payee, + sender: account?.address ?? client.account?.address, + }) + const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, { ...(feePayer && account ? { feePayer, account } : { account }), }) diff --git a/src/tempo/session/Chain.ts b/src/tempo/session/Chain.ts index 4972e576..fd98b159 100644 --- a/src/tempo/session/Chain.ts +++ b/src/tempo/session/Chain.ts @@ -16,7 +16,6 @@ import { sendRawTransaction, sendRawTransactionSync, signTransaction, - writeContract, } from 'viem/actions' import { Transaction } from 'viem/tempo' @@ -118,14 +117,13 @@ export async function settleOnChain( const data = encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args }) return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'settle') } - return writeContract(client, { - account: resolved, - chain: client.chain, - address: escrowContract, - abi: escrowAbi, - functionName: 'settle', - args, - }) + return sendAccountTx( + client, + resolved, + escrowContract, + encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args }), + 'settle', + ) } /** Options for {@link closeOnChain}. */ @@ -153,14 +151,43 @@ export async function closeOnChain( const data = encodeFunctionData({ abi: escrowAbi, functionName: 'close', args }) return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'close') } - return writeContract(client, { - account: resolved, - chain: client.chain, - address: escrowContract, - abi: escrowAbi, - functionName: 'close', - args, + return sendAccountTx( + client, + resolved, + escrowContract, + encodeFunctionData({ abi: escrowAbi, functionName: 'close', args }), + 'close', + ) +} + +async function sendAccountTx( + client: Client, + account: Account, + to: Address, + data: Hex, + label: string, +): Promise { + const prepared = await prepareTransactionRequest(client, { + account, + calls: [{ to, data }], + } as never) + + const serialized = (await signTransaction(client, { + ...prepared, + account, + } as never)) as Hex + + const receipt = await sendRawTransactionSync(client, { + serializedTransaction: serialized as Transaction.TransactionSerializedTempo, }) + + if (receipt.status !== 'success') { + throw new VerificationFailedError({ + reason: `${label} transaction reverted: ${receipt.transactionHash}`, + }) + } + + return receipt.transactionHash } /** From f4ff319d036053c08d73cb256c4e04ed52045178 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 14 Apr 2026 16:44:45 -0700 Subject: [PATCH 2/2] fix: format session access-key regressions --- src/tempo/server/Session.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 4f23a1e0..405c5268 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -1929,8 +1929,7 @@ describe.runIf(isLocalnet)('session', () => { test('sessionManager.close surfaces problem details from HTTP close failures', async () => { const challenge = makeChallenge({ id: 'close-http-failure', - channelId: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, }) let requests = 0 @@ -1976,7 +1975,9 @@ describe.runIf(isLocalnet)('session', () => { ) } - throw new Error(`unexpected payment action ${(credential.payload as { action: string }).action}`) + throw new Error( + `unexpected payment action ${(credential.payload as { action: string }).action}`, + ) } const manager = sessionManager({