diff --git a/.changeset/wise-lobsters-argue.md b/.changeset/wise-lobsters-argue.md new file mode 100644 index 00000000..fa360c7c --- /dev/null +++ b/.changeset/wise-lobsters-argue.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added sender and final-envelope simulations before sponsored Tempo broadcasts. diff --git a/src/tempo/internal/fee-payer.test.ts b/src/tempo/internal/fee-payer.test.ts index dc8b443e..3f0ae13f 100644 --- a/src/tempo/internal/fee-payer.test.ts +++ b/src/tempo/internal/fee-payer.test.ts @@ -11,8 +11,10 @@ import { defaultAllowedFeeTokens, FeePayerValidationError, fillHostedFeePayerTransaction, + preflightSponsorship, prepareSponsoredTransaction, simulationTransaction, + sponsoredSimulationTransaction, validateCalls, } from './fee-payer.js' import * as Selectors from './selectors.js' @@ -768,6 +770,97 @@ describe('simulationTransaction', () => { feePayerSignature: undefined, }) }) + + test('builds the final sponsored envelope without either signature', () => { + const transaction = { + calls: [{ to: bogus }], + feePayerSignature, + feeToken: swapTokenIn, + from: bogus, + signature: { r: 1n, s: 2n, yParity: 0 }, + } + + expect( + sponsoredSimulationTransaction(transaction as any, { + feePayer: swapTokenOut, + feeToken: bogus, + }), + ).toEqual({ + ...transaction, + account: bogus, + calls: transaction.calls, + feePayer: swapTokenOut, + feePayerSignature: undefined, + feeToken: bogus, + signature: undefined, + }) + }) +}) + +describe('preflightSponsorship', () => { + const transaction = { + calls: [{ to: bogus }], + feeToken: swapTokenIn, + from: bogus, + } + + test('simulates the sender before completion and the completed envelope after', async () => { + const simulations: Record[] = [] + let completions = 0 + + const result = await preflightSponsorship({ + transaction: transaction as any, + async simulate(request) { + simulations.push(request) + }, + async complete() { + completions += 1 + return { feePayer: swapTokenOut, transaction: transaction as any } + }, + }) + + expect(completions).toBe(1) + expect(result.feePayer).toBe(swapTokenOut) + expect(simulations).toEqual([ + { account: bogus, calls: transaction.calls }, + { + ...transaction, + account: bogus, + calls: transaction.calls, + feePayer: swapTokenOut, + feePayerSignature: undefined, + signature: undefined, + }, + ]) + }) + + test.each([ + { completeCalls: 0, failingSimulation: 1, simulationCalls: 1 }, + { completeCalls: 1, failingSimulation: 2, simulationCalls: 2 }, + ])( + 'stops at simulation $failingSimulation', + async ({ completeCalls, failingSimulation, simulationCalls }) => { + let completions = 0 + let simulations = 0 + + await expect( + preflightSponsorship({ + transaction: transaction as any, + async simulate() { + simulations += 1 + if (simulations === failingSimulation) throw new Error('simulation failed') + }, + async complete() { + completions += 1 + return { feePayer: swapTokenOut, transaction: transaction as any } + }, + }), + ).rejects.toThrow('simulation failed') + + expect(completions).toBe(completeCalls) + expect(simulations).toBe(simulationCalls) + }, + ) }) describe('prepareSponsoredTransaction', () => { diff --git a/src/tempo/internal/fee-payer.ts b/src/tempo/internal/fee-payer.ts index fd3e107b..4f764c64 100644 --- a/src/tempo/internal/fee-payer.ts +++ b/src/tempo/internal/fee-payer.ts @@ -228,7 +228,7 @@ export async function fillHostedFeePayerTransaction(parameters: { ...(filled.feePayerSignature as Record), yParity: Number((filled.feePayerSignature as { yParity?: unknown }).yParity), } - const feeToken = filled.feeToken + const feeToken = filled.feeToken as TempoAddress.Address // Recover the concrete sponsor address so the simulation can use a concrete // `feePayer` (the node rejects `eth_call` with `feePayer: true`). @@ -266,7 +266,14 @@ export async function fillHostedFeePayerTransaction(parameters: { } } -/** Returns a transaction shape suitable for pre-broadcast simulation. */ +/** + * Returns a transaction shape suitable for pre-broadcast simulation. + * + * Sponsored transactions are first simulated as calls from the sender with no + * fee fields or signatures. This checks call execution without requiring the + * sender to hold the fee token; transferred value and call-level balances are + * still checked by the RPC. + */ export function simulationTransaction( transaction: SponsoredTransaction, options: { feePayer: boolean }, @@ -284,6 +291,61 @@ export function simulationTransaction( } } +/** + * Returns the final fee-sponsored transaction shape for pre-broadcast + * simulation. RPC `eth_call` does not carry either transaction signature. + */ +export function sponsoredSimulationTransaction( + transaction: SponsoredTransaction, + options: { feePayer: TempoAddress.Address; feeToken?: TempoAddress.Address | undefined }, +) { + const { feePayer, feeToken } = options + return { + ...transaction, + account: transaction.from, + calls: transaction.calls, + feePayer, + feePayerSignature: undefined, + ...(feeToken !== undefined ? { feeToken } : {}), + signature: undefined, + } +} + +/** A completed sponsorship envelope that can be simulated before signing or broadcast. */ +export type PreflightSponsorship = { + feePayer: TempoAddress.Address + feeToken?: TempoAddress.Address | undefined + transaction: SponsoredTransaction +} + +/** + * Runs execution-only sender and final-envelope simulations around sponsorship. + * + * First, it simulates the calls with the sender as `from`, omitting fee fields + * and signatures so the sender's fee balance is irrelevant. Next, `complete` + * resolves the sponsor and produces the co-signed transaction. Finally, it + * simulates that transaction with its concrete fee payer and fee token. + * + * `complete` runs only after sender-context execution succeeds, so a reverting + * transaction never reaches a local signer or hosted fee-payer. + */ +export async function preflightSponsorship(parameters: { + complete: () => Promise + simulate: (request: Record) => Promise + transaction: SponsoredTransaction +}): Promise { + const { complete, simulate, transaction } = parameters + await simulate(simulationTransaction(transaction, { feePayer: true })) + const completed = await complete() + await simulate( + sponsoredSimulationTransaction(completed.transaction, { + feePayer: completed.feePayer, + feeToken: completed.feeToken, + }), + ) + return completed +} + /** * maxTotalFee must be high enough to cover `transferWithMemo` and * swap transactions at peak gas prices. Bumped from 0.01 ETH in #327. diff --git a/src/tempo/legacy/session/Chain.test.ts b/src/tempo/legacy/session/Chain.test.ts index 84b5d98e..43b434a4 100644 --- a/src/tempo/legacy/session/Chain.test.ts +++ b/src/tempo/legacy/session/Chain.test.ts @@ -457,7 +457,7 @@ describe.runIf(isLocalnet)('on-chain', () => { ).rejects.toThrow('gas exceeds sponsor policy') }) - test('fee-payer: simulates open before broadcasting', async () => { + test('fee-payer: simulates open before broadcasting and recovers duplicate opens', async () => { const rpcMethods: string[] = [] const interceptingClient = createClient({ account: accounts[0], @@ -521,6 +521,22 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(broadcastIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeLessThan(broadcastIndex) + expect( + rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'), + ).toHaveLength(2) + + const recovered = await broadcastOpenTransaction({ + client: interceptingClient, + serializedTransaction, + escrowContract, + channelId, + recipient, + currency, + feePayer: accounts[0], + }) + + expect(recovered.txHash).toBeUndefined() + expect(recovered.onChain.deposit).toBe(deposit) }) test('fee-payer: rejects smuggled second open call', async () => { @@ -620,6 +636,17 @@ describe.runIf(isLocalnet)('on-chain', () => { }) test('waitForConfirmation: false returns derived on-chain state', async () => { + const rpcMethods: string[] = [] + const interceptingClient = createClient({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + rpcMethods.push(args.method) + return client.transport.request(args) + }, + }), + }) const salt = nextSalt() const deposit = 10_000_000n @@ -633,7 +660,7 @@ describe.runIf(isLocalnet)('on-chain', () => { }) const result = await broadcastOpenTransaction({ - client, + client: interceptingClient, serializedTransaction, escrowContract, channelId, @@ -649,6 +676,52 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(result.onChain.deposit).toBe(deposit) expect(result.onChain.settled).toBe(0n) expect(result.onChain.finalized).toBe(false) + const simulationIndex = rpcMethods.indexOf('eth_call') + const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransaction') + expect(simulationIndex).toBeGreaterThan(-1) + expect(broadcastIndex).toBeGreaterThan(-1) + expect(simulationIndex).toBeLessThan(broadcastIndex) + }) + + test('fee-payer relay: optimistic open simulates before broadcasting', async () => { + const rpcMethods: string[] = [] + const interceptingClient = createClient({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + rpcMethods.push(args.method) + return client.transport.request(args) + }, + }), + }) + const salt = nextSalt() + const deposit = 10_000_000n + const { channelId, serializedTransaction } = await signOpenChannel({ + escrow: escrowContract, + payer, + payee: recipient, + token: currency, + deposit, + salt, + }) + + await broadcastOpenTransaction({ + client: interceptingClient, + serializedTransaction, + escrowContract, + channelId, + recipient, + currency, + isSponsored: true, + waitForConfirmation: false, + }) + + const simulationIndex = rpcMethods.indexOf('eth_call') + const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransaction') + expect(simulationIndex).toBeGreaterThan(-1) + expect(broadcastIndex).toBeGreaterThan(-1) + expect(simulationIndex).toBeLessThan(broadcastIndex) }) }) @@ -1034,6 +1107,9 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(broadcastIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeLessThan(broadcastIndex) + expect( + rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'), + ).toHaveLength(2) }) test('fee-payer: rejects smuggled second topUp call', async () => { diff --git a/src/tempo/legacy/session/Chain.ts b/src/tempo/legacy/session/Chain.ts index 31b80f4c..c306da74 100644 --- a/src/tempo/legacy/session/Chain.ts +++ b/src/tempo/legacy/session/Chain.ts @@ -626,37 +626,47 @@ export async function broadcastOpenTransaction( await beforeBroadcast?.(pendingOnChain) - const serializedTransaction_final = await (async () => { + const completeTransaction = async () => { if (feePayer) { if (!sponsoredOpenCall) throw new BadRequestError({ reason: 'transaction does not contain a valid escrow open call', }) - const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayer, - allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined, - challengeExpires, - chainId: client.chain!.id, - details: { channelId, currency, recipient }, - policy: feePayerPolicy, - transaction: { - ...transaction, - ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}), + const completed = await FeePayer.preflightSponsorship({ + transaction, + simulate: (request) => call(client, request as never), + async complete() { + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayer, + allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined, + challengeExpires, + chainId: client.chain!.id, + details: { channelId, currency, recipient }, + policy: feePayerPolicy, + transaction: { + ...transaction, + ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}), + }, + }) + return { feePayer: feePayer.address, transaction: sponsored } }, }) - return signTransaction(client, sponsored as never) + return signTransaction(client, completed.transaction as never) } return serializedTransaction - })() + } if (!waitForConfirmation) { - await call(client, { - ...transaction, - account: transaction.from, - calls, - feePayerSignature: undefined, - } as never) + const serializedTransaction_final = await completeTransaction() + // Local sponsorship already ran sender-context preflight above. Every + // other optimistic path must still simulate before returning calldata as + // pending on-chain state, including hosted sponsorship (`isSponsored`). + if (!feePayer) + await call( + client, + FeePayer.simulationTransaction(transaction, { feePayer: isSponsored }) as never, + ) const txHash = await sendRawTransaction(client, { serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo, }) @@ -669,14 +679,9 @@ export async function broadcastOpenTransaction( let txHash: Hex | undefined try { - if (feePayer) - await call(client, { - ...transaction, - account: transaction.from, - calls, - feePayerSignature: undefined, - } as never) - + // Keep local preflight inside recovery: a retry can legitimately revert + // during simulation after the original open was mined. + const serializedTransaction_final = await completeTransaction() const receipt = await sendRawTransactionSync(client, { serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo, }) @@ -775,37 +780,36 @@ export async function broadcastTopUpTransaction( }) const defaultFeeToken = defaults.currency[client.chain?.id as keyof typeof defaults.currency] - const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayer, - allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined, - challengeExpires, - chainId: client.chain!.id, - details: { - additionalDeposit: declaredDeposit.toString(), - channelId, - currency, - }, - policy: feePayerPolicy, - transaction: { - ...transaction, - ...((transaction.feeToken ?? defaultFeeToken) - ? { feeToken: transaction.feeToken ?? defaultFeeToken } - : {}), + const completed = await FeePayer.preflightSponsorship({ + transaction, + simulate: (request) => call(client, request as never), + async complete() { + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayer, + allowedFeeTokens: defaultFeeToken ? [defaultFeeToken] : undefined, + challengeExpires, + chainId: client.chain!.id, + details: { + additionalDeposit: declaredDeposit.toString(), + channelId, + currency, + }, + policy: feePayerPolicy, + transaction: { + ...transaction, + ...((transaction.feeToken ?? defaultFeeToken) + ? { feeToken: transaction.feeToken ?? defaultFeeToken } + : {}), + }, + }) + return { feePayer: feePayer.address, transaction: sponsored } }, }) - return signTransaction(client, sponsored as never) + return signTransaction(client, completed.transaction as never) } return serializedTransaction })() - if (feePayer) - await call(client, { - ...transaction, - account: transaction.from, - calls, - feePayerSignature: undefined, - } as never) - const receipt = await sendRawTransactionSync(client, { serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo, }) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index f61a4ef8..e42295e4 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1619,8 +1619,10 @@ describe('tempo', () => { httpServer.close() }) - test('behavior: fee payer pre-broadcast simulation targets the co-signed transaction', async () => { - // The pre-broadcast simulation must reflect the FINAL co-signed envelope + test('behavior: fee payer simulates the sender and final sponsored envelopes', async () => { + // First simulate execution as the sender with fee fields omitted. This + // catches call-level reverts without requiring the sender to fund fees. + // The second simulation must reflect the FINAL co-signed envelope // (concrete sponsor fee payer), not the pre-cosign 0x78 (`feePayer: true`). const callRequests: any[] = [] const interceptingClient = createClient({ @@ -1683,8 +1685,9 @@ describe('tempo', () => { }) expect(authResponse.status).toBe(200) - expect(callRequests.length).toBeGreaterThan(0) - const simRequest = callRequests[0] + expect(callRequests).toHaveLength(2) + expect(callRequests[0]).not.toHaveProperty('feePayer') + const simRequest = callRequests[1] // The co-signed envelope names a concrete sponsor as fee payer. The // pre-cosign 0x78 instead carries `feePayer: true`; asserting the address // proves we simulate the transaction the sponsor actually broadcasts. @@ -1772,6 +1775,77 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: fee payer does not broadcast when final simulation reverts', async () => { + const rpcMethods: string[] = [] + let simulations = 0 + const interceptingClient = createClient({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + rpcMethods.push(args.method) + if (args.method === 'eth_call' && ++simulations >= 2) + throw new Error('execution reverted: final simulation fixture') + return client.transport.request(args) + }, + }), + }) + + const serverWithRevert = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return interceptingClient + }, + currency: asset, + account: accounts[0], + }), + ], + realm, + secretKey, + }) + + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + getClient() { + return client + }, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + serverWithRevert.charge({ + feePayer: accounts[0], + amount: '1', + currency: asset, + recipient: accounts[0].address, + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const challengeResponse = await fetch(httpServer.url) + const credential = await mppx.createCredential(challengeResponse) + rpcMethods.length = 0 + + const authResponse = await fetch(httpServer.url, { + headers: { Authorization: credential }, + }) + + expect(authResponse.status).not.toBe(200) + expect(simulations).toBeGreaterThanOrEqual(2) + expect(rpcMethods).not.toContain('eth_sendRawTransactionSync') + expect(rpcMethods).not.toContain('eth_sendRawTransaction') + + httpServer.close() + }) + test('behavior: fee payer fails closed when simulation reverts (optimistic mode)', async () => { const rpcMethods: string[] = [] const interceptingClient = createClient({ @@ -2056,11 +2130,30 @@ describe('tempo', () => { test('behavior: fee payer URL (withFeePayer transport)', async () => { const feePayerRequests: any[] = [] + const sequence: string[] = [] + let rejectFinalSimulation = false + let simulations = 0 + const interceptingClient = createClient({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + if (args.method === 'eth_call' && args.params?.[0]?.calls) { + sequence.push('simulate') + if (rejectFinalSimulation && ++simulations >= 2) + throw new Error('hosted final simulation reverted') + } + if (args.method === 'eth_sendRawTransactionSync') sequence.push('broadcast') + return client.transport.request(args) + }, + }), + }) const feePayerServer = await Http.createServer(async (req, res) => { let requestBody = '' for await (const chunk of req) requestBody += chunk const request = JSON.parse(requestBody) feePayerRequests.push(request) + sequence.push('complete') const transaction = request.params[0] const quantity = (value: unknown) => @@ -2118,7 +2211,7 @@ describe('tempo', () => { methods: [ tempo_server.charge({ feePayer: feePayerServer.url, - getClient: () => client, + getClient: () => interceptingClient, currency: asset, }), ], @@ -2151,6 +2244,7 @@ describe('tempo', () => { const response = await mppx.fetch(httpServer.url) expect(response.status).toBe(200) expect(feePayerRequests.map(({ method }) => method)).toEqual(['eth_fillTransaction']) + expect(sequence).toEqual(['simulate', 'complete', 'simulate', 'broadcast']) const receipt = Receipt.fromResponse(response) expect(receipt.status).toBe('success') @@ -2160,6 +2254,16 @@ describe('tempo', () => { }) expect((txReceipt as any).feePayer).toBe(accounts[0].address.toLowerCase()) + sequence.length = 0 + simulations = 0 + rejectFinalSimulation = true + const rejected = await mppx.fetch(httpServer.url) + + expect(rejected.status).not.toBe(200) + expect(feePayerRequests).toHaveLength(2) + expect(sequence.slice(0, 2)).toEqual(['simulate', 'complete']) + expect(sequence).not.toContain('broadcast') + httpServer.close() feePayerServer.close() }) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index c9508709..94be776a 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -411,75 +411,66 @@ export function charge( if (isFeePayerTx) FeePayer.assertAllowedFeeToken(transaction, allowedFeeTokens) const selectableFeeTokens = allowedFeeTokens as readonly `0x${string}`[] - // Request for the pre-broadcast simulation; for sponsored payments - // this is overwritten below with the co-signed shape. - let simulationRequest: Record = FeePayer.simulationTransaction( - transaction, - { feePayer: isFeePayerTx }, - ) - const serializedTransaction_final = await (async () => { if (feePayerAccount && methodDetails?.feePayer !== false) { - const feeToken = - configuredFeeToken ?? - (await resolveFeeToken({ - account: feePayerAccount.address, - allowedTokens: selectableFeeTokens, - candidateTokens: selectableFeeTokens, - client, - prioritizeCandidates: true, - })) - const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayerAccount, - allowedFeeTokens, - challengeExpires: expires, - chainId: chainId ?? client.chain!.id, - details: { amount, currency, recipient }, - policy: feePayerPolicy, - transaction: { - ...transaction, - ...(feeToken ? { feeToken } : {}), + const completed = await FeePayer.preflightSponsorship({ + transaction, + simulate: (request) => viem_call(client, request as never), + async complete() { + const feeToken = + configuredFeeToken ?? + (await resolveFeeToken({ + account: feePayerAccount.address, + allowedTokens: selectableFeeTokens, + candidateTokens: selectableFeeTokens, + client, + prioritizeCandidates: true, + })) + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayerAccount, + allowedFeeTokens, + challengeExpires: expires, + chainId: chainId ?? client.chain!.id, + details: { amount, currency, recipient }, + policy: feePayerPolicy, + transaction: { + ...transaction, + ...(feeToken ? { feeToken } : {}), + }, + }) + return { feePayer: feePayerAccount.address, transaction: sponsored } }, }) - // `account` is the sender (eth_call `from`); `feePayer` is the - // sponsor that pays gas. - simulationRequest = { - ...sponsored, - account: transaction.from, - feePayer: feePayerAccount.address, - feePayerSignature: undefined, - signature: undefined, - } - return signTransaction(client, sponsored as never) + return signTransaction(client, completed.transaction as never) } if (feePayerUrl && isFeePayerTx) { - const hosted = await FeePayer.fillHostedFeePayerTransaction({ - allowedFeeTokens, - challengeExpires: expires, - chainId: chainId ?? client.chain!.id, - details: { amount, currency, recipient }, - policy: feePayerPolicy, + const completed = await FeePayer.preflightSponsorship({ transaction, - url: feePayerUrl, + simulate: (request) => viem_call(client, request as never), + async complete() { + const hosted = await FeePayer.fillHostedFeePayerTransaction({ + allowedFeeTokens, + challengeExpires: expires, + chainId: chainId ?? client.chain!.id, + details: { amount, currency, recipient }, + policy: feePayerPolicy, + transaction, + url: feePayerUrl, + }) + return { ...hosted, transaction } + }, }) - // Simulate the co-signed envelope (concrete fee payer + chosen - // fee token), mirroring the local-sponsor path. - simulationRequest = { - ...transaction, - account: transaction.from, - feePayer: hosted.feePayer, - feePayerSignature: undefined, - feeToken: hosted.feeToken, - signature: undefined, - } - return hosted.serializedTransaction + return completed.serializedTransaction } return serializedTransaction })() - // Pre-broadcast simulation: fail closed before broadcast so the - // sponsor never pays gas for a transaction that would revert. - await viem_call(client, simulationRequest as never) + // Pre-broadcast simulation for non-sponsored transactions. + if (!isFeePayerTx) + await viem_call( + client, + FeePayer.simulationTransaction(transaction, { feePayer: false }) as never, + ) if (waitForConfirmation) { const receipt = await sendRawTransactionSync(client, { diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index 9a08aba8..7ea7c5ac 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -112,22 +112,76 @@ async function createCredential( }) } -function createBillingClient(hashes: readonly string[]) { +function createBillingClient( + hashes: readonly string[], + options: { account?: typeof rootAccount; failSimulation?: number | undefined } = {}, +) { + const callRequests: Record[] = [] const rpcMethods: string[] = [] let nextHash = 0 + let simulations = 0 const client = createClient({ + account: options.account, chain: { ...tempo_chain, id: chainId }, transport: custom({ - async request({ method }) { + async request({ method, params }) { rpcMethods.push(method) if (method === 'eth_chainId') return `0x${chainId.toString(16)}` - if (method === 'eth_call') return '0x' + if (method === 'eth_getTransactionCount') return '0x0' + if (method === 'eth_estimateGas') return '0x5208' + if (method === 'eth_maxPriorityFeePerGas') return '0x1' + if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' } + if (method === 'eth_call') { + callRequests.push((params as [Record])[0]) + if (options.failSimulation !== undefined && ++simulations >= options.failSimulation) + throw new Error('final sponsored simulation reverted') + return '0x' + } if (method === 'eth_sendRawTransaction') return hashes[nextHash++] ?? hashActivate throw new Error(`unexpected rpc method: ${method}`) }, }), }) - return { client, rpcMethods } + return { callRequests, client, rpcMethods } +} + +async function activateFeeSponsoredSubscription(options?: { failSimulation?: number | undefined }) { + const store = Store.memory() + const billing = createBillingClient([hashActivate], { + account: rootAccount, + ...options, + }) + const method = subscription({ + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + feePayer: rootAccount, + getClient: async () => billing.client, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + waitForConfirmation: false, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + const challenge = Challenge.fromResponse(challengeResult.challenge) + const accessKey = ( + challenge.request as ReturnType + ).methodDetails?.accessKey + if (!accessKey) throw new Error('expected generated access key') + const credential = await createCredential(challenge, rootAccount.address, accessKey) + const result = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + return { ...billing, result } } const confirmedBlockHash = `0x${'f'.repeat(64)}` as const @@ -234,6 +288,27 @@ function createConfirmingBillingClient(options?: { } describe('tempo.subscription', () => { + test('preflights sender and final envelopes for fee-sponsored activation', async () => { + const { callRequests, result, rpcMethods } = await activateFeeSponsoredSubscription() + + expect(result.status).toBe(200) + expect(callRequests).toHaveLength(2) + expect(callRequests[0]).not.toHaveProperty('feePayer') + expect(callRequests[1]?.feePayer).toBe(rootAccount.address) + expect(rpcMethods).toContain('eth_sendRawTransaction') + }) + + test('does not broadcast a fee-sponsored activation after final simulation failure', async () => { + const { callRequests, result, rpcMethods } = await activateFeeSponsoredSubscription({ + failSimulation: 2, + }) + + expect(result.status).not.toBe(200) + expect(callRequests.length).toBeGreaterThanOrEqual(2) + expect(rpcMethods).not.toContain('eth_sendRawTransaction') + expect(rpcMethods).not.toContain('eth_sendRawTransactionSync') + }) + test('stores an activated subscription and reuses it on later requests', async () => { const store = Store.memory() let activationCount = 0 diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index 4fe18103..9c1aa547 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -965,28 +965,36 @@ async function submitSubscriptionPayment(parameters: { const userTransaction = Transaction.deserialize( userSerialized as Transaction.TransactionSerializedTempo, ) - const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayer, - chainId: chainId ?? client.chain!.id, - details: { - amount: String(request.amount), - currency: String(request.currency), - recipient: String(request.recipient), + const completed = await FeePayer.preflightSponsorship({ + transaction: userTransaction, + simulate: (request) => viem_call(client, request as never), + async complete() { + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayer, + chainId: chainId ?? client.chain!.id, + details: { + amount: String(request.amount), + currency: String(request.currency), + recipient: String(request.recipient), + }, + ...(feePayerPolicy ? { policy: feePayerPolicy } : {}), + transaction: userTransaction as never, + }) + return { feePayer: feePayer.address, transaction: sponsored } }, - ...(feePayerPolicy ? { policy: feePayerPolicy } : {}), - transaction: userTransaction as never, }) - return await signTransaction(client, sponsored as never) + return await signTransaction(client, completed.transaction as never) })() const transaction = Transaction.deserialize( serializedTransaction as Transaction.TransactionSerializedTempo, ) - await viem_call(client, { - ...transaction, - account: transaction.from, - calls: transaction.calls, - feePayerSignature: undefined, - } as never) + if (!feePayer) + await viem_call(client, { + ...transaction, + account: transaction.from, + calls: transaction.calls, + feePayerSignature: undefined, + } as never) if (!waitForConfirmation) { // Optimistic mode has no receipt to inspect, so it cannot detect a T6 diff --git a/src/tempo/session/precompile/Chain.test.ts b/src/tempo/session/precompile/Chain.test.ts index 960a8725..77292179 100644 --- a/src/tempo/session/precompile/Chain.test.ts +++ b/src/tempo/session/precompile/Chain.test.ts @@ -611,6 +611,9 @@ describe('precompile broadcastOpenTransaction', () => { expect(broadcastIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeLessThan(broadcastIndex) + expect( + rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'), + ).toHaveLength(2) }) test('hosted fee-payer relays a sender-signed open without local co-signing', async () => { @@ -653,6 +656,9 @@ describe('precompile broadcastOpenTransaction', () => { expect(rpcMethods).toContain('eth_sendRawTransaction') expect(rpcMethods).not.toContain('eth_sendRawTransactionSync') + expect(rpcMethods.indexOf('eth_call')).toBeLessThan( + rpcMethods.indexOf('eth_sendRawTransaction'), + ) }) test('rejects expiring nonce hash mismatches before broadcasting', async () => { @@ -971,6 +977,9 @@ describe('precompile broadcastTopUpTransaction', () => { expect(broadcastIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeGreaterThan(-1) expect(simulationIndex).toBeLessThan(broadcastIndex) + expect( + rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'), + ).toHaveLength(2) }) test('rejects top-up calldata amount mismatches before broadcasting', async () => { diff --git a/src/tempo/session/precompile/Chain.ts b/src/tempo/session/precompile/Chain.ts index 928f4c27..5c3f4b19 100644 --- a/src/tempo/session/precompile/Chain.ts +++ b/src/tempo/session/precompile/Chain.ts @@ -434,15 +434,10 @@ function parsePrecompileCredentialTransaction(parameters: { return { transaction, call: { ...call, data: call.data, to: call.to } } } -async function simulateTempoTransaction(client: Client, transaction: Transaction.TransactionTempo) { +async function simulateTempoTransaction(client: Client, request: unknown) { // viem's public `call` type does not yet model Tempo's multi-call and // fee-payer fields together. Keep that compatibility cast in one place. - await call(client, { - ...transaction, - account: transaction.from, - calls: transaction.calls ?? [], - feePayerSignature: undefined, - } as never) + await call(client, request as never) } async function signTempoTransaction(client: Client, transaction: unknown): Promise { @@ -705,25 +700,38 @@ export async function sendCredentialTransaction(parameters: SendCredentialTransa assertSenderSigned(transaction) if (feePayer === true) { + // The transport owns hosted completion, so mppx can only preflight call + // execution as the sender. It intentionally omits fee fields here; the + // transport is responsible for validating its final sponsored envelope. + await simulateTempoTransaction( + client, + FeePayer.simulationTransaction(transaction, { feePayer: true }), + ) const txHash = await sendTransaction(client, serializedTransaction) return waitForSuccessfulReceipt(client, txHash) } - await simulateTempoTransaction(client, transaction) - - const sponsored = FeePayer.prepareSponsoredTransaction({ - account: feePayer, - allowedFeeTokens, - challengeExpires, - chainId, - details, - policy: feePayerPolicy, - transaction: { - ...transaction, - ...(allowedFeeTokens?.[0] ? { feeToken: transaction.feeToken ?? allowedFeeTokens[0] } : {}), + const sponsorshipTransaction = { + ...transaction, + ...(allowedFeeTokens?.[0] ? { feeToken: transaction.feeToken ?? allowedFeeTokens[0] } : {}), + } + const completed = await FeePayer.preflightSponsorship({ + transaction: sponsorshipTransaction, + simulate: (request) => simulateTempoTransaction(client, request), + async complete() { + const sponsored = FeePayer.prepareSponsoredTransaction({ + account: feePayer, + allowedFeeTokens, + challengeExpires, + chainId, + details, + policy: feePayerPolicy, + transaction: sponsorshipTransaction, + }) + return { feePayer: feePayer.address, transaction: sponsored } }, }) - const serialized = await signTempoTransaction(client, sponsored) + const serialized = await signTempoTransaction(client, completed.transaction) const receipt = await sendRawTransactionSync(client, { serializedTransaction: serialized as Transaction.TransactionSerializedTempo, })