diff --git a/.changeset/safe-dynamic-owners.md b/.changeset/safe-dynamic-owners.md new file mode 100644 index 00000000..82e40048 --- /dev/null +++ b/.changeset/safe-dynamic-owners.md @@ -0,0 +1,5 @@ +--- +"permissionless": minor +--- + +Added support for dynamic (ERC-1271 contract) owners in toSafeSmartAccount. Owners can now be passed as `{ owner, dynamic: true }`, in which case their signatures are encoded as dynamic parts of the Safe signature bytes (contract signature format, signature type 0x00) and verified through EIP-1271 on the owner address. diff --git a/packages/permissionless/accounts/index.ts b/packages/permissionless/accounts/index.ts index a10c3817..365ac43d 100644 --- a/packages/permissionless/accounts/index.ts +++ b/packages/permissionless/accounts/index.ts @@ -35,6 +35,9 @@ export { } from "./etherspot/toEtherspotSmartAccount.js" export { + type DynamicOwner, + type RegularOwner, + type SafeOwner, type SafeSmartAccountImplementation, type SafeVersion, type ToSafeSmartAccountParameters, diff --git a/packages/permissionless/accounts/safe/index.ts b/packages/permissionless/accounts/safe/index.ts index 439bbca5..86ec00ff 100644 --- a/packages/permissionless/accounts/safe/index.ts +++ b/packages/permissionless/accounts/safe/index.ts @@ -7,6 +7,9 @@ export const SafeSmartAccount = { } export type { + DynamicOwner, + RegularOwner, + SafeOwner, SafeSmartAccountImplementation, SafeVersion, ToSafeSmartAccountParameters, diff --git a/packages/permissionless/accounts/safe/signUserOperation.test.ts b/packages/permissionless/accounts/safe/signUserOperation.test.ts index ef66cfba..e52e7ce4 100644 --- a/packages/permissionless/accounts/safe/signUserOperation.test.ts +++ b/packages/permissionless/accounts/safe/signUserOperation.test.ts @@ -1,3 +1,4 @@ +import { http, createTestClient, padHex, size, slice } from "viem" import { entryPoint06Address, entryPoint07Address @@ -12,9 +13,11 @@ import { describe, expect } from "vitest" import { testWithRpc } from "../../../permissionless-test/src/testWithRpc" import { getBundlerClient, + getPublicClient, getSafeClient } from "../../../permissionless-test/src/utils" import { signUserOperation } from "./signUserOperation" +import { toSafeSmartAccount } from "./toSafeSmartAccount" describe("signUserOperation", () => { testWithRpc("signUserOperation_V06", async ({ rpc }) => { @@ -185,6 +188,107 @@ describe("signUserOperation", () => { expect(receipt.success).toBeTruthy() }) + testWithRpc("signUserOperation_V07 with dynamic owner", async ({ rpc }) => { + const eoaOwner = privateKeyToAccount(generatePrivateKey()) + + // Contract owner validated through EIP-1271. The owner "signs" with a + // fixed 65-byte marker signature and the owner contract only returns + // the magic value when that marker arrives in the signature argument, + // proving the dynamic part is threaded through to isValidSignature. + const markerSignature = padHex("0xc0ffee", { dir: "right", size: 65 }) + + const erc1271OwnerAddress = privateKeyToAccount( + generatePrivateKey() + ).address + + const testClient = createTestClient({ + mode: "anvil", + transport: http(rpc.anvilRpc), + chain: foundry + }) + + // Minimal EIP-1271 validator. Safe v1.4.1 calls the legacy + // isValidSignature(bytes _data, bytes _signature) variant, so the + // calldata layout is: 4-byte selector, then the two head words with + // the offsets of the `_data` and `_signature` tails. The code loads + // the first 32 bytes of `_signature`, requires them to start with the + // 0xc0ffee marker, and only then returns the magic value 0x20c13b0b + // (the selector of the legacy variant); anything else reverts: + // + // PUSH1 0x24 CALLDATALOAD // offset of `_signature` tail + // PUSH1 0x24 ADD // + 4-byte selector + 32-byte length + // CALLDATALOAD // signature[0..32] + // PUSH1 0xe8 SHR // >> 232 bits: keep first 3 bytes + // PUSH3 0xc0ffee EQ // matches the marker? + // PUSH1 0x17 JUMPI // if so, jump to the return block + // PUSH1 0x00 PUSH1 0x00 REVERT + // JUMPDEST // 0x17 + // PUSH4 0x20c13b0b // legacy EIP-1271 magic value + // PUSH1 0xe0 SHL // left-align it as bytes4 + // PUSH1 0x00 MSTORE // store word at memory offset 0 + // PUSH1 0x20 PUSH1 0x00 RETURN // return that 32-byte word + await testClient.setCode({ + address: erc1271OwnerAddress, + bytecode: + "0x6024356024013560e81c62c0ffee1460175760006000fd5b6320c13b0b60e01b60005260206000f3" + }) + + // An owner that lives at the ERC-1271 contract address and produces + // the marker signature the contract expects. + const dynamicOwner = toAccount({ + address: erc1271OwnerAddress, + async signMessage() { + return markerSignature + }, + async signTypedData() { + return markerSignature + }, + async signTransaction() { + throw new Error("Not supported") + } + }) + + const account = await toSafeSmartAccount({ + client: getPublicClient(rpc.anvilRpc), + entryPoint: { + address: entryPoint07Address, + version: "0.7" + }, + owners: [eoaOwner, { owner: dynamicOwner, dynamic: true }], + version: "1.4.1", + saltNonce: 420n + }) + + const safeAccountClient = getBundlerClient({ + account, + entryPoint: { + version: "0.7" + }, + ...rpc + }) + + const stubSignature = await account.getStubSignature() + // 6 bytes validAfter + 6 bytes validUntil + 2 * 65 bytes static parts + // + 32 bytes dynamic length + 65 bytes dynamic signature data + expect(size(slice(stubSignature, 12))).toBe(130 + 32 + 65) + + const userOpHash = await safeAccountClient.sendUserOperation({ + calls: [ + { + to: account.address, + data: "0x" + } + ] + }) + + const receipt = await safeAccountClient.waitForUserOperationReceipt({ + hash: userOpHash + }) + + expect(receipt).toBeTruthy() + expect(receipt.success).toBeTruthy() + }) + testWithRpc("signUserOperation_V07 7579", async ({ rpc }) => { const owners = [ privateKeyToAccount(generatePrivateKey()), diff --git a/packages/permissionless/accounts/safe/signUserOperation.ts b/packages/permissionless/accounts/safe/signUserOperation.ts index 8907ff7c..507ac6fb 100644 --- a/packages/permissionless/accounts/safe/signUserOperation.ts +++ b/packages/permissionless/accounts/safe/signUserOperation.ts @@ -122,13 +122,34 @@ export async function signUserOperation( address: Address version: "0.6" | "0.7" } - owners: (Account | WebAuthnAccount)[] - account: OneOf< - | EthereumProvider - | WalletClient - | LocalAccount + owners: ( + | Account | WebAuthnAccount - > + | { owner: Account; dynamic?: boolean } + )[] + account: + | OneOf< + | EthereumProvider + | WalletClient + | LocalAccount + | WebAuthnAccount + > + | { + owner: OneOf< + | EthereumProvider + | WalletClient + | LocalAccount + | WebAuthnAccount + > + /** + * When true, the signature is encoded as a dynamic part of + * the Safe signature bytes (contract signature format, + * signature type 0x00), verified through EIP-1271 on the + * owner address. + * @default false + */ + dynamic?: boolean + } chainId: number signatures?: Hex validAfter?: number @@ -146,10 +167,14 @@ export async function signUserOperation( version, owners, signatures: existingSignatures, - account, + account: _account, ...userOperation } = parameters + const account = "owner" in _account ? _account.owner : _account + const isDynamicOwner = + "owner" in _account ? (_account.dynamic ?? false) : false + const { safe4337ModuleAddress } = getDefaultAddresses( version, entryPoint.version, @@ -258,7 +283,7 @@ export async function signUserOperation( ...unPackedSignatures, { signer, - dynamic: isWebAuthnAccount(localOwner), + dynamic: isWebAuthnAccount(localOwner) || isDynamicOwner, data: await (async () => { if (isWebAuthnAccount(localOwner)) { const safeHash = hashTypedData({ diff --git a/packages/permissionless/accounts/safe/toSafeSmartAccount.ts b/packages/permissionless/accounts/safe/toSafeSmartAccount.ts index 33c253c7..41ef2ed4 100644 --- a/packages/permissionless/accounts/safe/toSafeSmartAccount.ts +++ b/packages/permissionless/accounts/safe/toSafeSmartAccount.ts @@ -1153,11 +1153,24 @@ type GetErc7579Params = attestersThreshold?: number } -type RegularOwner = +export type RegularOwner = | Account | WalletClient | EthereumProvider +export type DynamicOwner = { + owner: RegularOwner + /** + * When true, the owner's signature is encoded as a dynamic part of the + * Safe signature bytes (contract signature format, signature type 0x00), + * verified through EIP-1271 on the owner address. + * @default false + */ + dynamic?: boolean +} + +export type SafeOwner = RegularOwner | WebAuthnAccount | DynamicOwner + type ValidateAtMostOneWebAuthn< T extends readonly unknown[], SeenWebAuthn extends boolean = false @@ -1168,12 +1181,12 @@ type ValidateAtMostOneWebAuthn< ? SeenWebAuthn extends true ? false : ValidateAtMostOneWebAuthn - : H extends RegularOwner + : H extends RegularOwner | DynamicOwner ? ValidateAtMostOneWebAuthn : false : true -type OwnersArray = +type OwnersArray = ValidateAtMostOneWebAuthn extends true ? T : never export type ToSafeSmartAccountParameters< @@ -1185,7 +1198,7 @@ export type ToSafeSmartAccountParameters< Chain | undefined, JsonRpcAccount | LocalAccount | undefined > - owners: OwnersArray + owners: OwnersArray threshold?: bigint version: SafeVersion entryPoint?: { @@ -1404,25 +1417,43 @@ export async function toSafeSmartAccount< useMultiSendForSetup = true } = parameters - const owners = await Promise.all( - _owners.map(async (owner) => { + // Unwrap `{ owner, dynamic }` entries, keeping track of which owners + // should produce dynamically encoded (contract) signatures. + const unwrappedOwners = _owners.map((owner) => { + if ("owner" in owner) { + return { + owner: owner.owner, + dynamic: owner.dynamic ?? false + } + } + + return { owner, dynamic: false } + }) + + const ownersWithDynamic = await Promise.all( + unwrappedOwners.map(async ({ owner, dynamic }) => { if ("account" in owner) { - return owner.account + return { owner: owner.account, dynamic } } if ("request" in owner) { - return toOwner({ - owner: owner as EthereumProvider - }) + return { + owner: await toOwner({ + owner: owner as EthereumProvider + }), + dynamic + } } - return owner + return { owner, dynamic } }) ) + const owners = ownersWithDynamic.map(({ owner }) => owner) + const localOwners = await Promise.all( - _owners - .filter((owner) => { + unwrappedOwners + .filter(({ owner }) => { if ("type" in owner && owner.type === "local") { return true } @@ -1442,18 +1473,25 @@ export async function toSafeSmartAccount< return false }) - .map((owner) => { + .map(async ({ owner, dynamic }) => { if (isWebAuthnAccount(owner)) { - return owner + return { owner, dynamic: true } } - return toOwner({ - owner: owner as OneOf< - | LocalAccount - | EthereumProvider - | WalletClient - > - }) + return { + owner: await toOwner({ + owner: owner as OneOf< + | LocalAccount + | EthereumProvider + | WalletClient< + Transport, + Chain | undefined, + Account + > + > + }), + dynamic + } }) ) @@ -1788,43 +1826,45 @@ export async function toSafeSmartAccount< }) }, async getStubSignature() { - const signatures = owners.map((owner) => { - let signer = safeWebAuthnSharedSignerAddress - let dynamic = true - let data: Hex = - "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c" + const signatures = ownersWithDynamic.map( + ({ owner, dynamic: isDynamicOwner }) => { + let signer = safeWebAuthnSharedSignerAddress + let dynamic = true + let data: Hex = + "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c" - if (isWebAuthnAccount(owner)) { - data = encodeAbiParameters( - [ - { name: "authenticatorData", type: "bytes" }, - { name: "clientDataJSON", type: "string" }, - { name: "signature", type: "uint256[2]" } - ], - [ - "0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97631d00000000", - '"origin":"http://somelargdomainheresothatwehaveenoughbytes.com","crossOrigin":false', + if (isWebAuthnAccount(owner)) { + data = encodeAbiParameters( [ - 44941127272049826721201904734628716258498742255959991581049806490182030242267n, - 9910254599581058084911561569808925251374718953855182016200087235935345969636n + { name: "authenticatorData", type: "bytes" }, + { name: "clientDataJSON", type: "string" }, + { name: "signature", type: "uint256[2]" } + ], + [ + "0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97631d00000000", + '"origin":"http://somelargdomainheresothatwehaveenoughbytes.com","crossOrigin":false', + [ + 44941127272049826721201904734628716258498742255959991581049806490182030242267n, + 9910254599581058084911561569808925251374718953855182016200087235935345969636n + ] ] - ] - ) - } else { - signer = owner.address - dynamic = false - } + ) + } else { + signer = owner.address + dynamic = isDynamicOwner + } - if (!signer) { - throw new Error("No signer found") - } + if (!signer) { + throw new Error("No signer found") + } - return { - signer, - data, - dynamic + return { + signer, + data, + dynamic + } } - }) + ) return encodePacked( ["uint48", "uint48", "bytes"], @@ -1860,39 +1900,44 @@ export async function toSafeSmartAccount< }) const signatures = await Promise.all( - localOwners.map(async (localOwner) => { - let signer = safeWebAuthnSharedSignerAddress - let data: Hex - let dynamic = true - - if (isWebAuthnAccount(localOwner)) { - data = await getWebAuthnSignature({ - owner: localOwner, - hash: messageHash - }) - } else { - signer = localOwner.address - data = adjustVInSignature( - "eth_sign", - await localOwner.signMessage({ + localOwners.map( + async ({ owner: localOwner, dynamic: isDynamicOwner }) => { + let signer = safeWebAuthnSharedSignerAddress + let data: Hex + let dynamic = true + + if (isWebAuthnAccount(localOwner)) { + data = await getWebAuthnSignature({ + owner: localOwner, + hash: messageHash + }) + } else { + signer = localOwner.address + const signature = await localOwner.signMessage({ message: { raw: toBytes(messageHash) } }) - ) - dynamic = false - } - - if (!signer) { - throw new Error("no signer found") - } - - return { - signer, - dynamic, - data + // Dynamic signatures are verified through EIP-1271 on + // the owner contract, so the v value must not be + // adjusted to the Safe's ECDSA signature types. + data = isDynamicOwner + ? signature + : adjustVInSignature("eth_sign", signature) + dynamic = isDynamicOwner + } + + if (!signer) { + throw new Error("no signer found") + } + + return { + signer, + dynamic, + data + } } - }) + ) ) const signatureBytes = concatSignatures(signatures) @@ -1913,37 +1958,14 @@ export async function toSafeSmartAccount< } const signatures = await Promise.all( - localOwners.map(async (localOwner) => { - let signer = safeWebAuthnSharedSignerAddress - let data: Hex - let dynamic = true - - if (isWebAuthnAccount(localOwner)) { - const messageHash = hashTypedData({ - domain: { - chainId: await getMemoizedChainId(), - verifyingContract: await this.getAddress() - }, - types: { - SafeMessage: [ - { name: "message", type: "bytes" } - ] - }, - primaryType: "SafeMessage", - message: { - message: generateSafeMessageMessage(typedData) - } - }) - - data = await getWebAuthnSignature({ - owner: localOwner, - hash: messageHash - }) - } else { - signer = localOwner.address - data = adjustVInSignature( - "eth_signTypedData", - await localOwner.signTypedData({ + localOwners.map( + async ({ owner: localOwner, dynamic: isDynamicOwner }) => { + let signer = safeWebAuthnSharedSignerAddress + let data: Hex + let dynamic = true + + if (isWebAuthnAccount(localOwner)) { + const messageHash = hashTypedData({ domain: { chainId: await getMemoizedChainId(), verifyingContract: await this.getAddress() @@ -1959,20 +1981,52 @@ export async function toSafeSmartAccount< generateSafeMessageMessage(typedData) } }) - ) - dynamic = false - } - if (!signer) { - throw new Error("no signer found") - } - - return { - signer, - dynamic, - data + data = await getWebAuthnSignature({ + owner: localOwner, + hash: messageHash + }) + } else { + signer = localOwner.address + const signature = await localOwner.signTypedData({ + domain: { + chainId: await getMemoizedChainId(), + verifyingContract: await this.getAddress() + }, + types: { + SafeMessage: [ + { name: "message", type: "bytes" } + ] + }, + primaryType: "SafeMessage", + message: { + message: + generateSafeMessageMessage(typedData) + } + }) + // Dynamic signatures are verified through EIP-1271 on + // the owner contract, so the v value must not be + // adjusted to the Safe's ECDSA signature types. + data = isDynamicOwner + ? signature + : adjustVInSignature( + "eth_signTypedData", + signature + ) + dynamic = isDynamicOwner + } + + if (!signer) { + throw new Error("no signer found") + } + + return { + signer, + dynamic, + data + } } - }) + ) ) const signatureBytes = concatSignatures(signatures) @@ -1993,13 +2047,13 @@ export async function toSafeSmartAccount< let signatures: Hex | undefined = undefined - for (const owner of localOwners) { + for (const { owner, dynamic } of localOwners) { signatures = await signUserOperation({ ...userOperation, version, entryPoint, - owners: localOwners, - account: owner, + owners: localOwners.map(({ owner }) => owner), + account: { owner, dynamic }, chainId, signatures, validAfter,