Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wise-lobsters-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added sender and final-envelope simulations before sponsored Tempo broadcasts.
93 changes: 93 additions & 0 deletions src/tempo/internal/fee-payer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
defaultAllowedFeeTokens,
FeePayerValidationError,
fillHostedFeePayerTransaction,
preflightSponsorship,
prepareSponsoredTransaction,
simulationTransaction,
sponsoredSimulationTransaction,
validateCalls,
} from './fee-payer.js'
import * as Selectors from './selectors.js'
Expand Down Expand Up @@ -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<string, unknown>[] = []
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', () => {
Expand Down
66 changes: 64 additions & 2 deletions src/tempo/internal/fee-payer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export async function fillHostedFeePayerTransaction(parameters: {
...(filled.feePayerSignature as Record<string, unknown>),
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`).
Expand Down Expand Up @@ -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 },
Expand All @@ -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<sponsorship extends PreflightSponsorship>(parameters: {
complete: () => Promise<sponsorship>
simulate: (request: Record<string, unknown>) => Promise<unknown>
transaction: SponsoredTransaction
}): Promise<sponsorship> {
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.
Expand Down
80 changes: 78 additions & 2 deletions src/tempo/legacy/session/Chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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

Expand All @@ -633,7 +660,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
})

const result = await broadcastOpenTransaction({
client,
client: interceptingClient,
serializedTransaction,
escrowContract,
channelId,
Expand All @@ -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)
})
})

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading