From 68749bbcd1ff6b436e08eba0e1f7d5222c10407c Mon Sep 17 00:00:00 2001
From: plusminushalf
Date: Wed, 13 Aug 2025 11:24:21 +0100
Subject: [PATCH 1/4] Add safe passkey support
---
.../accounts/safe/signUserOperation.ts | 216 +++++++--
.../accounts/safe/toSafeSmartAccount.ts | 426 ++++++++++++++----
packages/wagmi-demo/src/PasskeyServerDemo.tsx | 1 +
packages/wagmi-demo/src/PasskeysDemo.tsx | 231 +++++++++-
packages/wagmi-demo/src/main.tsx | 41 +-
packages/wagmi-demo/src/wagmi.ts | 2 +-
6 files changed, 781 insertions(+), 136 deletions(-)
diff --git a/packages/permissionless/accounts/safe/signUserOperation.ts b/packages/permissionless/accounts/safe/signUserOperation.ts
index e1323820..e9965572 100644
--- a/packages/permissionless/accounts/safe/signUserOperation.ts
+++ b/packages/permissionless/accounts/safe/signUserOperation.ts
@@ -1,3 +1,4 @@
+import { Signature } from "ox"
import {
type Account,
type Address,
@@ -8,13 +9,13 @@ import {
type Transport,
type UnionPartialBy,
type WalletClient,
- concat,
concatHex,
decodeAbiParameters,
encodeAbiParameters,
- encodePacked
+ encodePacked,
+ hashTypedData
} from "viem"
-import type { UserOperation } from "viem/account-abstraction"
+import type { UserOperation, WebAuthnAccount } from "viem/account-abstraction"
import { toOwner } from "../../utils/index.js"
import type { EthereumProvider } from "../../utils/toOwner.js"
import {
@@ -22,9 +23,93 @@ import {
EIP712_SAFE_OPERATION_TYPE_V07,
type SafeVersion,
getDefaultAddresses,
- getPaymasterAndData
+ getPaymasterAndData,
+ isWebAuthnAccount
} from "./toSafeSmartAccount.js"
+export const concatSignatures = (
+ signatures: { signer: Address; data: Hex; dynamic: boolean }[]
+) => {
+ signatures.sort((left, right) =>
+ left.signer.toLowerCase().localeCompare(right.signer.toLowerCase())
+ )
+
+ const SIGNATURE_LENGTH_BYTES = 65
+ let signatureBytes = "0x"
+ let dynamicBytes = ""
+
+ for (const sig of signatures) {
+ if (sig.dynamic) {
+ /*
+ A contract signature has a static part of 65 bytes and the dynamic part that needs to be appended
+ at the end of signature bytes.
+ The signature format is
+ Signature type == 0
+ Constant part: 65 bytes
+ {32-bytes signature verifier}{32-bytes dynamic data position}{1-byte signature type}
+ Dynamic part (solidity bytes): 32 bytes + signature data length
+ {32-bytes signature length}{bytes signature data}
+ */
+ const dynamicPartPosition = (
+ signatures.length * SIGNATURE_LENGTH_BYTES +
+ dynamicBytes.length / 2
+ )
+ .toString(16)
+ .padStart(64, "0")
+ const dynamicPartLength = (sig.data.slice(2).length / 2)
+ .toString(16)
+ .padStart(64, "0")
+ const staticSignature = `${sig.signer.slice(2).padStart(64, "0")}${dynamicPartPosition}00`
+ const dynamicPartWithLength = `${dynamicPartLength}${sig.data.slice(2)}`
+ signatureBytes += staticSignature
+ dynamicBytes += dynamicPartWithLength
+ } else {
+ signatureBytes += sig.data.slice(2)
+ }
+ }
+
+ signatureBytes += dynamicBytes
+
+ return signatureBytes as Hex
+}
+
+export const getWebAuthnSignature = async ({
+ owner,
+ hash
+}: {
+ owner: WebAuthnAccount
+ hash: Hex
+}) => {
+ const { signature: signatureData, webauthn } = await owner.sign({
+ hash
+ })
+
+ const signature = Signature.fromHex(signatureData)
+
+ const match = webauthn.clientDataJSON.match(
+ /^\{"type":"webauthn.get","challenge":"[A-Za-z0-9\-_]{43}",(.*)\}$/
+ )
+
+ if (!match) {
+ throw new Error("challenge not found in client data JSON")
+ }
+
+ const [, fields] = match
+
+ return encodeAbiParameters(
+ [
+ { name: "authenticatorData", type: "bytes" },
+ { name: "clientDataJSON", type: "string" },
+ { name: "signature", type: "uint256[2]" }
+ ],
+ [
+ webauthn.authenticatorData,
+ fields,
+ [BigInt(signature.r), BigInt(signature.s)]
+ ]
+ )
+}
+
export async function signUserOperation(
parameters: UnionPartialBy & {
version: SafeVersion
@@ -32,17 +117,19 @@ export async function signUserOperation(
address: Address
version: "0.6" | "0.7"
}
- owners: Account[]
+ owners: (Account | WebAuthnAccount)[]
account: OneOf<
| EthereumProvider
| WalletClient
| LocalAccount
+ | WebAuthnAccount
>
chainId: number
signatures?: Hex
validAfter?: number
validUntil?: number
safe4337ModuleAddress?: Address
+ safeWebAuthnSharedSignerAddress?: Address
}
) {
const {
@@ -102,38 +189,93 @@ export async function signUserOperation(
})
}
- const localOwners = [
- await toOwner({
- owner: account as OneOf
- })
- ]
+ const localOwner = isWebAuthnAccount(account)
+ ? account
+ : await toOwner({
+ owner: account
+ })
+
+ const signer = isWebAuthnAccount(localOwner)
+ ? parameters.safeWebAuthnSharedSignerAddress
+ : localOwner.address
- let unPackedSignatures: readonly { signer: Address; data: Hex }[] = []
+ if (!signer) {
+ throw new Error("no signer found")
+ }
+
+ let unPackedSignatures: readonly {
+ signer: Address
+ data: Hex
+ dynamic: boolean
+ }[] = []
if (existingSignatures) {
- const decoded = decodeAbiParameters(
- [
- {
- components: [
- { type: "address", name: "signer" },
- { type: "bytes", name: "data" }
- ],
- name: "signatures",
- type: "tuple[]"
- }
- ],
- existingSignatures
- )
+ try {
+ const decoded = decodeAbiParameters(
+ [
+ {
+ components: [
+ { type: "address", name: "signer" },
+ { type: "bytes", name: "data" },
+ { type: "bool", name: "dynamic" }
+ ],
+ name: "signatures",
+ type: "tuple[]"
+ }
+ ],
+ existingSignatures
+ )
+
+ unPackedSignatures = decoded[0]
+ } catch {
+ const decoded = decodeAbiParameters(
+ [
+ {
+ components: [
+ { type: "address", name: "signer" },
+ { type: "bytes", name: "data" }
+ ],
+ name: "signatures",
+ type: "tuple[]"
+ }
+ ],
+ existingSignatures
+ )
- unPackedSignatures = decoded[0]
+ unPackedSignatures = decoded[0].map((sig) => ({
+ ...sig,
+ dynamic: false
+ }))
+ }
}
- const signatures: { signer: Address; data: Hex }[] = [
+ const signatures: { signer: Address; data: Hex; dynamic: boolean }[] = [
...unPackedSignatures,
- ...(await Promise.all(
- localOwners.map(async (localOwner) => ({
- signer: localOwner.address,
- data: await localOwner.signTypedData({
+ {
+ signer,
+ dynamic: isWebAuthnAccount(localOwner),
+ data: await (async () => {
+ if (isWebAuthnAccount(localOwner)) {
+ const safeHash = hashTypedData({
+ domain: {
+ chainId,
+ verifyingContract: safe4337ModuleAddress
+ },
+ types:
+ entryPoint.version === "0.6"
+ ? EIP712_SAFE_OPERATION_TYPE_V06
+ : EIP712_SAFE_OPERATION_TYPE_V07,
+ primaryType: "SafeOp",
+ message: message
+ })
+
+ return getWebAuthnSignature({
+ owner: localOwner,
+ hash: safeHash
+ })
+ }
+
+ return localOwner.signTypedData({
domain: {
chainId,
verifyingContract: safe4337ModuleAddress
@@ -145,8 +287,8 @@ export async function signUserOperation(
primaryType: "SafeOp",
message: message
})
- }))
- ))
+ })()
+ }
]
if (signatures.length !== owners.length) {
@@ -155,7 +297,8 @@ export async function signUserOperation(
{
components: [
{ type: "address", name: "signer" },
- { type: "bytes", name: "data" }
+ { type: "bytes", name: "data" },
+ { type: "boolean", name: "dynamic" }
],
name: "signatures",
type: "tuple[]"
@@ -165,13 +308,8 @@ export async function signUserOperation(
)
}
- signatures.sort((left, right) =>
- left.signer.toLowerCase().localeCompare(right.signer.toLowerCase())
- )
- const signatureBytes = concat(signatures.map((sig) => sig.data))
-
return encodePacked(
["uint48", "uint48", "bytes"],
- [validAfter, validUntil, signatureBytes]
+ [validAfter, validUntil, concatSignatures(signatures)]
)
}
diff --git a/packages/permissionless/accounts/safe/toSafeSmartAccount.ts b/packages/permissionless/accounts/safe/toSafeSmartAccount.ts
index 221644d6..7c2a6ea9 100644
--- a/packages/permissionless/accounts/safe/toSafeSmartAccount.ts
+++ b/packages/permissionless/accounts/safe/toSafeSmartAccount.ts
@@ -1,3 +1,4 @@
+import { PublicKey } from "ox"
import {
type Account,
type Address,
@@ -35,6 +36,7 @@ import {
type SmartAccount,
type SmartAccountImplementation,
type UserOperation,
+ type WebAuthnAccount,
entryPoint06Abi,
entryPoint07Abi,
entryPoint07Address,
@@ -47,7 +49,11 @@ import { decode7579Calls } from "../../utils/decode7579Calls.js"
import { encode7579Calls } from "../../utils/encode7579Calls.js"
import { isSmartAccountDeployed } from "../../utils/isSmartAccountDeployed.js"
import { type EthereumProvider, toOwner } from "../../utils/toOwner.js"
-import { signUserOperation } from "./signUserOperation.js"
+import {
+ concatSignatures,
+ getWebAuthnSignature,
+ signUserOperation
+} from "./signUserOperation.js"
export type SafeVersion = "1.4.1" | "1.5.0"
@@ -186,6 +192,39 @@ const enableModulesAbi = [
}
] as const
+const safeWebAuthnSharedSignerAbi = [
+ {
+ inputs: [
+ {
+ components: [
+ {
+ internalType: "uint256",
+ name: "x",
+ type: "uint256"
+ },
+ {
+ internalType: "uint256",
+ name: "y",
+ type: "uint256"
+ },
+ {
+ internalType: "P256.Verifiers",
+ name: "verifiers",
+ type: "uint176"
+ }
+ ],
+ internalType: "struct SafeWebAuthnSharedSigner.Signer",
+ name: "signer",
+ type: "tuple"
+ }
+ ],
+ name: "configure",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function"
+ }
+] as const
+
const setupAbi = [
{
inputs: [
@@ -415,6 +454,8 @@ const SAFE_VERSION_TO_ADDRESSES_MAP: {
SAFE_SINGLETON_ADDRESS: Address
MULTI_SEND_ADDRESS: Address
MULTI_SEND_CALL_ONLY_ADDRESS: Address
+ WEB_AUTHN_SHARED_SIGNER_ADDRESS?: never
+ SAFE_P256_VERIFIER_ADDRESS?: never
}
"0.7": {
SAFE_MODULE_SETUP_ADDRESS: Address
@@ -423,6 +464,8 @@ const SAFE_VERSION_TO_ADDRESSES_MAP: {
SAFE_SINGLETON_ADDRESS: Address
MULTI_SEND_ADDRESS: Address
MULTI_SEND_CALL_ONLY_ADDRESS: Address
+ WEB_AUTHN_SHARED_SIGNER_ADDRESS: Address
+ SAFE_P256_VERIFIER_ADDRESS: Address
}
}
} = {
@@ -451,7 +494,11 @@ const SAFE_VERSION_TO_ADDRESSES_MAP: {
"0x41675C099F32341bf84BFc5382aF534df5C7461a",
MULTI_SEND_ADDRESS: "0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526",
MULTI_SEND_CALL_ONLY_ADDRESS:
- "0x9641d764fc13c8B624c04430C7356C1C7C8102e2"
+ "0x9641d764fc13c8B624c04430C7356C1C7C8102e2",
+ WEB_AUTHN_SHARED_SIGNER_ADDRESS:
+ "0x94a4F6affBd8975951142c3999aEAB7ecee555c2",
+ SAFE_P256_VERIFIER_ADDRESS:
+ "0xA86e0054C51E4894D88762a017ECc5E5235f5DBA"
}
},
"1.5.0": {
@@ -466,7 +513,11 @@ const SAFE_VERSION_TO_ADDRESSES_MAP: {
"0xFf51A5898e281Db6DfC7855790607438dF2ca44b",
MULTI_SEND_ADDRESS: "0x218543288004CD07832472D464648173c77D7eB7",
MULTI_SEND_CALL_ONLY_ADDRESS:
- "0xA83c336B20401Af773B6219BA5027174338D1836"
+ "0xA83c336B20401Af773B6219BA5027174338D1836",
+ WEB_AUTHN_SHARED_SIGNER_ADDRESS:
+ "0x94a4F6affBd8975951142c3999aEAB7ecee555c2",
+ SAFE_P256_VERIFIER_ADDRESS:
+ "0xA86e0054C51E4894D88762a017ECc5E5235f5DBA"
}
}
}
@@ -559,6 +610,7 @@ const get7579LaunchPadInitData = ({
safe4337ModuleAddress,
safeSingletonAddress,
erc7579LaunchpadAddress,
+ safeWebAuthnSharedSignerAddress,
owners,
validators,
executors,
@@ -571,7 +623,8 @@ const get7579LaunchPadInitData = ({
safe4337ModuleAddress: Address
safeSingletonAddress: Address
erc7579LaunchpadAddress: Address
- owners: Address[]
+ safeWebAuthnSharedSignerAddress?: Address
+ owners: OwnersArray
executors: {
address: Address
context: Address
@@ -583,9 +636,24 @@ const get7579LaunchPadInitData = ({
threshold: bigint
attestersThreshold: number
}) => {
+ const ownerAddresses = owners.map((owner) => {
+ if ("type" in owner && owner.type === "webAuthn") {
+ if (!safeWebAuthnSharedSignerAddress) {
+ throw new Error("safeWebAuthnSharedSignerAddress not defined")
+ }
+ return safeWebAuthnSharedSignerAddress
+ }
+
+ if ("address" in owner && owner.address) {
+ return owner.address
+ }
+
+ throw new Error("Incorrect owner found")
+ })
+
const initData = {
singleton: safeSingletonAddress,
- owners: owners,
+ owners: ownerAddresses,
threshold: threshold,
setupTo: erc7579LaunchpadAddress,
setupData: encodeFunctionData({
@@ -618,11 +686,19 @@ const get7579LaunchPadInitData = ({
return initData
}
+export const isWebAuthnAccount = (
+ owner: RegularOwner | WebAuthnAccount
+): owner is WebAuthnAccount => {
+ return "type" in owner && owner.type === "webAuthn"
+}
+
const getInitializerCode = async ({
owners,
threshold,
safeModuleSetupAddress,
safe4337ModuleAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
multiSendAddress,
safeSingletonAddress,
erc7579LaunchpadAddress,
@@ -638,11 +714,13 @@ const getInitializerCode = async ({
payment = BigInt(0),
paymentReceiver = zeroAddress
}: {
- owners: Address[]
+ owners: OwnersArray
threshold: bigint
safeSingletonAddress: Address
safeModuleSetupAddress: Address
safe4337ModuleAddress: Address
+ safeWebAuthnSharedSignerAddress?: Address
+ safeP256VerifierAddress?: Address
multiSendAddress: Address
erc7579LaunchpadAddress?: Address
setupTransactions?: {
@@ -668,6 +746,7 @@ const getInitializerCode = async ({
const initData = get7579LaunchPadInitData({
safe4337ModuleAddress,
safeSingletonAddress,
+ safeWebAuthnSharedSignerAddress,
erc7579LaunchpadAddress,
owners,
validators,
@@ -752,7 +831,32 @@ const getInitializerCode = async ({
})
}
- const multiSendCallData = encodeMultiSend([
+ const webAuthnOwner = owners.reduce(
+ (acc, owner) => {
+ if (isWebAuthnAccount(owner)) {
+ return owner
+ }
+ return acc
+ },
+ undefined
+ )
+
+ const ownerAddresses = owners.map((owner) => {
+ if (isWebAuthnAccount(owner)) {
+ if (!safeWebAuthnSharedSignerAddress) {
+ throw new Error("safeWebAuthnSharedSignerAddress not defined")
+ }
+ return safeWebAuthnSharedSignerAddress
+ }
+
+ if ("address" in owner && owner.address) {
+ return owner.address
+ }
+
+ throw new Error("Incorrect owner found")
+ })
+
+ const multiCalls = [
{
to: safeModuleSetupAddress,
data: encodeFunctionData({
@@ -761,16 +865,49 @@ const getInitializerCode = async ({
args: [[safe4337ModuleAddress, ...safeModules]]
}),
value: BigInt(0),
- operation: 1
- },
- ...setupTransactions.map((tx) => ({ ...tx, operation: 0 as 0 | 1 }))
- ])
+ operation: 1 as 0 | 1
+ }
+ ]
+
+ if (
+ webAuthnOwner &&
+ safeWebAuthnSharedSignerAddress &&
+ safeP256VerifierAddress
+ ) {
+ const parsedPublicKey = PublicKey.fromHex(webAuthnOwner.publicKey)
+
+ multiCalls.push({
+ to: safeWebAuthnSharedSignerAddress,
+ data: encodeFunctionData({
+ abi: safeWebAuthnSharedSignerAbi,
+ functionName: "configure",
+ args: [
+ {
+ x: parsedPublicKey.x,
+ y: parsedPublicKey.y,
+ verifiers: BigInt(safeP256VerifierAddress)
+ }
+ ]
+ }),
+ value: BigInt(0),
+ operation: 1 as 0 | 1
+ })
+ }
+
+ for (const tx of setupTransactions) {
+ multiCalls.push({
+ ...tx,
+ operation: 0 as 0 | 1
+ })
+ }
+
+ const multiSendCallData = encodeMultiSend(multiCalls)
return encodeFunctionData({
abi: setupAbi,
functionName: "setup",
args: [
- owners,
+ ownerAddresses,
threshold,
multiSendAddress,
multiSendCallData,
@@ -815,6 +952,8 @@ const getAccountInitCode = async ({
safe4337ModuleAddress,
safeSingletonAddress,
erc7579LaunchpadAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
multiSendAddress,
paymentToken,
payment,
@@ -829,12 +968,14 @@ const getAccountInitCode = async ({
attesters = [],
attestersThreshold = 0
}: {
- owners: Address[]
+ owners: OwnersArray
threshold: bigint
safeModuleSetupAddress: Address
safe4337ModuleAddress: Address
safeSingletonAddress: Address
multiSendAddress: Address
+ safeWebAuthnSharedSignerAddress?: Address
+ safeP256VerifierAddress?: Address
erc7579LaunchpadAddress?: Address
saltNonce?: bigint
setupTransactions?: {
@@ -860,6 +1001,8 @@ const getAccountInitCode = async ({
owners,
threshold,
safeModuleSetupAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
safe4337ModuleAddress,
multiSendAddress,
setupTransactions,
@@ -900,7 +1043,9 @@ export const getDefaultAddresses = (
safeProxyFactoryAddress: _safeProxyFactoryAddress,
safeSingletonAddress: _safeSingletonAddress,
multiSendAddress: _multiSendAddress,
- multiSendCallOnlyAddress: _multiSendCallOnlyAddress
+ multiSendCallOnlyAddress: _multiSendCallOnlyAddress,
+ safeWebAuthnSharedSignerAddress: _safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress: _safeP256VerifierAddress
}: {
addModuleLibAddress?: Address
safeModuleSetupAddress?: Address
@@ -909,6 +1054,8 @@ export const getDefaultAddresses = (
safeSingletonAddress?: Address
multiSendAddress?: Address
multiSendCallOnlyAddress?: Address
+ safeWebAuthnSharedSignerAddress?: Address
+ safeP256VerifierAddress?: Address
}
) => {
const versionAddresses =
@@ -937,13 +1084,21 @@ export const getDefaultAddresses = (
_multiSendCallOnlyAddress ??
versionAddresses.MULTI_SEND_CALL_ONLY_ADDRESS
+ const safeWebAuthnSharedSignerAddress =
+ _safeWebAuthnSharedSignerAddress ??
+ versionAddresses.WEB_AUTHN_SHARED_SIGNER_ADDRESS
+ const safeP256VerifierAddress =
+ _safeP256VerifierAddress ?? versionAddresses.SAFE_P256_VERIFIER_ADDRESS
+
return {
safeModuleSetupAddress,
safe4337ModuleAddress,
safeProxyFactoryAddress,
safeSingletonAddress,
multiSendAddress,
- multiSendCallOnlyAddress
+ multiSendCallOnlyAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress
}
}
@@ -973,6 +1128,29 @@ type GetErc7579Params =
attestersThreshold?: number
}
+type RegularOwner =
+ | Account
+ | WalletClient
+ | EthereumProvider
+
+type ValidateAtMostOneWebAuthn<
+ T extends readonly unknown[],
+ SeenWebAuthn extends boolean = false
+> = T extends readonly []
+ ? true
+ : T extends readonly [infer H, ...infer Rest]
+ ? H extends WebAuthnAccount
+ ? SeenWebAuthn extends true
+ ? false
+ : ValidateAtMostOneWebAuthn
+ : H extends RegularOwner
+ ? ValidateAtMostOneWebAuthn
+ : false
+ : true
+
+type OwnersArray =
+ ValidateAtMostOneWebAuthn extends true ? T : never
+
export type ToSafeSmartAccountParameters<
entryPointVersion extends "0.6" | "0.7",
TErc7579 extends Address | undefined
@@ -982,11 +1160,7 @@ export type ToSafeSmartAccountParameters<
Chain | undefined,
JsonRpcAccount | LocalAccount | undefined
>
- owners: (
- | Account
- | WalletClient
- | EthereumProvider
- )[]
+ owners: OwnersArray
threshold?: bigint
version: SafeVersion
entryPoint?: {
@@ -997,6 +1171,8 @@ export type ToSafeSmartAccountParameters<
erc7579LaunchpadAddress?: TErc7579
safeProxyFactoryAddress?: Address
safeSingletonAddress?: Address
+ safeWebAuthnSharedSignerAddress?: Address
+ safeP256VerifierAddress?: Address
address?: Address
saltNonce?: bigint
validUntil?: number
@@ -1039,6 +1215,8 @@ const getAccountAddress = async ({
safeProxyFactoryAddress,
safeSingletonAddress,
multiSendAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
erc7579LaunchpadAddress,
paymentToken,
payment,
@@ -1054,12 +1232,14 @@ const getAccountAddress = async ({
attestersThreshold = 0
}: {
client: Client
- owners: Address[]
+ owners: OwnersArray
threshold: bigint
safeModuleSetupAddress: Address
safe4337ModuleAddress: Address
safeProxyFactoryAddress: Address
safeSingletonAddress: Address
+ safeWebAuthnSharedSignerAddress?: Address
+ safeP256VerifierAddress?: Address
multiSendAddress: Address
setupTransactions: {
to: Address
@@ -1093,6 +1273,8 @@ const getAccountAddress = async ({
threshold,
safeModuleSetupAddress,
safe4337ModuleAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
multiSendAddress,
setupTransactions,
safeSingletonAddress,
@@ -1216,17 +1398,25 @@ export async function toSafeSmartAccount<
return true
}
+ if (isWebAuthnAccount(owner)) {
+ return true
+ }
+
return false
})
- .map((owner) =>
- toOwner({
+ .map((owner) => {
+ if (isWebAuthnAccount(owner)) {
+ return owner
+ }
+
+ return toOwner({
owner: owner as OneOf<
| LocalAccount
| EthereumProvider
| WalletClient
>
})
- )
+ })
)
const entryPoint = {
@@ -1277,14 +1467,19 @@ export async function toSafeSmartAccount<
safeProxyFactoryAddress,
safeSingletonAddress,
multiSendAddress,
- multiSendCallOnlyAddress
+ multiSendCallOnlyAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress
} = getDefaultAddresses(version, entryPoint.version, {
safeModuleSetupAddress: _safeModuleSetupAddress,
safe4337ModuleAddress: _safe4337ModuleAddress,
safeProxyFactoryAddress: _safeProxyFactoryAddress,
safeSingletonAddress: _safeSingletonAddress,
multiSendAddress: _multiSendAddress,
- multiSendCallOnlyAddress: _multiSendCallOnlyAddress
+ multiSendCallOnlyAddress: _multiSendCallOnlyAddress,
+ safeWebAuthnSharedSignerAddress:
+ parameters.safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress: parameters.safeP256VerifierAddress
})
let accountAddress: Address | undefined = address
@@ -1303,11 +1498,13 @@ export async function toSafeSmartAccount<
return {
factory: safeProxyFactoryAddress,
factoryData: await getAccountInitCode({
- owners: owners.map((owner) => owner.address),
+ owners,
threshold,
safeModuleSetupAddress,
safe4337ModuleAddress,
safeSingletonAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
multiSendAddress,
erc7579LaunchpadAddress,
saltNonce,
@@ -1336,12 +1533,14 @@ export async function toSafeSmartAccount<
// Get the sender address based on the init code
accountAddress = await getAccountAddress({
client,
- owners: owners.map((owner) => owner.address),
+ owners,
threshold,
safeModuleSetupAddress,
safe4337ModuleAddress,
safeProxyFactoryAddress,
safeSingletonAddress,
+ safeWebAuthnSharedSignerAddress,
+ safeP256VerifierAddress,
multiSendAddress,
erc7579LaunchpadAddress,
saltNonce,
@@ -1374,7 +1573,8 @@ export async function toSafeSmartAccount<
safe4337ModuleAddress,
safeSingletonAddress,
erc7579LaunchpadAddress,
- owners: owners.map((owner) => owner.address),
+ safeWebAuthnSharedSignerAddress,
+ owners,
threshold,
validators,
executors,
@@ -1548,18 +1748,47 @@ export async function toSafeSmartAccount<
})
},
async getStubSignature() {
+ const signatures = owners.map((owner) => {
+ let signer = safeWebAuthnSharedSignerAddress
+ let dynamic = true
+ let data: Hex =
+ "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c"
+
+ if (!isWebAuthnAccount(owner)) {
+ signer = owner.address
+ dynamic = false
+ } else {
+ data = encodeAbiParameters(
+ [
+ { name: "authenticatorData", type: "bytes" },
+ { name: "clientDataJSON", type: "string" },
+ { name: "signature", type: "uint256[2]" }
+ ],
+ [
+ "0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97631d00000000",
+ '"origin":"http://somelargdomainheresothatwehaveenoughbytes.com","crossOrigin":false',
+ [
+ 44941127272049826721201904734628716258498742255959991581049806490182030242267n,
+ 9910254599581058084911561569808925251374718953855182016200087235935345969636n
+ ]
+ ]
+ )
+ }
+
+ if (!signer) {
+ throw new Error("No signer found")
+ }
+
+ return {
+ signer,
+ data,
+ dynamic
+ }
+ })
+
return encodePacked(
["uint48", "uint48", "bytes"],
- [
- 0,
- 0,
- `0x${owners
- .map(
- (_) =>
- "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
- )
- .join("")}`
- ]
+ [0, 0, concatSignatures(signatures)]
)
},
async sign({ hash }) {
@@ -1591,26 +1820,42 @@ export async function toSafeSmartAccount<
})
const signatures = await Promise.all(
- localOwners.map(async (localOwner) => ({
- signer: localOwner.address,
- data: adjustVInSignature(
- "eth_sign",
- await localOwner.signMessage({
- message: {
- raw: toBytes(messageHash)
- }
+ localOwners.map(async (localOwner) => {
+ let signer = safeWebAuthnSharedSignerAddress
+ let data: Hex
+ let dynamic = true
+
+ if (!isWebAuthnAccount(localOwner)) {
+ signer = localOwner.address
+ data = adjustVInSignature(
+ "eth_sign",
+ await localOwner.signMessage({
+ message: {
+ raw: toBytes(messageHash)
+ }
+ })
+ )
+ dynamic = false
+ } else {
+ data = await getWebAuthnSignature({
+ owner: localOwner,
+ hash: messageHash
})
- )
- }))
- )
+ }
+
+ if (!signer) {
+ throw new Error("no signer found")
+ }
- signatures.sort((left, right) =>
- left.signer
- .toLowerCase()
- .localeCompare(right.signer.toLowerCase())
+ return {
+ signer,
+ dynamic,
+ data
+ }
+ })
)
- const signatureBytes = concat(signatures.map((sig) => sig.data))
+ const signatureBytes = concatSignatures(signatures)
return erc7579LaunchpadAddress
? concat([zeroAddress, signatureBytes])
@@ -1628,12 +1873,35 @@ export async function toSafeSmartAccount<
}
const signatures = await Promise.all(
- localOwners.map(async (localOwner) => ({
- signer: localOwner.address,
- data: adjustVInSignature(
- "eth_signTypedData",
-
- await localOwner.signTypedData({
+ localOwners.map(async (localOwner) => {
+ let signer = safeWebAuthnSharedSignerAddress
+ let data: Hex
+ let dynamic = true
+
+ if (!isWebAuthnAccount(localOwner)) {
+ signer = localOwner.address
+ data = adjustVInSignature(
+ "eth_sign",
+ await localOwner.signTypedData({
+ domain: {
+ chainId: await getMemoizedChainId(),
+ verifyingContract: await this.getAddress()
+ },
+ types: {
+ SafeMessage: [
+ { name: "message", type: "bytes" }
+ ]
+ },
+ primaryType: "SafeMessage",
+ message: {
+ message:
+ generateSafeMessageMessage(typedData)
+ }
+ })
+ )
+ dynamic = false
+ } else {
+ const messageHash = hashTypedData({
domain: {
chainId: await getMemoizedChainId(),
verifyingContract: await this.getAddress()
@@ -1648,17 +1916,26 @@ export async function toSafeSmartAccount<
message: generateSafeMessageMessage(typedData)
}
})
- )
- }))
- )
- signatures.sort((left, right) =>
- left.signer
- .toLowerCase()
- .localeCompare(right.signer.toLowerCase())
+ data = await getWebAuthnSignature({
+ owner: localOwner,
+ hash: messageHash
+ })
+ }
+
+ if (!signer) {
+ throw new Error("no signer found")
+ }
+
+ return {
+ signer,
+ dynamic,
+ data
+ }
+ })
)
- const signatureBytes = concat(signatures.map((sig) => sig.data))
+ const signatureBytes = concatSignatures(signatures)
return erc7579LaunchpadAddress
? concat([zeroAddress, signatureBytes])
@@ -1682,16 +1959,13 @@ export async function toSafeSmartAccount<
version,
entryPoint,
owners: localOwners,
- account: owner as OneOf<
- | EthereumProvider
- | WalletClient
- | LocalAccount
- >,
- chainId: await getMemoizedChainId(),
+ account: owner,
+ chainId,
signatures,
validAfter,
validUntil,
- safe4337ModuleAddress
+ safe4337ModuleAddress,
+ safeWebAuthnSharedSignerAddress
})
}
diff --git a/packages/wagmi-demo/src/PasskeyServerDemo.tsx b/packages/wagmi-demo/src/PasskeyServerDemo.tsx
index 162a9d68..4ae6fc63 100644
--- a/packages/wagmi-demo/src/PasskeyServerDemo.tsx
+++ b/packages/wagmi-demo/src/PasskeyServerDemo.tsx
@@ -25,6 +25,7 @@ import {
toWebAuthnAccount
} from "viem/account-abstraction"
import { baseSepolia } from "viem/chains"
+import { pimlicoApiKey } from "./wagmi"
const chain = baseSepolia
const pimlicoUrl = `https://api.pimlico.io/v2/${chain.id}/rpc?apikey=${pimlicoApiKey}`
diff --git a/packages/wagmi-demo/src/PasskeysDemo.tsx b/packages/wagmi-demo/src/PasskeysDemo.tsx
index 42bf6276..abee0cf2 100644
--- a/packages/wagmi-demo/src/PasskeysDemo.tsx
+++ b/packages/wagmi-demo/src/PasskeysDemo.tsx
@@ -4,7 +4,9 @@ import {
} from "permissionless"
import {
type ToKernelSmartAccountReturnType,
- toKernelSmartAccount
+ type ToSafeSmartAccountReturnType,
+ toKernelSmartAccount,
+ toSafeSmartAccount
} from "permissionless/accounts"
import { createPimlicoClient } from "permissionless/clients/pimlico"
import * as React from "react"
@@ -15,7 +17,8 @@ import {
type Transport,
createPublicClient,
getAddress,
- parseEther
+ parseEther,
+ zeroAddress
} from "viem"
import {
type P256Credential,
@@ -24,6 +27,7 @@ import {
toWebAuthnAccount
} from "viem/account-abstraction"
import { baseSepolia } from "viem/chains"
+import { pimlicoApiKey } from "./wagmi"
const chain = baseSepolia
const pimlicoUrl = `https://api.pimlico.io/v2/${chain.id}/rpc?apikey=${pimlicoApiKey}`
@@ -72,7 +76,7 @@ const pimlicoClient = createPimlicoClient({
transport: http(pimlicoUrl)
})
-export function PasskeysDemo() {
+function KernelSmartAccountDemo() {
const [smartAccountClient, setSmartAccountClient] =
React.useState<
SmartAccountClient<
@@ -82,7 +86,7 @@ export function PasskeysDemo() {
>
>()
const [credential, setCredential] = React.useState(() =>
- JSON.parse(localStorage.getItem("credential") || "null")
+ JSON.parse(localStorage.getItem("credential_kernel") || "null")
)
const [hash, setHash] = React.useState()
@@ -124,7 +128,7 @@ export function PasskeysDemo() {
const credential = await createWebAuthnCredential({
name: "Wallet"
})
- localStorage.setItem("credential", JSON.stringify(credential))
+ localStorage.setItem("credential_kernel", JSON.stringify(credential))
setCredential(credential)
}
@@ -134,9 +138,8 @@ export function PasskeysDemo() {
event.preventDefault()
if (!smartAccountClient) return
- const formData = new FormData(event.currentTarget)
- const to = formData.get("to") as `0x${string}`
- const value = formData.get("value") as string
+ const to = zeroAddress
+ const value = "0"
const hash = await smartAccountClient.sendUserOperation({
calls: [
@@ -189,7 +192,7 @@ export function PasskeysDemo() {
if (!credential)
return (
<>
- Account
+ Kernel Account
@@ -199,13 +202,11 @@ export function PasskeysDemo() {
return (
<>
- Account
+ Kernel Account
Address: {smartAccountClient?.account?.address}
Send User Operation
+ )}
+ {isVerified !== undefined && (
+ Verified: {isVerified.toString()}
+ )}
+
+ Sign typed data
+
+ {signatureTypedData && (
+
+ Signature:
{signatureTypedData}
+
+ )}
+ {isVerifiedTypedData !== undefined && (
+ Verified: {isVerifiedTypedData.toString()}
+ )}
+ >
+ )
+}
+
+export function PasskeysDemo({
+ path,
+ handleSelection
+}: {
+ path: string
+ handleSelection: (
+ component:
+ | "app"
+ | "passkey"
+ | "passkeyServer"
+ | "temp"
+ | "passkey/kernel"
+ | "passkey/safe"
+ ) => void
+}) {
+ if (path === "/passkey") {
+ return (
+
+
+
+
+ )
+ }
+ if (path === "/passkey/kernel") {
+ return
+ }
+ return
+}
diff --git a/packages/wagmi-demo/src/main.tsx b/packages/wagmi-demo/src/main.tsx
index ffce21b3..969bb95d 100644
--- a/packages/wagmi-demo/src/main.tsx
+++ b/packages/wagmi-demo/src/main.tsx
@@ -32,6 +32,7 @@ import {
} from "viem/account-abstraction"
import { PasskeyServerDemo } from "./PasskeyServerDemo"
import { PasskeysDemo } from "./PasskeysDemo"
+import { pimlicoApiKey } from "./wagmi"
globalThis.Buffer = Buffer
@@ -77,7 +78,7 @@ function Temp() {
const paymasterClient = createPimlicoClient({
chain: publicClient.chain,
transport: http(
- `https://api.pimlico.io/v2/${publicClient.chain.id}/rpc?apikey=${PIMLICO_API_KEY}`
+ `https://api.pimlico.io/v2/${publicClient.chain.id}/rpc?apikey=${pimlicoApiKey}`
)
})
@@ -86,7 +87,7 @@ function Temp() {
chain: publicClient.chain,
paymaster: paymasterClient,
bundlerTransport: http(
- `https://api.pimlico.io/v2/${publicClient.chain.id}/rpc?apikey=${PIMLICO_API_KEY}`
+ `https://api.pimlico.io/v2/${publicClient.chain.id}/rpc?apikey=${pimlicoApiKey}`
),
userOperation: {
estimateFeesPerGas: async () =>
@@ -172,21 +173,39 @@ function Temp() {
function Main() {
const [path, setPath] = React.useState(window.location.pathname)
+ React.useEffect(() => {
+ // Handle initial page load - push current state to history
+ if (window.location.pathname !== "/") {
+ window.history.replaceState(
+ { path: window.location.pathname },
+ "",
+ window.location.pathname
+ )
+ }
+ }, [])
+
const handleSelection = (
- component: "app" | "passkey" | "passkeyServer" | "temp"
+ component:
+ | "app"
+ | "passkey"
+ | "passkeyServer"
+ | "temp"
+ | "passkey/kernel"
+ | "passkey/safe"
) => {
const newPath = `/${component}`
- window.history.pushState({}, "", newPath)
+ window.history.pushState({ path: newPath }, "", newPath)
setPath(newPath)
}
const handleBack = () => {
- window.history.pushState({}, "", "/")
- setPath("/")
+ window.history.back()
}
+ console.log({ path })
React.useEffect(() => {
- const handlePopState = () => {
+ const handlePopState = (event: PopStateEvent) => {
+ console.log("popstate event fired", window.location.pathname)
setPath(window.location.pathname)
}
window.addEventListener("popstate", handlePopState)
@@ -233,7 +252,13 @@ function Main() {
{path === "/app" && }
- {path === "/passkey" && }
+ {(path === "/passkey" ||
+ path.startsWith("/passkey/")) && (
+
+ )}
{path === "/passkeyServer" && (
)}
diff --git a/packages/wagmi-demo/src/wagmi.ts b/packages/wagmi-demo/src/wagmi.ts
index 3e91eeaa..5a5f35b8 100644
--- a/packages/wagmi-demo/src/wagmi.ts
+++ b/packages/wagmi-demo/src/wagmi.ts
@@ -3,7 +3,7 @@ import { http, createConfig } from "wagmi"
import { coinbaseWallet } from "wagmi/connectors"
// TODO: Replace with your Pimlico API key
-// @ts-ignore
+export const pimlicoApiKey = ""
export const config = createConfig({
chains: [sepolia],
From a3cc2d2eeb5a395ea5b1eb3cbc7629e0927006e7 Mon Sep 17 00:00:00 2001
From: plusminushalf
Date: Wed, 13 Aug 2025 13:28:04 +0100
Subject: [PATCH 2/4] fix encoding
---
packages/permissionless/accounts/safe/signUserOperation.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/permissionless/accounts/safe/signUserOperation.ts b/packages/permissionless/accounts/safe/signUserOperation.ts
index e9965572..e10c4f0f 100644
--- a/packages/permissionless/accounts/safe/signUserOperation.ts
+++ b/packages/permissionless/accounts/safe/signUserOperation.ts
@@ -298,7 +298,7 @@ export async function signUserOperation(
components: [
{ type: "address", name: "signer" },
{ type: "bytes", name: "data" },
- { type: "boolean", name: "dynamic" }
+ { type: "bool", name: "dynamic" }
],
name: "signatures",
type: "tuple[]"
From 1e225a63c4735f0d581793348c333c19ccb681b3 Mon Sep 17 00:00:00 2001
From: plusminushalf
Date: Wed, 13 Aug 2025 17:26:00 +0100
Subject: [PATCH 3/4] Fix signTypedData test
---
packages/permissionless/accounts/safe/toSafeSmartAccount.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/permissionless/accounts/safe/toSafeSmartAccount.ts b/packages/permissionless/accounts/safe/toSafeSmartAccount.ts
index 7c2a6ea9..d2c4c63a 100644
--- a/packages/permissionless/accounts/safe/toSafeSmartAccount.ts
+++ b/packages/permissionless/accounts/safe/toSafeSmartAccount.ts
@@ -1881,7 +1881,7 @@ export async function toSafeSmartAccount<
if (!isWebAuthnAccount(localOwner)) {
signer = localOwner.address
data = adjustVInSignature(
- "eth_sign",
+ "eth_signTypedData",
await localOwner.signTypedData({
domain: {
chainId: await getMemoizedChainId(),
From 29ac0341359235ce4ad78f167df5fedb87022b9f Mon Sep 17 00:00:00 2001
From: plusminushalf
Date: Wed, 13 Aug 2025 17:27:27 +0100
Subject: [PATCH 4/4] Add changeset
---
.changeset/fresh-moons-bathe.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/fresh-moons-bathe.md
diff --git a/.changeset/fresh-moons-bathe.md b/.changeset/fresh-moons-bathe.md
new file mode 100644
index 00000000..99f5367a
--- /dev/null
+++ b/.changeset/fresh-moons-bathe.md
@@ -0,0 +1,5 @@
+---
+"permissionless": patch
+---
+
+Added passkeys support for all Safe versions and entrypoint 0.7 & 0.8