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/fee-options-stub-pending-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0xsequence/wallet-core': patch
---

Fix fee options simulation failing for wallets with pending config updates signed by sapient or ERC-1271 signers (e.g. passkeys). `buildFeeOptionsTransaction` now stubs the signatures of pending updates in the chained signature suffix, not only the current configuration, so the relayer no longer rejects `FeeOptions` with `InvalidPasskeySignature`.
39 changes: 38 additions & 1 deletion packages/wallet/core/src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,37 @@ function stubFeeOptionsTopology(topology: Config.Topology): SequenceSignature.Ra
return topology
}

// Pending config updates are chained after the stubbed signature. On-chain, each one is
// verified against a config-update payload built from the imageHash recovered from the
// previous link, which the stub makes meaningless. Real signatures in the suffix would then
// be checked against the wrong digest: ECDSA just recovers a wrong address, but sapient
// signers (e.g. passkeys) and ERC-1271 signers revert and abort the whole simulation.
// So every signed leaf in the suffix is stubbed too, keeping its weight.
function stubFeeOptionsSignedTopology(topology: SequenceSignature.RawTopology): SequenceSignature.RawTopology {
if (SequenceSignature.isRawNode(topology)) {
return [stubFeeOptionsSignedTopology(topology[0]), stubFeeOptionsSignedTopology(topology[1])]
}

if (SequenceSignature.isRawSignerLeaf(topology)) {
return {
type: 'unrecovered-signer',
weight: topology.weight,
signature: FeeOptionsStubSignature,
}
}

if (SequenceSignature.isRawNestedLeaf(topology)) {
return {
type: 'nested',
weight: topology.weight,
threshold: topology.threshold,
tree: stubFeeOptionsSignedTopology(topology.tree),
}
}

return topology
}

function buildFeeOptionsStubSignature(status: WalletStatusWithOnchain): Hex.Hex {
return Bytes.toHex(
SequenceSignature.encodeSignature({
Expand All @@ -65,7 +96,13 @@ function buildFeeOptionsStubSignature(status: WalletStatusWithOnchain): Hex.Hex
...status.configuration,
topology: stubFeeOptionsTopology(status.configuration.topology),
},
suffix: status.pendingUpdates.map(({ signature }) => signature),
suffix: status.pendingUpdates.map(({ signature }) => ({
...signature,
configuration: {
...signature.configuration,
topology: stubFeeOptionsSignedTopology(signature.configuration.topology),
},
})),
}),
)
}
Expand Down
118 changes: 117 additions & 1 deletion packages/wallet/core/test/wallet-fee-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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 { Constants, Config, Context, Payload, Signature } from '../../primitives/src/index.js'
import { State, Wallet } from '../src/index.js'

const SIGNER = '0x1234567890123456789012345678901234567890' as Address.Address
const PASSKEYS_MODULE = '0x0000000000005204f3711851ead52cc9c241499a' as Address.Address
const PASSKEY_SIGNATURE_DATA = '0xdeadbeefdeadbeefdeadbeef' as Hex.Hex
const TARGET = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address.Address
const FEE_OPTIONS_STUB_SIGNATURE =
'0x040001711fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' as Hex.Hex
Expand Down Expand Up @@ -103,4 +105,118 @@ describe('Wallet.buildFeeOptionsTransaction', () => {
expect(Address.isEqual(decoded.calls[1]!.to, wallet.address)).toBe(true)
expect(decoded.calls[1]!.data).toBe(expectedExecuteData)
})

it('stubs signatures in pending config updates so sapient signers cannot revert the simulation', async () => {
const stateProvider = new State.Local.Provider()
const wallet = await Wallet.fromConfiguration(configuration, { stateProvider, context: Context.Dev2 })
const imageHash = Hex.from(Config.hashConfiguration(configuration))

const updatedConfiguration: Config.Config = { ...configuration, checkpoint: 1n }
const updatedImageHash = Hex.from(Config.hashConfiguration(updatedConfiguration))
await stateProvider.saveConfiguration(updatedConfiguration)

// Pending update signed by a passkey (sapient) signer from the deployed configuration
const pendingSignature: Signature.RawSignature = {
noChainId: true,
configuration: {
threshold: 1n,
checkpoint: 0n,
topology: [
{
type: 'unrecovered-signer',
weight: 1n,
signature: { type: 'sapient_compact', address: PASSKEYS_MODULE, data: PASSKEY_SIGNATURE_DATA },
},
{ type: 'signer', address: SIGNER, weight: 1n },
],
},
}
vi.spyOn(stateProvider, 'getConfigurationUpdates').mockResolvedValue([
{ imageHash: updatedImageHash, signature: pendingSignature },
])

const transaction = await wallet.buildFeeOptionsTransaction(providerFor({ deployed: true, imageHash }), payload)
const [, signature] = AbiFunction.decodeData(Constants.EXECUTE, transaction.data)

const expectedSignature = Bytes.toHex(
Signature.encodeSignature({
noChainId: false,
configuration: {
...updatedConfiguration,
topology: {
type: 'unrecovered-signer',
weight: 1n,
signature: {
type: 'eth_sign',
r: 0x1fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn,
s: 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n,
yParity: 0,
},
},
},
suffix: [
{
...pendingSignature,
configuration: {
...pendingSignature.configuration,
topology: [
{
type: 'unrecovered-signer',
weight: 1n,
signature: {
type: 'eth_sign',
r: 0x1fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn,
s: 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n,
yParity: 0,
},
},
{ type: 'signer', address: SIGNER, weight: 1n },
],
},
},
],
}),
)

expect(signature).toBe(expectedSignature)
expect(signature).not.toContain(PASSKEY_SIGNATURE_DATA.slice(2))
})

it('stubs recovered sapient signer leaves returned by state providers for pending updates', async () => {
const stateProvider = new State.Local.Provider()
const wallet = await Wallet.fromConfiguration(configuration, { stateProvider, context: Context.Dev2 })
const imageHash = Hex.from(Config.hashConfiguration(configuration))

const updatedConfiguration: Config.Config = { ...configuration, checkpoint: 1n }
const updatedImageHash = Hex.from(Config.hashConfiguration(updatedConfiguration))
await stateProvider.saveConfiguration(updatedConfiguration)

// State providers return recovered leaves (e.g. Sequence provider recovers the config)
const pendingSignature = {
noChainId: true,
configuration: {
threshold: 1n,
checkpoint: 0n,
topology: [
{
type: 'sapient-signer',
address: PASSKEYS_MODULE,
imageHash: Hex.padLeft('0x01', 32),
weight: 1n,
signed: true,
signature: { type: 'sapient_compact', address: PASSKEYS_MODULE, data: PASSKEY_SIGNATURE_DATA },
},
{ type: 'signer', address: SIGNER, weight: 1n },
],
},
} as Signature.RawSignature
vi.spyOn(stateProvider, 'getConfigurationUpdates').mockResolvedValue([
{ imageHash: updatedImageHash, signature: pendingSignature },
])

const transaction = await wallet.buildFeeOptionsTransaction(providerFor({ deployed: true, imageHash }), payload)
const [, signature] = AbiFunction.decodeData(Constants.EXECUTE, transaction.data)

expect(signature).not.toContain(PASSKEY_SIGNATURE_DATA.slice(2))
})
})
Loading