From 02e16cba9e073a576dc8543983e1928c2b7b886f Mon Sep 17 00:00:00 2001 From: steven Date: Wed, 10 Jun 2026 23:37:07 -0600 Subject: [PATCH] feat: simulate co-signed transaction before sponsored broadcast --- .changeset/sponsor-presimulate-cosigned.md | 5 + src/tempo/server/Charge.test.ts | 222 +++++++++++++++++++++ src/tempo/server/Charge.ts | 40 ++-- 3 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 .changeset/sponsor-presimulate-cosigned.md diff --git a/.changeset/sponsor-presimulate-cosigned.md b/.changeset/sponsor-presimulate-cosigned.md new file mode 100644 index 00000000..4b879f47 --- /dev/null +++ b/.changeset/sponsor-presimulate-cosigned.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +The Tempo fee-payer (sponsor) pre-broadcast simulation now simulates the co-signed transaction the sponsor actually broadcasts — with the concrete fee payer and chosen fee token — instead of the pre-cosign `0x78` envelope. This catches reverts in the exact transaction the sponsor pays gas for, and fails closed (no broadcast) when the simulation reverts. diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 83272666..48269a13 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -1604,6 +1604,228 @@ 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 + // (concrete sponsor fee payer), not the pre-cosign 0x78 (`feePayer: true`). + const callRequests: any[] = [] + const interceptingClient = createClient({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + if (args.method === 'eth_call') callRequests.push(args.params?.[0]) + return client.transport.request(args) + }, + }), + }) + + const serverWithTrace = 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( + serverWithTrace.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) + callRequests.length = 0 + + const authResponse = await fetch(httpServer.url, { + headers: { Authorization: credential }, + }) + expect(authResponse.status).toBe(200) + + expect(callRequests.length).toBeGreaterThan(0) + const simRequest = callRequests[0] + // 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. + expect(simRequest.feePayer).not.toBe(true) + expect(typeof simRequest.feePayer).toBe('string') + expect((simRequest.feePayer as string).toLowerCase()).toBe(accounts[0].address.toLowerCase()) + // Execution runs as the sender, not the sponsor. + expect((simRequest.from as string).toLowerCase()).toBe(accounts[1].address.toLowerCase()) + expect(simRequest.calls?.length).toBeGreaterThan(0) + + httpServer.close() + }) + + test('behavior: fee payer fails closed when pre-broadcast simulation reverts', async () => { + // A reverting pre-broadcast simulation must abort before broadcast so the + // sponsor never pays gas for a transaction that would revert. + const rpcMethods: string[] = [] + const interceptingClient = createClient({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + rpcMethods.push(args.method) + if (args.method === 'eth_call') + throw new Error('execution reverted: 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 }, + }) + + // Fails closed: not successful, and the transaction is never broadcast. + expect(authResponse.status).not.toBe(200) + expect(rpcMethods).toContain('eth_call') + 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({ + account: accounts[0], + chain: client.chain, + transport: custom({ + async request(args: any) { + rpcMethods.push(args.method) + if (args.method === 'eth_call') + throw new Error('execution reverted: simulation fixture') + return client.transport.request(args) + }, + }), + }) + + const serverNoWait = Mppx_server.create({ + methods: [ + tempo_server.charge({ + getClient() { + return interceptingClient + }, + currency: asset, + account: accounts[0], + waitForConfirmation: false, + }), + ], + 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( + serverNoWait.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(rpcMethods).toContain('eth_call') + expect(rpcMethods).not.toContain('eth_sendRawTransaction') + expect(rpcMethods).not.toContain('eth_sendRawTransactionSync') + + httpServer.close() + }) + test('behavior: fee payer rejects concurrent in-flight transactions from one sender', async () => { let releaseSimulation!: () => void let resolveSimulationStarted!: () => void diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index f1ef2113..88dbf944 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -403,6 +403,15 @@ export function charge( const expectedFeeToken = defaults.currency[chainId as keyof typeof defaults.currency] const resolvedFeeToken = transaction.feeToken ?? expectedFeeToken + // Request for the pre-broadcast simulation; for sponsored payments + // this is overwritten below with the co-signed shape. + let simulationRequest: Record = { + ...transaction, + account: transaction.from, + calls: transaction.calls, + feePayerSignature: undefined, + } + const serializedTransaction_final = await (async () => { if (feePayerAccount && methodDetails?.feePayer !== false) { const sponsored = FeePayer.prepareSponsoredTransaction({ @@ -417,18 +426,25 @@ export function charge( ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}), }, }) + // `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 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) + if (waitForConfirmation) { - await viem_call(client, { - ...transaction, - account: transaction.from, - calls: transaction.calls, - feePayerSignature: undefined, - } as never) const receipt = await sendRawTransactionSync(client, { serializedTransaction: serializedTransaction_final, }) @@ -458,15 +474,9 @@ export function charge( return toReceipt(receipt) } - // Optimistic path: simulate to catch obvious reverts, then broadcast - // without waiting for on-chain confirmation. The returned receipt - // assumes success — callers opt into this risk via waitForConfirmation: false. - await viem_call(client, { - ...transaction, - account: transaction.from, - calls: transaction.calls, - feePayerSignature: undefined, - } as never) + // Optimistic path: broadcast without waiting for confirmation + // (simulation above already ran). The returned receipt assumes + // success — callers opt in via waitForConfirmation: false. const reference = await sendRawTransaction(client, { serializedTransaction: serializedTransaction_final, })