diff --git a/.changeset/fee-options-stub-pending-updates.md b/.changeset/fee-options-stub-pending-updates.md new file mode 100644 index 0000000000..16c697abef --- /dev/null +++ b/.changeset/fee-options-stub-pending-updates.md @@ -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`. diff --git a/packages/wallet/core/src/wallet.ts b/packages/wallet/core/src/wallet.ts index 66b0eb52b5..7c6c190ded 100644 --- a/packages/wallet/core/src/wallet.ts +++ b/packages/wallet/core/src/wallet.ts @@ -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({ @@ -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), + }, + })), }), ) } diff --git a/packages/wallet/core/test/wallet-fee-options.test.ts b/packages/wallet/core/test/wallet-fee-options.test.ts index 633f476727..f8f85ca695 100644 --- a/packages/wallet/core/test/wallet-fee-options.test.ts +++ b/packages/wallet/core/test/wallet-fee-options.test.ts @@ -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 @@ -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)) + }) })