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
1 change: 1 addition & 0 deletions packages/services/relayer/src/relayer/relayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
Expand Down
14 changes: 8 additions & 6 deletions packages/services/relayer/src/relayer/rpc-relayer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
tolgahan-arikan marked this conversation as resolved.

try {
const result = await this.client.feeOptions(
{
wallet,
to: feeOptionsTo,
data: Hex.fromBytes(data),
data: feeOptionsData,
},
{ ...(this.projectAccessKey ? { 'X-Access-Key': this.projectAccessKey } : undefined) },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
67 changes: 60 additions & 7 deletions packages/services/relayer/test/relayer/relayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions packages/wallet/core/src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Payload.Calls>) {
const status = await this.getStatus(provider)

Expand Down
101 changes: 101 additions & 0 deletions packages/wallet/core/test/wallet-fee-options.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
8 changes: 7 additions & 1 deletion packages/wallet/dapp-client/src/ChainSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`)
Expand Down
31 changes: 20 additions & 11 deletions packages/wallet/wdk/src/sequence/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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([
Expand 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 : {}
Expand Down Expand Up @@ -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)
Expand Down
Loading