Skip to content

Commit 1c23094

Browse files
authored
Fix fee options for undeployed wallets (#998)
1 parent 021207e commit 1c23094

8 files changed

Lines changed: 249 additions & 26 deletions

File tree

packages/services/relayer/src/relayer/relayer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface Relayer {
1818
chainId: number,
1919
to: Address.Address,
2020
calls: Payload.Call[],
21+
data?: Hex.Hex,
2122
): Promise<{ options: FeeOption[]; quote?: FeeQuote }>
2223

2324
relay(to: Address.Address, data: Hex.Hex, chainId: number, quote?: FeeQuote): Promise<{ opHash: Hex.Hex }>

packages/services/relayer/src/relayer/rpc-relayer/index.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,22 +149,24 @@ export class RpcRelayer implements Relayer {
149149
chainId: number,
150150
to: Address.Address,
151151
calls: Payload.Call[],
152+
data?: Hex.Hex,
152153
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
153154
// IMPORTANT:
154155
// The relayer FeeOptions endpoint simulates `eth_call(to, data)`.
155-
// wallet-webapp-v3 requests FeeOptions with `to = wallet` and `data = Payload.encode(calls, self=wallet)`.
156-
// This works for undeployed wallets and avoids guest-module simulation pitfalls.
157-
const callsStruct: Payload.Calls = { type: 'call', space: 0n, nonce: 0n, calls: calls }
156+
// Callers that already built a wallet transaction should pass its `to` and `data`.
157+
// This is required for undeployed wallets because the transaction must target the
158+
// guest module and include the deploy call before executing from the wallet.
159+
const callsStruct: Payload.Calls = { type: 'call', space: 0n, nonce: 0n, calls }
158160

159-
const feeOptionsTo = wallet
160-
const data = Payload.encode(callsStruct, wallet)
161+
const feeOptionsTo = to
162+
const feeOptionsData = data ?? Hex.fromBytes(Payload.encode(callsStruct, to))
161163

162164
try {
163165
const result = await this.client.feeOptions(
164166
{
165167
wallet,
166168
to: feeOptionsTo,
167-
data: Hex.fromBytes(data),
169+
data: feeOptionsData,
168170
},
169171
{ ...(this.projectAccessKey ? { 'X-Access-Key': this.projectAccessKey } : undefined) },
170172
)

packages/services/relayer/src/relayer/standard/sequence.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@ export class SequenceRelayer implements Relayer {
3838
_chainId: number,
3939
to: Address.Address,
4040
calls: Payload.Call[],
41+
transactionData?: Hex.Hex,
4142
): Promise<{ options: FeeOption[]; quote?: FeeQuote }> {
4243
const execute = AbiFunction.from('function execute(bytes calldata _payload, bytes calldata _signature)')
4344
const payload = Payload.encode({ type: 'call', space: 0n, nonce: 0n, calls }, to)
4445
const signature = '0x0001' // TODO: use a stub signature
45-
const data = AbiFunction.encodeData(execute, [Bytes.toHex(payload), signature])
46+
const data = transactionData ?? AbiFunction.encodeData(execute, [Bytes.toHex(payload), signature])
4647

4748
const { options, quote } = await this.service.feeOptions({ wallet, to, data })
4849

packages/services/relayer/test/relayer/relayer.test.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,11 @@ describe('Relayer', () => {
9191
})
9292

9393
it('should return false for non-objects', () => {
94-
// These will throw due to the 'in' operator, so we need to test the actual behavior
95-
expect(() => Relayer.isRelayer(null)).toThrow()
96-
expect(() => Relayer.isRelayer(undefined)).toThrow()
97-
expect(() => Relayer.isRelayer('string')).toThrow()
98-
expect(() => Relayer.isRelayer(123)).toThrow()
99-
expect(() => Relayer.isRelayer(true)).toThrow()
100-
// Arrays and objects should not throw, but should return false
94+
expect(Relayer.isRelayer(null)).toBe(false)
95+
expect(Relayer.isRelayer(undefined)).toBe(false)
96+
expect(Relayer.isRelayer('string')).toBe(false)
97+
expect(Relayer.isRelayer(123)).toBe(false)
98+
expect(Relayer.isRelayer(true)).toBe(false)
10199
expect(Relayer.isRelayer([])).toBe(false)
102100
})
103101

@@ -324,6 +322,61 @@ describe('Relayer', () => {
324322
})
325323
})
326324

325+
describe('RpcRelayer.feeOptions', () => {
326+
const mockCall: Payload.Call = {
327+
to: TEST_TO_ADDRESS,
328+
value: 0n,
329+
data: TEST_DATA,
330+
gasLimit: 21000n,
331+
delegateCall: false,
332+
onlyFallback: false,
333+
behaviorOnError: 'revert',
334+
}
335+
336+
const makeRelayer = () => {
337+
const requests: Array<{ input: RequestInfo; init?: RequestInit }> = []
338+
const fetchImpl = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
339+
requests.push({ input, init })
340+
return new Response(JSON.stringify({ options: [], sponsored: false }), { status: 200 })
341+
})
342+
343+
return {
344+
relayer: new Relayer.RpcRelayer('https://relayer.test', TEST_CHAIN_ID, 'https://rpc.test', fetchImpl),
345+
requests,
346+
}
347+
}
348+
349+
it('should send provided transaction target and data when available', async () => {
350+
const { relayer, requests } = makeRelayer()
351+
352+
await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall], TEST_DATA)
353+
354+
expect(requests).toHaveLength(1)
355+
expect(requests[0]!.input).toBe('https://relayer.test/rpc/Relayer/FeeOptions')
356+
expect(JSON.parse(requests[0]!.init!.body as string)).toEqual({
357+
wallet: TEST_WALLET_ADDRESS,
358+
to: TEST_TO_ADDRESS,
359+
data: TEST_DATA,
360+
})
361+
})
362+
363+
it('should encode calls for the provided target when transaction data is not provided', async () => {
364+
const { relayer, requests } = makeRelayer()
365+
366+
await relayer.feeOptions(TEST_WALLET_ADDRESS, TEST_CHAIN_ID, TEST_TO_ADDRESS, [mockCall])
367+
368+
const expectedData = Hex.fromBytes(
369+
Payload.encode({ type: 'call', space: 0n, nonce: 0n, calls: [mockCall] }, TEST_TO_ADDRESS),
370+
)
371+
372+
expect(JSON.parse(requests[0]!.init!.body as string)).toEqual({
373+
wallet: TEST_WALLET_ADDRESS,
374+
to: TEST_TO_ADDRESS,
375+
data: expectedData,
376+
})
377+
})
378+
})
379+
327380
describe('Type compatibility', () => {
328381
it('should work with Address and Hex types from ox', () => {
329382
// Test that the interfaces work correctly with ox types

packages/wallet/core/src/wallet.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,56 @@ export class Wallet {
494494
}
495495
}
496496

497+
async buildFeeOptionsTransaction(
498+
provider: Provider.Provider,
499+
payload: Payload.Calls,
500+
): Promise<{ to: Address.Address; data: Hex.Hex }> {
501+
const status = await this.getStatus(provider)
502+
const signature = '0x0001' as Hex.Hex
503+
504+
const executeData = AbiFunction.encodeData(Constants.EXECUTE, [Bytes.toHex(Payload.encode(payload)), signature])
505+
506+
if (status.isDeployed) {
507+
return {
508+
to: this.address,
509+
data: executeData,
510+
}
511+
}
512+
513+
const deploy = await this.buildDeployTransaction()
514+
515+
return {
516+
to: this.guest,
517+
data: Bytes.toHex(
518+
Payload.encode({
519+
type: 'call',
520+
space: 0n,
521+
nonce: 0n,
522+
calls: [
523+
{
524+
to: deploy.to,
525+
value: 0n,
526+
data: deploy.data,
527+
gasLimit: 0n,
528+
delegateCall: false,
529+
onlyFallback: false,
530+
behaviorOnError: 'revert',
531+
},
532+
{
533+
to: this.address,
534+
value: 0n,
535+
data: executeData,
536+
gasLimit: 0n,
537+
delegateCall: false,
538+
onlyFallback: false,
539+
behaviorOnError: 'revert',
540+
},
541+
],
542+
}),
543+
),
544+
}
545+
}
546+
497547
async buildTransaction(provider: Provider.Provider, envelope: Envelope.Signed<Payload.Calls>) {
498548
const status = await this.getStatus(provider)
499549

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { AbiFunction, Address, Bytes, Hex, Provider } from 'ox'
3+
4+
import { Constants, Config, Context, Payload } from '../../primitives/src/index.js'
5+
import { State, Wallet } from '../src/index.js'
6+
7+
const SIGNER = '0x1234567890123456789012345678901234567890' as Address.Address
8+
const TARGET = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address.Address
9+
10+
const configuration: Config.Config = {
11+
threshold: 1n,
12+
checkpoint: 0n,
13+
topology: { type: 'signer', address: SIGNER, weight: 1n },
14+
}
15+
16+
const call: Payload.Call = {
17+
to: TARGET,
18+
value: 0n,
19+
data: '0x',
20+
gasLimit: 0n,
21+
delegateCall: false,
22+
onlyFallback: false,
23+
behaviorOnError: 'revert',
24+
}
25+
26+
const payload: Payload.Calls = {
27+
type: 'call',
28+
space: 0n,
29+
nonce: 0n,
30+
calls: [call],
31+
}
32+
33+
function providerFor(options: { deployed: boolean; imageHash: Hex.Hex }): Provider.Provider {
34+
return {
35+
request: vi.fn(async (request: { method: string; params?: unknown[] }) => {
36+
switch (request.method) {
37+
case 'eth_chainId':
38+
return '0x1'
39+
40+
case 'eth_getCode':
41+
return options.deployed ? '0x1234' : '0x'
42+
43+
case 'eth_call': {
44+
const rpcCall = request.params?.[0] as { data?: Hex.Hex } | undefined
45+
46+
if (rpcCall?.data === AbiFunction.encodeData(Constants.GET_IMPLEMENTATION)) {
47+
return options.deployed ? Hex.padLeft(Context.Dev2.stage2, 32) : '0x'
48+
}
49+
50+
if (rpcCall?.data === AbiFunction.encodeData(Constants.IMAGE_HASH)) {
51+
return options.imageHash
52+
}
53+
54+
return '0x'
55+
}
56+
57+
default:
58+
throw new Error(`Unexpected RPC method: ${request.method}`)
59+
}
60+
}),
61+
} as unknown as Provider.Provider
62+
}
63+
64+
async function createWallet() {
65+
const stateProvider = new State.Local.Provider()
66+
const wallet = await Wallet.fromConfiguration(configuration, { stateProvider, context: Context.Dev2 })
67+
const imageHash = Hex.from(Config.hashConfiguration(configuration))
68+
69+
return { wallet, imageHash }
70+
}
71+
72+
describe('Wallet.buildFeeOptionsTransaction', () => {
73+
it('targets the wallet execute method when the wallet is deployed', async () => {
74+
const { wallet, imageHash } = await createWallet()
75+
const transaction = await wallet.buildFeeOptionsTransaction(providerFor({ deployed: true, imageHash }), payload)
76+
77+
const expectedData = AbiFunction.encodeData(Constants.EXECUTE, [Bytes.toHex(Payload.encode(payload)), '0x0001'])
78+
79+
expect(Address.isEqual(transaction.to, wallet.address)).toBe(true)
80+
expect(transaction.data).toBe(expectedData)
81+
})
82+
83+
it('targets the guest module and prefixes deployment when the wallet is undeployed', async () => {
84+
const { wallet, imageHash } = await createWallet()
85+
const deploy = await wallet.buildDeployTransaction()
86+
const transaction = await wallet.buildFeeOptionsTransaction(providerFor({ deployed: false, imageHash }), payload)
87+
const decoded = Payload.decode(Bytes.fromHex(transaction.data))
88+
89+
const expectedExecuteData = AbiFunction.encodeData(Constants.EXECUTE, [
90+
Bytes.toHex(Payload.encode(payload)),
91+
'0x0001',
92+
])
93+
94+
expect(Address.isEqual(transaction.to, Constants.DefaultGuestAddress)).toBe(true)
95+
expect(decoded.calls).toHaveLength(2)
96+
expect(Address.isEqual(decoded.calls[0]!.to, deploy.to)).toBe(true)
97+
expect(decoded.calls[0]!.data).toBe(deploy.data)
98+
expect(Address.isEqual(decoded.calls[1]!.to, wallet.address)).toBe(true)
99+
expect(decoded.calls[1]!.data).toBe(expectedExecuteData)
100+
})
101+
})

packages/wallet/dapp-client/src/ChainSessionManager.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,13 @@ export class ChainSessionManager {
865865
}
866866
const walletAddress = this.walletAddress
867867
if (!walletAddress) throw new InitializationError('Wallet is not initialized.')
868-
const feeOptions = await this.relayer.feeOptions(walletAddress, this.chainId, signedCall.to, callsToSend)
868+
const feeOptions = await this.relayer.feeOptions(
869+
walletAddress,
870+
this.chainId,
871+
signedCall.to,
872+
callsToSend,
873+
signedCall.data,
874+
)
869875
return feeOptions.options
870876
} catch (err) {
871877
throw new FeeOptionError(`Failed to get fee options: ${err instanceof Error ? err.message : String(err)}`)

packages/wallet/wdk/src/sequence/transactions.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,22 +310,28 @@ export class Transactions implements TransactionsInterface {
310310
throw new Error(`Transaction ${transactionId} is not in the requested state`)
311311
}
312312

313+
if (!Payload.isCalls(tx.envelope.payload)) {
314+
throw new Error(`Transaction ${transactionId} is not a calls payload`)
315+
}
316+
317+
const payload = tx.envelope.payload
318+
313319
// Modify the envelope with the changes
314320
if (changes?.nonce) {
315-
tx.envelope.payload.nonce = changes.nonce
321+
payload.nonce = changes.nonce
316322
}
317323

318324
if (changes?.space) {
319-
tx.envelope.payload.space = changes.space
325+
payload.space = changes.space
320326
}
321327

322328
if (changes?.calls) {
323-
if (changes.calls.length !== tx.envelope.payload.calls.length) {
329+
if (changes.calls.length !== payload.calls.length) {
324330
throw new Error(`Invalid number of calls for transaction ${transactionId}`)
325331
}
326332

327333
for (let i = 0; i < changes.calls.length; i++) {
328-
tx.envelope.payload.calls[i]!.gasLimit = changes.calls[i]!.gasLimit
334+
payload.calls[i]!.gasLimit = changes.calls[i]!.gasLimit
329335
}
330336
}
331337

@@ -335,6 +341,7 @@ export class Transactions implements TransactionsInterface {
335341
throw new Error(`Network not found for ${tx.envelope.chainId}`)
336342
}
337343
const provider = Provider.from(RpcTransport.fromHttp(network.rpcUrl))
344+
const feeOptionsTransaction = await wallet.buildFeeOptionsTransaction(provider, payload)
338345

339346
// Get relayer and relayer options
340347
const [allRelayerOptions, allBundlerOptions] = await Promise.all([
@@ -347,11 +354,13 @@ export class Transactions implements TransactionsInterface {
347354
return []
348355
}
349356

350-
// Determine the to address for the built transaction
351-
const walletStatus = await wallet.getStatus(provider)
352-
const to = walletStatus.isDeployed ? wallet.address : wallet.guest
353-
354-
const feeOptions = await relayer.feeOptions(tx.wallet, tx.envelope.chainId, to, tx.envelope.payload.calls)
357+
const feeOptions = await relayer.feeOptions(
358+
tx.wallet,
359+
tx.envelope.chainId,
360+
feeOptionsTransaction.to,
361+
payload.calls,
362+
feeOptionsTransaction.data,
363+
)
355364

356365
if (feeOptions.options.length === 0) {
357366
const { name, icon } = relayer instanceof Relayer.EIP6963.EIP6963Relayer ? relayer.info : {}
@@ -392,8 +401,8 @@ export class Transactions implements TransactionsInterface {
392401
}
393402

394403
try {
395-
const erc4337Op = await wallet.prepare4337Transaction(provider, tx.envelope.payload.calls, {
396-
space: tx.envelope.payload.space,
404+
const erc4337Op = await wallet.prepare4337Transaction(provider, payload.calls, {
405+
space: payload.space,
397406
})
398407

399408
const erc4337OpsWithEstimatedLimits = await bundler.estimateLimits(tx.wallet, erc4337Op.payload)

0 commit comments

Comments
 (0)