Skip to content

Commit 58005fe

Browse files
committed
feat(safe): support dynamic owners
- Add dynamic owner types and export them for safe accounts - Encode contract owners as dynamic Safe signatures in signing flows - Add integration coverage for v0.7 dynamic owner user operations
1 parent 660c8e2 commit 58005fe

6 files changed

Lines changed: 307 additions & 135 deletions

File tree

.changeset/safe-dynamic-owners.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"permissionless": minor
3+
---
4+
5+
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.

packages/permissionless/accounts/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export {
3535
} from "./etherspot/toEtherspotSmartAccount.js"
3636

3737
export {
38+
type DynamicOwner,
39+
type RegularOwner,
40+
type SafeOwner,
3841
type SafeSmartAccountImplementation,
3942
type SafeVersion,
4043
type ToSafeSmartAccountParameters,

packages/permissionless/accounts/safe/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export const SafeSmartAccount = {
77
}
88

99
export type {
10+
DynamicOwner,
11+
RegularOwner,
12+
SafeOwner,
1013
SafeSmartAccountImplementation,
1114
SafeVersion,
1215
ToSafeSmartAccountParameters,

packages/permissionless/accounts/safe/signUserOperation.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { http, createTestClient, size, slice } from "viem"
12
import {
23
entryPoint06Address,
34
entryPoint07Address
@@ -12,9 +13,11 @@ import { describe, expect } from "vitest"
1213
import { testWithRpc } from "../../../permissionless-test/src/testWithRpc"
1314
import {
1415
getBundlerClient,
16+
getPublicClient,
1517
getSafeClient
1618
} from "../../../permissionless-test/src/utils"
1719
import { signUserOperation } from "./signUserOperation"
20+
import { toSafeSmartAccount } from "./toSafeSmartAccount"
1821

1922
describe("signUserOperation", () => {
2023
testWithRpc("signUserOperation_V06", async ({ rpc }) => {
@@ -185,6 +188,85 @@ describe("signUserOperation", () => {
185188
expect(receipt.success).toBeTruthy()
186189
})
187190

191+
testWithRpc("signUserOperation_V07 with dynamic owner", async ({ rpc }) => {
192+
const eoaOwner = privateKeyToAccount(generatePrivateKey())
193+
const dynamicSignerKey = privateKeyToAccount(generatePrivateKey())
194+
195+
// Contract owner validated through EIP-1271. The runtime code echoes
196+
// the called selector back, so isValidSignature always returns the
197+
// expected magic value (both the bytes32 and legacy bytes variants).
198+
const erc1271OwnerAddress = privateKeyToAccount(
199+
generatePrivateKey()
200+
).address
201+
202+
const testClient = createTestClient({
203+
mode: "anvil",
204+
transport: http(rpc.anvilRpc),
205+
chain: foundry
206+
})
207+
208+
await testClient.setCode({
209+
address: erc1271OwnerAddress,
210+
bytecode: "0x60003560e01c60e01b60005260206000f3"
211+
})
212+
213+
// A KMS-style owner: signs with a local key but lives at the
214+
// ERC-1271 contract address.
215+
const dynamicOwner = toAccount({
216+
address: erc1271OwnerAddress,
217+
async signMessage({ message }) {
218+
return dynamicSignerKey.signMessage({ message })
219+
},
220+
async signTypedData(typedData) {
221+
// biome-ignore lint/suspicious/noExplicitAny: test helper
222+
return dynamicSignerKey.signTypedData(typedData as any)
223+
},
224+
async signTransaction() {
225+
throw new Error("Not supported")
226+
}
227+
})
228+
229+
const account = await toSafeSmartAccount({
230+
client: getPublicClient(rpc.anvilRpc),
231+
entryPoint: {
232+
address: entryPoint07Address,
233+
version: "0.7"
234+
},
235+
owners: [eoaOwner, { owner: dynamicOwner, dynamic: true }],
236+
version: "1.4.1",
237+
saltNonce: 420n
238+
})
239+
240+
const safeAccountClient = getBundlerClient({
241+
account,
242+
entryPoint: {
243+
version: "0.7"
244+
},
245+
...rpc
246+
})
247+
248+
const stubSignature = await account.getStubSignature()
249+
// 6 bytes validAfter + 6 bytes validUntil + 2 * 65 bytes static parts
250+
// + 32 bytes dynamic length + 65 bytes dynamic signature data
251+
expect(size(slice(stubSignature, 12))).toBe(130 + 32 + 65)
252+
253+
const userOpHash = await safeAccountClient.sendUserOperation({
254+
calls: [
255+
{
256+
to: account.address,
257+
data: "0x"
258+
}
259+
]
260+
})
261+
262+
const receipt = await safeAccountClient.waitForUserOperationReceipt({
263+
hash: userOpHash
264+
})
265+
266+
expect(receipt).toBeTruthy()
267+
expect(receipt.success).toBeTruthy()
268+
})
269+
188270
testWithRpc("signUserOperation_V07 7579", async ({ rpc }) => {
189271
const owners = [
190272
privateKeyToAccount(generatePrivateKey()),

packages/permissionless/accounts/safe/signUserOperation.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,34 @@ export async function signUserOperation(
122122
address: Address
123123
version: "0.6" | "0.7"
124124
}
125-
owners: (Account | WebAuthnAccount)[]
126-
account: OneOf<
127-
| EthereumProvider
128-
| WalletClient<Transport, Chain | undefined, Account>
129-
| LocalAccount
125+
owners: (
126+
| Account
130127
| WebAuthnAccount
131-
>
128+
| { owner: Account; dynamic?: boolean }
129+
)[]
130+
account:
131+
| OneOf<
132+
| EthereumProvider
133+
| WalletClient<Transport, Chain | undefined, Account>
134+
| LocalAccount
135+
| WebAuthnAccount
136+
>
137+
| {
138+
owner: OneOf<
139+
| EthereumProvider
140+
| WalletClient<Transport, Chain | undefined, Account>
141+
| LocalAccount
142+
| WebAuthnAccount
143+
>
144+
/**
145+
* When true, the signature is encoded as a dynamic part of
146+
* the Safe signature bytes (contract signature format,
147+
* signature type 0x00), verified through EIP-1271 on the
148+
* owner address.
149+
* @default false
150+
*/
151+
dynamic?: boolean
152+
}
132153
chainId: number
133154
signatures?: Hex
134155
validAfter?: number
@@ -146,10 +167,14 @@ export async function signUserOperation(
146167
version,
147168
owners,
148169
signatures: existingSignatures,
149-
account,
170+
account: _account,
150171
...userOperation
151172
} = parameters
152173

174+
const account = "owner" in _account ? _account.owner : _account
175+
const isDynamicOwner =
176+
"owner" in _account ? (_account.dynamic ?? false) : false
177+
153178
const { safe4337ModuleAddress } = getDefaultAddresses(
154179
version,
155180
entryPoint.version,
@@ -258,7 +283,7 @@ export async function signUserOperation(
258283
...unPackedSignatures,
259284
{
260285
signer,
261-
dynamic: isWebAuthnAccount(localOwner),
286+
dynamic: isWebAuthnAccount(localOwner) || isDynamicOwner,
262287
data: await (async () => {
263288
if (isWebAuthnAccount(localOwner)) {
264289
const safeHash = hashTypedData({

0 commit comments

Comments
 (0)