Skip to content
Open
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/safe-dynamic-owners.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/permissionless/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export {
} from "./etherspot/toEtherspotSmartAccount.js"

export {
type DynamicOwner,
type RegularOwner,
type SafeOwner,
type SafeSmartAccountImplementation,
type SafeVersion,
type ToSafeSmartAccountParameters,
Expand Down
3 changes: 3 additions & 0 deletions packages/permissionless/accounts/safe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const SafeSmartAccount = {
}

export type {
DynamicOwner,
RegularOwner,
SafeOwner,
SafeSmartAccountImplementation,
SafeVersion,
ToSafeSmartAccountParameters,
Expand Down
104 changes: 104 additions & 0 deletions packages/permissionless/accounts/safe/signUserOperation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { http, createTestClient, padHex, size, slice } from "viem"
import {
entryPoint06Address,
entryPoint07Address
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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()),
Expand Down
41 changes: 33 additions & 8 deletions packages/permissionless/accounts/safe/signUserOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,34 @@ export async function signUserOperation(
address: Address
version: "0.6" | "0.7"
}
owners: (Account | WebAuthnAccount)[]
account: OneOf<
| EthereumProvider
| WalletClient<Transport, Chain | undefined, Account>
| LocalAccount
owners: (
| Account
| WebAuthnAccount
>
| { owner: Account; dynamic?: boolean }
)[]
account:
| OneOf<
| EthereumProvider
| WalletClient<Transport, Chain | undefined, Account>
| LocalAccount
| WebAuthnAccount
>
| {
owner: OneOf<
| EthereumProvider
| WalletClient<Transport, Chain | undefined, Account>
| 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
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading