From a8c5eb0c3223370473df1801d8fbb044ffd170ca Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:19:03 -0700 Subject: [PATCH 1/2] feat: add tempo session credential lifecycle hooks --- .../tempo-session-credential-lifecycle.md | 5 + src/tempo/session/precompile/Chain.ts | 82 +++++- src/tempo/session/server/ChannelStore.ts | 45 +++- .../session/server/CredentialVerification.ts | 255 ++++++++++++++++++ src/tempo/session/server/Session.test.ts | 149 +++++++++- src/tempo/session/server/Session.ts | 153 +++++++---- 6 files changed, 609 insertions(+), 80 deletions(-) create mode 100644 .changeset/tempo-session-credential-lifecycle.md diff --git a/.changeset/tempo-session-credential-lifecycle.md b/.changeset/tempo-session-credential-lifecycle.md new file mode 100644 index 00000000..d5a9449c --- /dev/null +++ b/.changeset/tempo-session-credential-lifecycle.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added non-mutating credential validation and broadcast hooks to the Tempo session server method. diff --git a/src/tempo/session/precompile/Chain.ts b/src/tempo/session/precompile/Chain.ts index 1a991a90..56190921 100644 --- a/src/tempo/session/precompile/Chain.ts +++ b/src/tempo/session/precompile/Chain.ts @@ -522,6 +522,22 @@ async function simulateTempoTransaction(client: Client, request: unknown) { await call(client, request as never) } +/** Simulates an unsponsored client-signed session transaction without broadcasting it. */ +export async function simulateCredentialTransaction(parameters: { + /** viem client used for the read-only execution simulation. */ + client: TransactionClient + /** Fee-payer configuration for the eventual broadcast. Sponsored transactions are simulated after completion. */ + feePayer?: Account | true | undefined + /** Parsed client-signed Tempo transaction. */ + transaction: ReturnType<(typeof Transaction)['deserialize']> +}): Promise { + if (parameters.feePayer) return + await simulateTempoTransaction( + parameters.client, + FeePayer.simulationTransaction(parameters.transaction, { feePayer: false }), + ) +} + async function signTempoTransaction(client: Client, transaction: unknown): Promise { return (await signTransaction(client, transaction as never)) as Hex } @@ -874,10 +890,25 @@ export type BroadcastOpenTransactionParameters = { serializedTransaction: Hex } -/** Broadcast and validate a client-signed TIP-1034 open transaction. */ -export async function broadcastOpenTransaction( - parameters: BroadcastOpenTransactionParameters, -): Promise { +/** Inputs for validating a client-signed TIP-1034 open transaction without broadcasting it. */ +export type ValidateOpenCredentialTransactionParameters = Omit< + BroadcastOpenTransactionParameters, + 'beforeBroadcast' | 'challengeExpires' | 'client' | 'feePayerPolicy' +> + +/** Validated fields recovered from a client-signed TIP-1034 open transaction. */ +export type ValidatedOpenCredentialTransaction = Omit< + BroadcastOpenTransactionResult, + 'state' | 'txHash' +> & { + /** Parsed client-signed transaction, reused by simulation and broadcast. */ + transaction: ReturnType<(typeof Transaction)['deserialize']> +} + +/** Validates the immutable fields of a client-signed TIP-1034 open transaction. */ +export function validateOpenCredentialTransaction( + parameters: ValidateOpenCredentialTransactionParameters, +): ValidatedOpenCredentialTransaction { const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({ escrowContract: parameters.escrowContract, feePayer: parameters.feePayer, @@ -920,10 +951,19 @@ export async function broadcastOpenTransaction( throw new VerificationFailedError({ reason: 'credential expiringNonceHash does not match transaction', }) + return { descriptor, expiringNonceHash, openDeposit: open.deposit, transaction } +} + +/** Broadcast and validate a client-signed TIP-1034 open transaction. */ +export async function broadcastOpenTransaction( + parameters: BroadcastOpenTransactionParameters, +): Promise { + const { descriptor, expiringNonceHash, openDeposit, transaction } = + validateOpenCredentialTransaction(parameters) await parameters.beforeBroadcast?.({ descriptor, expiringNonceHash, - openDeposit: open.deposit, + openDeposit, }) const receipt = await sendCredentialTransaction({ challengeExpires: parameters.challengeExpires, @@ -952,7 +992,7 @@ export async function broadcastOpenTransaction( emittedExpiringNonceHash: opened.expiringNonceHash, escrow: parameters.escrowContract, expectedChannelId: parameters.expectedChannelId, - openDeposit: open.deposit, + openDeposit, }) const chainChannel = await readbackWithRetry(() => getChannel(parameters.client, descriptor, parameters.escrowContract, receipt.blockNumber), @@ -964,7 +1004,7 @@ export async function broadcastOpenTransaction( descriptor, state, expiringNonceHash: opened.expiringNonceHash, - openDeposit: open.deposit, + openDeposit, } } @@ -1004,10 +1044,22 @@ export type BroadcastTopUpTransactionParameters = { serializedTransaction: Hex } -/** Broadcast and validate a client-signed TIP-1034 top-up transaction. */ -export async function broadcastTopUpTransaction( - parameters: BroadcastTopUpTransactionParameters, -): Promise { +/** Inputs for validating a client-signed TIP-1034 top-up transaction without broadcasting it. */ +export type ValidateTopUpCredentialTransactionParameters = Omit< + BroadcastTopUpTransactionParameters, + 'challengeExpires' | 'client' | 'feePayerPolicy' +> + +/** Validated fields recovered from a client-signed TIP-1034 top-up transaction. */ +export type ValidatedTopUpCredentialTransaction = { + /** Parsed client-signed transaction, reused by simulation and broadcast. */ + transaction: ReturnType<(typeof Transaction)['deserialize']> +} + +/** Validates the immutable fields of a client-signed TIP-1034 top-up transaction. */ +export function validateTopUpCredentialTransaction( + parameters: ValidateTopUpCredentialTransactionParameters, +): ValidatedTopUpCredentialTransaction { const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({ escrowContract: parameters.escrowContract, feePayer: parameters.feePayer, @@ -1027,6 +1079,14 @@ export async function broadcastTopUpTransaction( label: 'topUp', prefixCalls, }) + return { transaction } +} + +/** Broadcast and validate a client-signed TIP-1034 top-up transaction. */ +export async function broadcastTopUpTransaction( + parameters: BroadcastTopUpTransactionParameters, +): Promise { + const { transaction } = validateTopUpCredentialTransaction(parameters) const receipt = await sendCredentialTransaction({ challengeExpires: parameters.challengeExpires, chainId: parameters.chainId, diff --git a/src/tempo/session/server/ChannelStore.ts b/src/tempo/session/server/ChannelStore.ts index 5fccbc52..58f67ef7 100644 --- a/src/tempo/session/server/ChannelStore.ts +++ b/src/tempo/session/server/ChannelStore.ts @@ -240,6 +240,18 @@ export type VerifyAndAcceptVoucherParameters = { voucher: SignedVoucher } +/** Inputs for validating a cumulative voucher without recording it. */ +export type ValidateVoucherParameters = Omit< + VerifyAndAcceptVoucherParameters, + 'challenge' | 'store' +> + +/** Result of validating a cumulative voucher against persisted channel state. */ +export type VoucherValidation = { + /** Whether the voucher was already accepted at the same cumulative amount. */ + alreadyAccepted: boolean +} + /** Channel backend-specific fields. */ export type BackendState = CompatibilityBackendState | PrecompileBackendState @@ -520,12 +532,11 @@ export async function loadPrecompileChannel( return channel } -/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */ -export async function verifyAndAcceptVoucher( - parameters: VerifyAndAcceptVoucherParameters, -): Promise { - const { store, minVoucherDelta, challenge, channel, voucher, channelState, methodDetails } = - parameters +/** Verifies a cumulative voucher without changing persisted channel state. */ +export async function validateVoucher( + parameters: ValidateVoucherParameters, +): Promise { + const { minVoucherDelta, channel, voucher, channelState, methodDetails } = parameters validateChannelState(channelState) if (voucher.cumulativeAmount > channelState.deposit) @@ -546,7 +557,22 @@ export async function verifyAndAcceptVoucher( throw new VerificationFailedError({ reason: 'voucher cumulativeAmount is below on-chain settled amount', }) - if (voucher.cumulativeAmount === channel.highestVoucherAmount) + if (voucher.cumulativeAmount === channel.highestVoucherAmount) return { alreadyAccepted: true } + const delta = voucher.cumulativeAmount - channel.highestVoucherAmount + if (delta < minVoucherDelta) + throw new DeltaTooSmallError({ + reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, + }) + return { alreadyAccepted: false } +} + +/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */ +export async function verifyAndAcceptVoucher( + parameters: VerifyAndAcceptVoucherParameters, +): Promise { + const { store, challenge, channel, voucher, channelState } = parameters + const validation = await validateVoucher(parameters) + if (validation.alreadyAccepted) return createSessionReceipt({ challengeId: challenge.id, channelId: voucher.channelId, @@ -554,11 +580,6 @@ export async function verifyAndAcceptVoucher( spent: channel.spent, units: channel.units, }) - const delta = voucher.cumulativeAmount - channel.highestVoucherAmount - if (delta < minVoucherDelta) - throw new DeltaTooSmallError({ - reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`, - }) const updated = await store.updateChannel(voucher.channelId, (current) => acceptVoucherStateUpdate({ channelState, current, voucher }), ) diff --git a/src/tempo/session/server/CredentialVerification.ts b/src/tempo/session/server/CredentialVerification.ts index cbd4228f..d9f5f0c1 100644 --- a/src/tempo/session/server/CredentialVerification.ts +++ b/src/tempo/session/server/CredentialVerification.ts @@ -378,6 +378,261 @@ const refreshOnChainVerificationCache = { voucher: false, } satisfies Record +/** Inputs for validating a session credential without applying its state transition. */ +export type ValidateCredentialPayloadParameters = Pick< + VerifyCredentialPayloadParameters, + | 'account' + | 'chainId' + | 'client' + | 'credentialSource' + | 'escrow' + | 'expectedOperator' + | 'feePayer' + | 'minVoucherDelta' + | 'payload' + | 'store' + | 'challenge' +> + +/** Non-mutating result produced by session credential validation. */ +export type CredentialPayloadValidation = { + /** Session action validated from the credential. */ + action: SessionCredentialPayload['action'] + /** Normalized channel ID targeted by the credential. */ + channelId: Hex +} + +/** Validates all action-specific session credential invariants without changing payment state. */ +export async function validateCredentialPayload( + parameters: ValidateCredentialPayloadParameters, +): Promise { + const { payload } = parameters + switch (payload.action) { + case 'open': + await validateOpenCredential(parameters, payload) + break + case 'topUp': + await validateTopUpCredential(parameters, payload) + break + case 'voucher': + await validateVoucherCredential(parameters, payload) + break + case 'close': + await validateCloseCredential(parameters, payload) + break + } + return { action: payload.action, channelId: ChannelStore.normalizeChannelId(payload.channelId) } +} + +async function validateOpenCredential( + parameters: ValidateCredentialPayloadParameters, + payload: Extract, +) { + const { challenge, chainId, client, escrow } = parameters + const request = getChallengePaymentFields(challenge) + const expectedOperator = parameters.expectedOperator ?? zeroAddress + const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) + assertDescriptor(payload) + if ( + payload.authorizedSigner !== undefined && + !isAddressEqual(payload.authorizedSigner, payload.descriptor.authorizedSigner) + ) + throw new VerificationFailedError({ + reason: 'credential authorizedSigner does not match descriptor', + }) + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + validateChannelDescriptor( + payload.descriptor, + channelId, + chainId, + escrow, + request.recipient, + request.currency, + expectedOperator, + ) + const transaction = Chain.validateOpenCredentialTransaction({ + chainId, + escrowContract: escrow, + expectedAuthorizedSigner: payload.descriptor.authorizedSigner, + expectedChannelId: channelId, + expectedCurrency: request.currency, + expectedOperator, + expectedPayee: request.recipient, + expectedExpiringNonceHash: payload.descriptor.expiringNonceHash, + expectedPayer: payload.descriptor.payer, + feePayer: parameters.feePayer, + serializedTransaction: payload.transaction, + }) + assertOpenCredentialCoversRequest({ + cumulativeAmount, + openDeposit: transaction.openDeposit, + requestAmount: request.amount, + }) + assertSameDescriptor(transaction.descriptor, payload.descriptor) + if (cumulativeAmount > transaction.openDeposit) + throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' }) + const valid = await Voucher.verifyVoucher( + escrow, + chainId, + { channelId, cumulativeAmount, signature: payload.signature }, + authorizedSigner(transaction.descriptor), + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + await Chain.simulateCredentialTransaction({ + client, + feePayer: parameters.feePayer, + transaction: transaction.transaction, + }) +} + +async function validateTopUpCredential( + parameters: ValidateCredentialPayloadParameters, + payload: Extract, +) { + const { challenge, chainId, client, escrow, store } = parameters + const request = getChallengePaymentFields(challenge) + const expectedOperator = parameters.expectedOperator ?? zeroAddress + const additionalDeposit = uint96(BigInt(payload.additionalDeposit)) + assertDescriptor(payload) + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + validateChannelDescriptor( + payload.descriptor, + channelId, + chainId, + escrow, + request.recipient, + request.currency, + expectedOperator, + ) + const channel = await ChannelStore.loadPrecompileChannel({ + descriptor: payload.descriptor, + channelId, + chainId, + escrow, + store, + validateDescriptor: true, + }) + const transaction = Chain.validateTopUpCredentialTransaction({ + additionalDeposit, + chainId, + descriptor: channel.descriptor, + escrowContract: escrow, + expectedChannelId: channelId, + expectedCurrency: request.currency, + feePayer: parameters.feePayer, + serializedTransaction: payload.transaction, + }) + await Chain.simulateCredentialTransaction({ + client, + feePayer: parameters.feePayer, + transaction: transaction.transaction, + }) +} + +async function validateVoucherCredential( + parameters: ValidateCredentialPayloadParameters, + payload: Extract, +) { + const { challenge, chainId, client, credentialSource, escrow, minVoucherDelta, store } = + parameters + const request = getChallengePaymentFields(challenge) + const expectedOperator = parameters.expectedOperator ?? zeroAddress + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + const voucher = Voucher.parseVoucherFromPayload( + channelId, + payload.cumulativeAmount, + payload.signature, + ) + assertDescriptor(payload) + validateChannelDescriptor( + payload.descriptor, + channelId, + chainId, + escrow, + request.recipient, + request.currency, + expectedOperator, + ) + const channel = await ChannelStore.loadPrecompileChannel({ + descriptor: payload.descriptor, + channelId, + chainId, + escrow, + store, + validateDescriptor: true, + }) + assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource }) + if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) + const channelState = await Chain.getChannelState(client, channelId, escrow) + await ChannelStore.validateVoucher({ + channel, + channelState, + methodDetails: { chainId, escrowContract: escrow }, + minVoucherDelta, + voucher, + }) +} + +async function validateCloseCredential( + parameters: ValidateCredentialPayloadParameters, + payload: Extract, +) { + const { challenge, chainId, client, escrow, store } = parameters + const request = getChallengePaymentFields(challenge) + const expectedOperator = parameters.expectedOperator ?? zeroAddress + const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount)) + const channelId = ChannelStore.normalizeChannelId(payload.channelId) + assertDescriptor(payload) + validateChannelDescriptor( + payload.descriptor, + channelId, + chainId, + escrow, + request.recipient, + request.currency, + expectedOperator, + ) + const channel = await ChannelStore.loadPrecompileChannel({ + descriptor: payload.descriptor, + channelId, + chainId, + escrow, + store, + }) + if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' }) + const state = await Chain.getChannelState(client, channelId, escrow) + if (state.closeRequestedAt !== 0) + throw new ChannelClosedError({ reason: 'channel has a pending close request' }) + if (state.deposit === 0n && (cumulativeAmount !== 0n || channel.spent !== 0n)) + throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) + if (cumulativeAmount < channel.spent) + throw new VerificationFailedError({ + reason: `close voucher amount must be >= ${channel.spent} (spent)`, + }) + if (cumulativeAmount < state.settled) + throw new VerificationFailedError({ + reason: `close voucher amount must be >= ${state.settled} (on-chain settled)`, + }) + const valid = await Voucher.verifyVoucher( + escrow, + chainId, + { channelId, cumulativeAmount, signature: payload.signature }, + channel.authorizedSigner, + ) + if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) + const captureAmount = uint96(channel.spent > state.settled ? channel.spent : state.settled) + if (captureAmount > state.deposit) + throw new AmountExceedsDepositError({ reason: 'close capture amount exceeds on-chain deposit' }) + const account = parameters.account ?? getClientAccount(client) + assertSettlementSender({ + operation: 'close', + channelId, + operator: channel.operator, + payee: channel.payee, + sender: account?.address, + }) +} + /** Verifies a session credential payload and applies the action-specific state transition. */ export async function verifyCredentialPayload( context: VerifyCredentialPayloadParameters, diff --git a/src/tempo/session/server/Session.test.ts b/src/tempo/session/server/Session.test.ts index 34269223..bf8feb81 100644 --- a/src/tempo/session/server/Session.test.ts +++ b/src/tempo/session/server/Session.test.ts @@ -4,6 +4,8 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' import { Challenge, Constants, Credential } from 'mppx' import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' +import { Secp256k1 } from 'ox' +import { TxEnvelopeTempo } from 'ox/tempo' import { type Address, createClient, @@ -40,9 +42,8 @@ import * as ChannelStore from './ChannelStore.js' import { charge, session, type ResolveSessionChannelId } from './Session.js' import * as TempoWs from './Ws.js' -const payer = privateKeyToAccount( - '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', -) +const payerPrivateKey = '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +const payer = privateKeyToAccount(payerPrivateKey) const wrongPayer = privateKeyToAccount( '0x59c6995e998f97a5a0044966f094538a009d74290f5811cfba6a6b4d238ff944', ) @@ -315,6 +316,48 @@ async function createOpenPayload( } } +function sponsorTransaction(serializedTransaction: Hex): Hex { + const transaction = Transaction.deserialize( + serializedTransaction as Transaction.TransactionSerializedTempo, + ) + const envelope = TxEnvelopeTempo.from({ + ...transaction, + feePayerSignature: null, + gas: 100_000n, + maxFeePerGas: 1n, + maxPriorityFeePerGas: 1n, + validBefore: Math.floor(Date.now() / 1_000) + 600, + } as never) + return TxEnvelopeTempo.serialize(envelope, { + signature: Secp256k1.sign({ + payload: TxEnvelopeTempo.getSignPayload(envelope), + privateKey: payerPrivateKey, + }), + }) as Hex +} + +async function createSponsoredOpenPayload(): Promise< + Extract +> { + const payload = await createOpenPayload() + const transaction = sponsorTransaction(payload.transaction) + const signed = Transaction.deserialize(transaction as Transaction.TransactionSerializedTempo) + const expiringNonceHash = Channel.computeExpiringNonceHash( + Channel.transactionForExpiringNonceHash({ feePayer: true, transaction: signed }), + { sender: payer.address }, + ) + const descriptor = { ...payload.descriptor, expiringNonceHash } + const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow }) + const signature = await Voucher.signVoucher( + createSigningClient(), + payer, + { channelId, cumulativeAmount: BigInt(payload.cumulativeAmount) }, + tip20ChannelEscrow, + chainId, + ) + return { ...payload, channelId, transaction, signature, descriptor } +} + function transactionReceipt(logs: readonly Record[]) { return { blockHash: `0x${'01'.repeat(32)}`, @@ -757,6 +800,106 @@ describe('precompile server session unit guardrails', () => { ).rejects.toThrow('Client not configured with chainId 1.') }) + test('fully validates a session credential without broadcasting or changing channel state', async () => { + const { method, rpcCalls, store } = createServer() + const openPayload = await createOpenPayload() + await persistPrecompileChannel(store, openPayload) + const payload = await ClientOps.createVoucherPayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(200n), + chainId, + ) + + const validation = await method.validate!({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + source: sourceFor(), + }, + request: verifyRequest(openPayload.channelId), + }) + + expect(validation.details).toEqual({ action: 'voucher', channelId: openPayload.channelId }) + expect(await store.getChannel(openPayload.channelId)).toMatchObject({ + highestVoucherAmount: 100n, + spent: 0n, + units: 0, + }) + expect(rpcCalls.map(({ method }) => method)).toContain('eth_call') + expect(rpcCalls.map(({ method }) => method)).not.toContain('eth_sendRawTransaction') + expect(rpcCalls.map(({ method }) => method)).not.toContain('eth_sendRawTransactionSync') + }) + + test('simulates unsponsored open and top-up credentials during validation', async () => { + const { method, rpcCalls, store } = createServer() + const openPayload = await createOpenPayload() + + await method.validate!({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: verifyRequest(openPayload.channelId), + }) + await persistPrecompileChannel(store, openPayload) + const topUpPayload = await createTopUpPayload(openPayload.descriptor, 100n) + + await method.validate!({ + credential: { challenge: makeChallenge(topUpPayload.channelId), payload: topUpPayload }, + request: verifyRequest(topUpPayload.channelId), + }) + + expect(rpcCalls.map(({ method }) => method)).toEqual(['eth_call', 'eth_call']) + expect(await store.getChannel(openPayload.channelId)).toMatchObject({ + deposit: 1_000n, + highestVoucherAmount: 100n, + }) + }) + + test('does not simulate sponsored open or top-up credentials during validation', async () => { + const { method, rpcCalls, store } = createServer({ feePayer: payer }) + const openPayload = await createSponsoredOpenPayload() + + await method.validate!({ + credential: { challenge: makeChallenge(openPayload.channelId), payload: openPayload }, + request: verifyRequestWithFeePayer(openPayload.channelId, payer), + }) + await persistPrecompileChannel(store, openPayload) + const topUpPayload = await createTopUpPayload(openPayload.descriptor, 100n) + topUpPayload.transaction = sponsorTransaction(topUpPayload.transaction) + + await method.validate!({ + credential: { challenge: makeChallenge(topUpPayload.channelId), payload: topUpPayload }, + request: verifyRequestWithFeePayer(topUpPayload.channelId, payer), + }) + + expect(rpcCalls).toEqual([]) + expect(await store.getChannel(openPayload.channelId)).toMatchObject({ + deposit: 1_000n, + highestVoucherAmount: 100n, + }) + }) + + test('validates close credentials without marking the channel pending', async () => { + const { method, rpcCalls, store } = createServer() + const openPayload = await createOpenPayload() + await persistPrecompileChannel(store, openPayload, { payee: payer.address }) + const payload = await ClientOps.createClosePayload( + createSigningClient(), + payer, + openPayload.descriptor, + Types.uint96(100n), + chainId, + ) + + await method.validate!({ + credential: { challenge: makeChallenge(openPayload.channelId), payload }, + request: verifyRequest(openPayload.channelId), + }) + + expect((await store.getChannel(openPayload.channelId))?.closeRequestedAt).toBe(0n) + expect(rpcCalls.map(({ method }) => method)).toEqual(['eth_call']) + }) + test('rejects open transactions targeting the wrong address', async () => { const { method } = createServer() const payload = await createOpenPayload({ escrow: wrongTarget }) diff --git a/src/tempo/session/server/Session.ts b/src/tempo/session/server/Session.ts index 7d0bbccc..064a09b7 100644 --- a/src/tempo/session/server/Session.ts +++ b/src/tempo/session/server/Session.ts @@ -31,7 +31,7 @@ import { serializeSnapshot as serializeSessionSnapshot, } from '../Snapshot.js' import * as ChannelStore from './ChannelStore.js' -import { verifyCredentialPayload } from './CredentialVerification.js' +import { validateCredentialPayload, verifyCredentialPayload } from './CredentialVerification.js' import { requireSessionCredentialPayload } from './CredentialVerification.js' import { type ResolveSessionChannelId, @@ -349,6 +349,102 @@ export function session( }) : undefined + const validateCredential: Method.ValidateFn = async ({ + credential, + request, + }) => { + const payload = requireSessionCredentialPayload(credential.payload) + const context = await resolveCredentialVerificationContext({ + decimals, + feePayer: configuredFeePayer, + getClient, + minVoucherDelta: parameters.minVoucherDelta, + request, + }) + + const details = await validateCredentialPayload({ + account, + challenge: credential.challenge, + chainId: context.chainId, + client: context.client, + credentialSource: credential.source, + escrow: context.escrow, + expectedOperator: context.methodDetails.operator, + feePayer: context.feePayer, + minVoucherDelta: context.minVoucherDelta, + payload, + store, + }) + + return { + challenge: credential.challenge, + credential: { ...credential, payload }, + details, + intent: 'session' as const, + method: 'tempo' as const, + request: context.request, + source: credential.source, + } + } + + const broadcastCredential: Method.BroadcastFn = async ({ + credential, + envelope, + request, + }) => { + const { challenge } = credential + const payload = requireSessionCredentialPayload(credential.payload) + const context = await resolveCredentialVerificationContext({ + decimals, + feePayer: configuredFeePayer, + getClient, + minVoucherDelta: parameters.minVoucherDelta, + request, + }) + + const sessionReceipt = await verifyCredentialPayload({ + account, + challenge, + channelStateTtl, + chainId: context.chainId, + client: context.client, + credentialSource: credential.source, + escrow: context.escrow, + expectedOperator: context.methodDetails.operator, + feePayer: context.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + feeToken: parameters.feeToken, + lastOnChainVerified, + minVoucherDelta: context.minVoucherDelta, + onSessionSettlement, + payload, + store, + }) + + return applyVerifiedHttpAccounting({ + capturedRequest: envelope?.capturedRequest, + payloadAction: payload.action, + receipt: sessionReceipt, + getRequestAmount: () => BigInt(context.request.amount ?? challenge.request.amount), + sseEnabled: Boolean(parameters.sse), + markPrepaidReceipt: Transport.markPrepaidSessionTick, + charge: (channelId, requestAmount) => + chargeSessionChannel({ store, channelId, amount: requestAmount }), + settleCharged: (channel) => + maybeSettleScheduled({ + account, + client: context.client, + ...(typeof context.feePayer === 'object' ? { feePayer: context.feePayer } : {}), + feePayerPolicy: parameters.feePayerPolicy, + feeToken: parameters.feeToken, + onSessionSettlement, + schedule: settlementSchedule, + store, + channel, + }), + }) + } + type Defaults = session.DeriveDefaults return Method.toServer(Methods.session, { defaults: deriveServerDefaults({ @@ -406,59 +502,8 @@ export function session( } }, - async verify({ credential, envelope, request }) { - const { challenge } = credential - const payload = requireSessionCredentialPayload(credential.payload) - const context = await resolveCredentialVerificationContext({ - decimals, - feePayer: configuredFeePayer, - getClient, - minVoucherDelta: parameters.minVoucherDelta, - request, - }) - - const sessionReceipt = await verifyCredentialPayload({ - account, - challenge, - channelStateTtl, - chainId: context.chainId, - client: context.client, - credentialSource: credential.source, - escrow: context.escrow, - expectedOperator: context.methodDetails.operator, - feePayer: context.feePayer, - feePayerPolicy: parameters.feePayerPolicy, - feeToken: parameters.feeToken, - lastOnChainVerified, - minVoucherDelta: context.minVoucherDelta, - onSessionSettlement, - payload, - store, - }) - - return applyVerifiedHttpAccounting({ - capturedRequest: envelope?.capturedRequest, - payloadAction: payload.action, - receipt: sessionReceipt, - getRequestAmount: () => BigInt(context.request.amount ?? challenge.request.amount), - sseEnabled: Boolean(parameters.sse), - markPrepaidReceipt: Transport.markPrepaidSessionTick, - charge: (channelId, requestAmount) => - chargeSessionChannel({ store, channelId, amount: requestAmount }), - settleCharged: (channel) => - maybeSettleScheduled({ - account, - client: context.client, - ...(typeof context.feePayer === 'object' ? { feePayer: context.feePayer } : {}), - feePayerPolicy: parameters.feePayerPolicy, - feeToken: parameters.feeToken, - onSessionSettlement, - schedule: settlementSchedule, - store, - channel, - }), - }) - }, + validate: validateCredential, + broadcast: broadcastCredential, // This hook acts as a gate: when it returns a Response, `withReceipt()` // in Mppx.ts short-circuits and returns that response directly without From 4c24dfab11e576ec746e3f74cb1f60b13069177a Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:03:28 -0700 Subject: [PATCH 2/2] fix: preflight tempo session validation --- src/tempo/session/precompile/Chain.ts | 64 +++++++++++++++++- .../session/server/CredentialVerification.ts | 65 +++++++++++++++---- src/tempo/session/server/Session.test.ts | 48 +++++++++++--- src/tempo/session/server/Session.ts | 3 + 4 files changed, 159 insertions(+), 21 deletions(-) diff --git a/src/tempo/session/precompile/Chain.ts b/src/tempo/session/precompile/Chain.ts index 56190921..1e09d2f0 100644 --- a/src/tempo/session/precompile/Chain.ts +++ b/src/tempo/session/precompile/Chain.ts @@ -538,6 +538,39 @@ export async function simulateCredentialTransaction(parameters: { ) } +function validateCredentialSponsorship(parameters: { + allowedFeeTokens: readonly Address[] + challengeExpires?: string | undefined + chainId: number + details: Record + feePayer?: Account | true | undefined + feePayerPolicy?: Partial | undefined + transaction: ReturnType<(typeof Transaction)['deserialize']> +}) { + const { + allowedFeeTokens, + challengeExpires, + chainId, + details, + feePayer, + feePayerPolicy, + transaction, + } = parameters + if (!feePayer || feePayer === true) return + FeePayer.prepareSponsoredTransaction({ + account: feePayer, + allowedFeeTokens, + challengeExpires, + chainId, + details, + policy: feePayerPolicy, + transaction: { + ...transaction, + ...(allowedFeeTokens[0] ? { feeToken: transaction.feeToken ?? allowedFeeTokens[0] } : {}), + }, + }) +} + async function signTempoTransaction(client: Client, transaction: unknown): Promise { return (await signTransaction(client, transaction as never)) as Hex } @@ -893,7 +926,7 @@ export type BroadcastOpenTransactionParameters = { /** Inputs for validating a client-signed TIP-1034 open transaction without broadcasting it. */ export type ValidateOpenCredentialTransactionParameters = Omit< BroadcastOpenTransactionParameters, - 'beforeBroadcast' | 'challengeExpires' | 'client' | 'feePayerPolicy' + 'beforeBroadcast' | 'client' > /** Validated fields recovered from a client-signed TIP-1034 open transaction. */ @@ -940,6 +973,19 @@ export function validateOpenCredentialTransaction( channelId: parameters.expectedChannelId, }) if (parameters.feePayer) assertSenderSigned(transaction) + validateCredentialSponsorship({ + allowedFeeTokens: [parameters.expectedCurrency], + challengeExpires: parameters.challengeExpires, + chainId: parameters.chainId, + details: { + channelId: parameters.expectedChannelId, + currency: parameters.expectedCurrency, + recipient: parameters.expectedPayee, + }, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + transaction, + }) const expiringNonceHash = ChannelUtils.computeExpiringNonceHash( ChannelUtils.transactionForExpiringNonceHash({ feePayer: parameters.feePayer, @@ -1047,7 +1093,7 @@ export type BroadcastTopUpTransactionParameters = { /** Inputs for validating a client-signed TIP-1034 top-up transaction without broadcasting it. */ export type ValidateTopUpCredentialTransactionParameters = Omit< BroadcastTopUpTransactionParameters, - 'challengeExpires' | 'client' | 'feePayerPolicy' + 'client' > /** Validated fields recovered from a client-signed TIP-1034 top-up transaction. */ @@ -1079,6 +1125,20 @@ export function validateTopUpCredentialTransaction( label: 'topUp', prefixCalls, }) + if (parameters.feePayer) assertSenderSigned(transaction) + validateCredentialSponsorship({ + allowedFeeTokens: [parameters.expectedCurrency], + challengeExpires: parameters.challengeExpires, + chainId: parameters.chainId, + details: { + additionalDeposit: parameters.additionalDeposit.toString(), + channelId: parameters.expectedChannelId, + currency: parameters.expectedCurrency, + }, + feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + transaction, + }) return { transaction } } diff --git a/src/tempo/session/server/CredentialVerification.ts b/src/tempo/session/server/CredentialVerification.ts index d9f5f0c1..053fcaf5 100644 --- a/src/tempo/session/server/CredentialVerification.ts +++ b/src/tempo/session/server/CredentialVerification.ts @@ -382,12 +382,15 @@ const refreshOnChainVerificationCache = { export type ValidateCredentialPayloadParameters = Pick< VerifyCredentialPayloadParameters, | 'account' + | 'channelStateTtl' | 'chainId' | 'client' | 'credentialSource' | 'escrow' | 'expectedOperator' | 'feePayer' + | 'feePayerPolicy' + | 'lastOnChainVerified' | 'minVoucherDelta' | 'payload' | 'store' @@ -451,6 +454,7 @@ async function validateOpenCredential( expectedOperator, ) const transaction = Chain.validateOpenCredentialTransaction({ + challengeExpires: challenge.expires, chainId, escrowContract: escrow, expectedAuthorizedSigner: payload.descriptor.authorizedSigner, @@ -461,6 +465,7 @@ async function validateOpenCredential( expectedExpiringNonceHash: payload.descriptor.expiringNonceHash, expectedPayer: payload.descriptor.payer, feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, serializedTransaction: payload.transaction, }) assertOpenCredentialCoversRequest({ @@ -514,14 +519,17 @@ async function validateTopUpCredential( }) const transaction = Chain.validateTopUpCredentialTransaction({ additionalDeposit, + challengeExpires: challenge.expires, chainId, descriptor: channel.descriptor, escrowContract: escrow, expectedChannelId: channelId, expectedCurrency: request.currency, feePayer: parameters.feePayer, + feePayerPolicy: parameters.feePayerPolicy, serializedTransaction: payload.transaction, }) + validateChannelState(await Chain.getChannelState(client, channelId, escrow)) await Chain.simulateCredentialTransaction({ client, feePayer: parameters.feePayer, @@ -533,8 +541,17 @@ async function validateVoucherCredential( parameters: ValidateCredentialPayloadParameters, payload: Extract, ) { - const { challenge, chainId, client, credentialSource, escrow, minVoucherDelta, store } = - parameters + const { + challenge, + chainId, + client, + credentialSource, + escrow, + minVoucherDelta, + store, + channelStateTtl, + lastOnChainVerified, + } = parameters const request = getChallengePaymentFields(challenge) const expectedOperator = parameters.expectedOperator ?? zeroAddress const channelId = ChannelStore.normalizeChannelId(payload.channelId) @@ -563,7 +580,14 @@ async function validateVoucherCredential( }) assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource }) if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) - const channelState = await Chain.getChannelState(client, channelId, escrow) + const channelState = await resolveVoucherChannelState({ + channel, + channelId, + channelStateTtl, + client, + escrow, + lastOnChainVerified, + }) await ChannelStore.validateVoucher({ channel, channelState, @@ -573,6 +597,25 @@ async function validateVoucherCredential( }) } +async function resolveVoucherChannelState(parameters: { + channel: ChannelStore.State + channelId: Hex + channelStateTtl: number + client: Chain.TransactionClient + escrow: Address + lastOnChainVerified: Map +}): Promise { + const { channel, channelId, channelStateTtl, client, escrow, lastOnChainVerified } = parameters + const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl + const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined + if (state) lastOnChainVerified.set(channelId, Date.now()) + return { + deposit: state?.deposit ?? uint96(channel.deposit), + settled: state?.settled ?? uint96(channel.settledOnChain), + closeRequestedAt: state?.closeRequestedAt ?? Number(channel.closeRequestedAt), + } +} + async function validateCloseCredential( parameters: ValidateCredentialPayloadParameters, payload: Extract, @@ -852,14 +895,14 @@ async function handleVoucherCredential( }) assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource }) if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' }) - const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl - const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined - if (state) lastOnChainVerified.set(channelId, Date.now()) - const channelState = { - deposit: state?.deposit ?? uint96(channel.deposit), - settled: state?.settled ?? uint96(channel.settledOnChain), - closeRequestedAt: state?.closeRequestedAt ?? Number(channel.closeRequestedAt), - } + const channelState = await resolveVoucherChannelState({ + channel, + channelId, + channelStateTtl, + client, + escrow, + lastOnChainVerified, + }) if (channelState.closeRequestedAt !== 0) { await store.updateChannel(channelId, (current) => current diff --git a/src/tempo/session/server/Session.test.ts b/src/tempo/session/server/Session.test.ts index bf8feb81..cc95dd71 100644 --- a/src/tempo/session/server/Session.test.ts +++ b/src/tempo/session/server/Session.test.ts @@ -16,6 +16,7 @@ import { encodeFunctionData, encodeFunctionResult, type Hex, + maxUint256, zeroAddress, } from 'viem' import { privateKeyToAccount } from 'viem/accounts' @@ -316,16 +317,20 @@ async function createOpenPayload( } } -function sponsorTransaction(serializedTransaction: Hex): Hex { +function sponsorTransaction( + serializedTransaction: Hex, + options: { gas?: bigint | undefined } = {}, +): Hex { const transaction = Transaction.deserialize( serializedTransaction as Transaction.TransactionSerializedTempo, ) const envelope = TxEnvelopeTempo.from({ ...transaction, feePayerSignature: null, - gas: 100_000n, + gas: options.gas ?? 100_000n, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n, + nonceKey: maxUint256, validBefore: Math.floor(Date.now() / 1_000) + 600, } as never) return TxEnvelopeTempo.serialize(envelope, { @@ -821,13 +826,22 @@ describe('precompile server session unit guardrails', () => { request: verifyRequest(openPayload.channelId), }) + await method.validate!({ + credential: { + challenge: makeChallenge(openPayload.channelId), + payload, + source: sourceFor(), + }, + request: verifyRequest(openPayload.channelId), + }) + expect(validation.details).toEqual({ action: 'voucher', channelId: openPayload.channelId }) expect(await store.getChannel(openPayload.channelId)).toMatchObject({ highestVoucherAmount: 100n, spent: 0n, units: 0, }) - expect(rpcCalls.map(({ method }) => method)).toContain('eth_call') + expect(rpcCalls.map(({ method }) => method)).toEqual(['eth_call']) expect(rpcCalls.map(({ method }) => method)).not.toContain('eth_sendRawTransaction') expect(rpcCalls.map(({ method }) => method)).not.toContain('eth_sendRawTransactionSync') }) @@ -848,14 +862,14 @@ describe('precompile server session unit guardrails', () => { request: verifyRequest(topUpPayload.channelId), }) - expect(rpcCalls.map(({ method }) => method)).toEqual(['eth_call', 'eth_call']) + expect(rpcCalls.map(({ method }) => method)).toEqual(['eth_call', 'eth_call', 'eth_call']) expect(await store.getChannel(openPayload.channelId)).toMatchObject({ deposit: 1_000n, highestVoucherAmount: 100n, }) }) - test('does not simulate sponsored open or top-up credentials during validation', async () => { + test('does not simulate sponsored credentials during validation but checks top-up state', async () => { const { method, rpcCalls, store } = createServer({ feePayer: payer }) const openPayload = await createSponsoredOpenPayload() @@ -872,13 +886,31 @@ describe('precompile server session unit guardrails', () => { request: verifyRequestWithFeePayer(topUpPayload.channelId, payer), }) - expect(rpcCalls).toEqual([]) + expect(rpcCalls.map(({ method }) => method)).toEqual(['eth_call']) expect(await store.getChannel(openPayload.channelId)).toMatchObject({ deposit: 1_000n, highestVoucherAmount: 100n, }) }) + test('rejects sponsored credentials that exceed the fee-payer policy during validation', async () => { + const { method, rpcCalls } = createServer({ + feePayer: payer, + feePayerPolicy: { maxGas: 1n }, + }) + const payload = await createSponsoredOpenPayload() + + payload.transaction = sponsorTransaction(payload.transaction, { gas: 2n }) + + await expect( + method.validate!({ + credential: { challenge: makeChallenge(payload.channelId), payload }, + request: verifyRequestWithFeePayer(payload.channelId, payer), + }), + ).rejects.toThrow(/fee-sponsored transaction gas exceeds sponsor policy/) + expect(rpcCalls).toEqual([]) + }) + test('validates close credentials without marking the channel pending', async () => { const { method, rpcCalls, store } = createServer() const openPayload = await createOpenPayload() @@ -1552,7 +1584,7 @@ describe('precompile server session unit guardrails', () => { expect(stored?.units).toBe(4) }) - test('rejects precompile top-up when on-chain state has pending close', async () => { + test('rejects top-up validation when on-chain state has pending close', async () => { const rawStore = Store.memory() const store = channelStore(rawStore) const openPayload = await createOpenPayload({ initialAmount: 100n }) @@ -1574,7 +1606,7 @@ describe('precompile server session unit guardrails', () => { }) await expect( - method.verify({ + method.validate!({ credential: { challenge: makeChallenge(openPayload.channelId), payload: topUpPayload, diff --git a/src/tempo/session/server/Session.ts b/src/tempo/session/server/Session.ts index 064a09b7..ecdda155 100644 --- a/src/tempo/session/server/Session.ts +++ b/src/tempo/session/server/Session.ts @@ -365,12 +365,15 @@ export function session( const details = await validateCredentialPayload({ account, challenge: credential.challenge, + channelStateTtl, chainId: context.chainId, client: context.client, credentialSource: credential.source, escrow: context.escrow, expectedOperator: context.methodDetails.operator, feePayer: context.feePayer, + feePayerPolicy: parameters.feePayerPolicy, + lastOnChainVerified, minVoucherDelta: context.minVoucherDelta, payload, store,