diff --git a/packages/services/relayer/src/relayer/relayer.ts b/packages/services/relayer/src/relayer/relayer.ts index f68536820..9f648004a 100644 --- a/packages/services/relayer/src/relayer/relayer.ts +++ b/packages/services/relayer/src/relayer/relayer.ts @@ -18,6 +18,7 @@ export interface Relayer { chainId: number, to: Address.Address, calls: Payload.Call[], + data?: Hex.Hex, ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> relay(to: Address.Address, data: Hex.Hex, chainId: number, quote?: FeeQuote): Promise<{ opHash: Hex.Hex }> diff --git a/packages/services/relayer/src/relayer/rpc-relayer/index.ts b/packages/services/relayer/src/relayer/rpc-relayer/index.ts index e045b996e..814d25bbd 100644 --- a/packages/services/relayer/src/relayer/rpc-relayer/index.ts +++ b/packages/services/relayer/src/relayer/rpc-relayer/index.ts @@ -149,22 +149,24 @@ export class RpcRelayer implements Relayer { chainId: number, to: Address.Address, calls: Payload.Call[], + data?: Hex.Hex, ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { // IMPORTANT: // The relayer FeeOptions endpoint simulates `eth_call(to, data)`. - // wallet-webapp-v3 requests FeeOptions with `to = wallet` and `data = Payload.encode(calls, self=wallet)`. - // This works for undeployed wallets and avoids guest-module simulation pitfalls. - const callsStruct: Payload.Calls = { type: 'call', space: 0n, nonce: 0n, calls: calls } + // Callers that already built a wallet transaction should pass its `to` and `data`. + // This is required for undeployed wallets because the transaction must target the + // guest module and include the deploy call before executing from the wallet. + const callsStruct: Payload.Calls = { type: 'call', space: 0n, nonce: 0n, calls } - const feeOptionsTo = wallet - const data = Payload.encode(callsStruct, wallet) + const feeOptionsTo = to + const feeOptionsData = data ?? Hex.fromBytes(Payload.encode(callsStruct, to)) try { const result = await this.client.feeOptions( { wallet, to: feeOptionsTo, - data: Hex.fromBytes(data), + data: feeOptionsData, }, { ...(this.projectAccessKey ? { 'X-Access-Key': this.projectAccessKey } : undefined) }, ) diff --git a/packages/services/relayer/src/relayer/standard/sequence.ts b/packages/services/relayer/src/relayer/standard/sequence.ts index bb55a9726..1ae5ec69b 100644 --- a/packages/services/relayer/src/relayer/standard/sequence.ts +++ b/packages/services/relayer/src/relayer/standard/sequence.ts @@ -38,11 +38,12 @@ export class SequenceRelayer implements Relayer { _chainId: number, to: Address.Address, calls: Payload.Call[], + transactionData?: Hex.Hex, ): Promise<{ options: FeeOption[]; quote?: FeeQuote }> { const execute = AbiFunction.from('function execute(bytes calldata _payload, bytes calldata _signature)') const payload = Payload.encode({ type: 'call', space: 0n, nonce: 0n, calls }, to) const signature = '0x0001' // TODO: use a stub signature - const data = AbiFunction.encodeData(execute, [Bytes.toHex(payload), signature]) + const data = transactionData ?? AbiFunction.encodeData(execute, [Bytes.toHex(payload), signature]) const { options, quote } = await this.service.feeOptions({ wallet, to, data }) diff --git a/packages/services/relayer/test/relayer/relayer.test.ts b/packages/services/relayer/test/relayer/relayer.test.ts index 716cd11d6..028ccf32f 100644 --- a/packages/services/relayer/test/relayer/relayer.test.ts +++ b/packages/services/relayer/test/relayer/relayer.test.ts @@ -91,13 +91,11 @@ describe('Relayer', () => { }) it('should return false for non-objects', () => { - // These will throw due to the 'in' operator, so we need to test the actual behavior - expect(() => Relayer.isRelayer(null)).toThrow() - expect(() => Relayer.isRelayer(undefined)).toThrow() - expect(() => Relayer.isRelayer('string')).toThrow() - expect(() => Relayer.isRelayer(123)).toThrow() - expect(() => Relayer.isRelayer(true)).toThrow() - // Arrays and objects should not throw, but should return false + expect(Relayer.isRelayer(null)).toBe(false) + expect(Relayer.isRelayer(undefined)).toBe(false) + expect(Relayer.isRelayer('string')).toBe(false) + expect(Relayer.isRelayer(123)).toBe(false) + expect(Relayer.isRelayer(true)).toBe(false) expect(Relayer.isRelayer([])).toBe(false) }) @@ -324,6 +322,61 @@ describe('Relayer', () => { }) }) + describe('RpcRelayer.feeOptions', () => { + const mockCall: Payload.Call = { + to: TEST_TO_ADDRESS, + value: 0n, + data: TEST_DATA, + gasLimit: 21000n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + const makeRelayer = () => { + const requests: Array<{ input: RequestInfo; init?: RequestInit }> = [] + const fetchImpl = vi.fn(async (input: RequestInfo, init?: RequestInit) => { + requests.push({ input, init }) + return new Response(JSON.stringify({ options: [], sponsored: false }), { status: 200 }) + }) + + return { + relayer: new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl), + requests, + } + } + + it('should send provided transaction target and data when available', async () => { + const { relayer, requests } = makeRelayer() + + await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall], TEST_DATA) + + expect(requests).toHaveLength(1) + expect(requests[0]!.input).toBe('https://relayer.test/rpc/Relayer/FeeOptions') + expect(JSON.parse(requests[0]!.init!.body as string)).toEqual({ + wallet: TEST_WALLET_ADDRESS, + to: TEST_TO_ADDRESS, + data: TEST_DATA, + }) + }) + + it('should encode calls for the provided target when transaction data is not provided', async () => { + const { relayer, requests } = makeRelayer() + + await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall]) + + const expectedData = Hex.fromBytes( + Payload.encode({ type: 'call', space: 0n, nonce: 0n, calls: [mockCall] }, TEST_TO_ADDRESS), + ) + + expect(JSON.parse(requests[0]!.init!.body as string)).toEqual({ + wallet: TEST_WALLET_ADDRESS, + to: TEST_TO_ADDRESS, + data: expectedData, + }) + }) + }) + describe('Type compatibility', () => { it('should work with Address and Hex types from ox', () => { // Test that the interfaces work correctly with ox types diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index d89d5b2b9..cdfdf66c2 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -494,6 +494,56 @@ export class Wallet { } } + async buildFeeOptionsTransaction( + provider: Provider.Provider, + payload: Payload.Calls, + ): Promise<{ to: Address.Address; data: Hex.Hex }> { + const status = await this.getStatus(provider) + const signature = '0x0001' as Hex.Hex + + const executeData = AbiFunction.encodeData(Constants.EXECUTE, [Bytes.toHex(Payload.encode(payload)), signature]) + + if (status.isDeployed) { + return { + to: this.address, + data: executeData, + } + } + + const deploy = await this.buildDeployTransaction() + + return { + to: this.guest, + data: Bytes.toHex( + Payload.encode({ + type: 'call', + space: 0n, + nonce: 0n, + calls: [ + { + to: deploy.to, + value: 0n, + data: deploy.data, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + { + to: this.address, + value: 0n, + data: executeData, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + }, + ], + }), + ), + } + } + async buildTransaction(provider: Provider.Provider, envelope: Envelope.Signed) { const status = await this.getStatus(provider) diff --git a/packages/wallet/core/test/wallet-fee-options.test.ts b/packages/wallet/core/test/wallet-fee-options.test.ts new file mode 100644 index 000000000..d3e4ef30f --- /dev/null +++ b/packages/wallet/core/test/wallet-fee-options.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { AbiFunction, Address, Bytes, Hex, Provider } from 'ox' + +import { Constants, Config, Context, Payload } from '../../primitives/src/index.js' +import { State, Wallet } from '../src/index.js' + +const SIGNER = '0x1234567890123456789012345678901234567890' as Address.Address +const TARGET = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address.Address + +const configuration: Config.Config = { + threshold: 1n, + checkpoint: 0n, + topology: { type: 'signer', address: SIGNER, weight: 1n }, +} + +const call: Payload.Call = { + to: TARGET, + value: 0n, + data: '0x', + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', +} + +const payload: Payload.Calls = { + type: 'call', + space: 0n, + nonce: 0n, + calls: [call], +} + +function providerFor(options: { deployed: boolean; imageHash: Hex.Hex }): Provider.Provider { + return { + request: vi.fn(async (request: { method: string; params?: unknown[] }) => { + switch (request.method) { + case 'eth_chainId': + return '0x1' + + case 'eth_getCode': + return options.deployed ? '0x1234' : '0x' + + case 'eth_call': { + const rpcCall = request.params?.[0] as { data?: Hex.Hex } | undefined + + if (rpcCall?.data === AbiFunction.encodeData(Constants.GET_IMPLEMENTATION)) { + return options.deployed ? Hex.padLeft(Context.Dev2.stage2, 32) : '0x' + } + + if (rpcCall?.data === AbiFunction.encodeData(Constants.IMAGE_HASH)) { + return options.imageHash + } + + return '0x' + } + + default: + throw new Error(`Unexpected RPC method: ${request.method}`) + } + }), + } as unknown as Provider.Provider +} + +async function createWallet() { + const stateProvider = new State.Local.Provider() + const wallet = await Wallet.fromConfiguration(configuration, { stateProvider, context: Context.Dev2 }) + const imageHash = Hex.from(Config.hashConfiguration(configuration)) + + return { wallet, imageHash } +} + +describe('Wallet.buildFeeOptionsTransaction', () => { + it('targets the wallet execute method when the wallet is deployed', async () => { + const { wallet, imageHash } = await createWallet() + const transaction = await wallet.buildFeeOptionsTransaction(providerFor({ deployed: true, imageHash }), payload) + + const expectedData = AbiFunction.encodeData(Constants.EXECUTE, [Bytes.toHex(Payload.encode(payload)), '0x0001']) + + expect(Address.isEqual(transaction.to, wallet.address)).toBe(true) + expect(transaction.data).toBe(expectedData) + }) + + it('targets the guest module and prefixes deployment when the wallet is undeployed', async () => { + const { wallet, imageHash } = await createWallet() + const deploy = await wallet.buildDeployTransaction() + const transaction = await wallet.buildFeeOptionsTransaction(providerFor({ deployed: false, imageHash }), payload) + const decoded = Payload.decode(Bytes.fromHex(transaction.data)) + + const expectedExecuteData = AbiFunction.encodeData(Constants.EXECUTE, [ + Bytes.toHex(Payload.encode(payload)), + '0x0001', + ]) + + expect(Address.isEqual(transaction.to, Constants.DefaultGuestAddress)).toBe(true) + expect(decoded.calls).toHaveLength(2) + expect(Address.isEqual(decoded.calls[0]!.to, deploy.to)).toBe(true) + expect(decoded.calls[0]!.data).toBe(deploy.data) + expect(Address.isEqual(decoded.calls[1]!.to, wallet.address)).toBe(true) + expect(decoded.calls[1]!.data).toBe(expectedExecuteData) + }) +}) diff --git a/packages/wallet/dapp-client/src/ChainSessionManager.ts b/packages/wallet/dapp-client/src/ChainSessionManager.ts index a57e2c232..dc1f30e23 100644 --- a/packages/wallet/dapp-client/src/ChainSessionManager.ts +++ b/packages/wallet/dapp-client/src/ChainSessionManager.ts @@ -865,7 +865,13 @@ export class ChainSessionManager { } const walletAddress = this.walletAddress if (!walletAddress) throw new InitializationError('Wallet is not initialized.') - const feeOptions = await this.relayer.feeOptions(walletAddress, this.chainId, signedCall.to, callsToSend) + const feeOptions = await this.relayer.feeOptions( + walletAddress, + this.chainId, + signedCall.to, + callsToSend, + signedCall.data, + ) return feeOptions.options } catch (err) { throw new FeeOptionError(`Failed to get fee options: ${err instanceof Error ? err.message : String(err)}`) diff --git a/packages/wallet/wdk/src/sequence/transactions.ts b/packages/wallet/wdk/src/sequence/transactions.ts index 26bf21d34..146d66999 100644 --- a/packages/wallet/wdk/src/sequence/transactions.ts +++ b/packages/wallet/wdk/src/sequence/transactions.ts @@ -310,22 +310,28 @@ export class Transactions implements TransactionsInterface { throw new Error(`Transaction ${transactionId} is not in the requested state`) } + if (!Payload.isCalls(tx.envelope.payload)) { + throw new Error(`Transaction ${transactionId} is not a calls payload`) + } + + const payload = tx.envelope.payload + // Modify the envelope with the changes if (changes?.nonce) { - tx.envelope.payload.nonce = changes.nonce + payload.nonce = changes.nonce } if (changes?.space) { - tx.envelope.payload.space = changes.space + payload.space = changes.space } if (changes?.calls) { - if (changes.calls.length !== tx.envelope.payload.calls.length) { + if (changes.calls.length !== payload.calls.length) { throw new Error(`Invalid number of calls for transaction ${transactionId}`) } for (let i = 0; i < changes.calls.length; i++) { - tx.envelope.payload.calls[i]!.gasLimit = changes.calls[i]!.gasLimit + payload.calls[i]!.gasLimit = changes.calls[i]!.gasLimit } } @@ -335,6 +341,7 @@ export class Transactions implements TransactionsInterface { throw new Error(`Network not found for ${tx.envelope.chainId}`) } const provider = Provider.from(RpcTransport.fromHttp(network.rpcUrl)) + const feeOptionsTransaction = await wallet.buildFeeOptionsTransaction(provider, payload) // Get relayer and relayer options const [allRelayerOptions, allBundlerOptions] = await Promise.all([ @@ -347,11 +354,13 @@ export class Transactions implements TransactionsInterface { return [] } - // Determine the to address for the built transaction - const walletStatus = await wallet.getStatus(provider) - const to = walletStatus.isDeployed ? wallet.address : wallet.guest - - const feeOptions = await relayer.feeOptions(tx.wallet, tx.envelope.chainId, to, tx.envelope.payload.calls) + const feeOptions = await relayer.feeOptions( + tx.wallet, + tx.envelope.chainId, + feeOptionsTransaction.to, + payload.calls, + feeOptionsTransaction.data, + ) if (feeOptions.options.length === 0) { const { name, icon } = relayer instanceof Relayer.EIP6963.EIP6963Relayer ? relayer.info : {} @@ -392,8 +401,8 @@ export class Transactions implements TransactionsInterface { } try { - const erc4337Op = await wallet.prepare4337Transaction(provider, tx.envelope.payload.calls, { - space: tx.envelope.payload.space, + const erc4337Op = await wallet.prepare4337Transaction(provider, payload.calls, { + space: payload.space, }) const erc4337OpsWithEstimatedLimits = await bundler.estimateLimits(tx.wallet, erc4337Op.payload)