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

- - {userOpHash &&

User Operation Hash: {userOpHash}

} {hash &&

Transaction Hash: {hash}

} @@ -239,3 +240,209 @@ export function PasskeysDemo() { ) } + +function SafeSmartAccountDemo() { + const [smartAccountClient, setSmartAccountClient] = + React.useState< + SmartAccountClient< + Transport, + Chain, + ToSafeSmartAccountReturnType<"0.7"> + > + >() + const [credential, setCredential] = React.useState(() => + JSON.parse(localStorage.getItem("credential_safe") || "null") + ) + + const [hash, setHash] = React.useState() + const [userOpHash, setUserOpHash] = React.useState() + const [signature, setSignature] = React.useState() + const [isVerified, setIsVerified] = React.useState() + const [signatureTypedData, setSignatureTypedData] = React.useState() + const [isVerifiedTypedData, setIsVerifiedTypedData] = + React.useState() + + React.useEffect(() => { + if (!credential) return + toSafeSmartAccount({ + client: publicClient, + version: "1.4.1", + owners: [toWebAuthnAccount({ credential })], + entryPoint: { + address: entryPoint07Address, + version: "0.7" + } + }).then((account: ToSafeSmartAccountReturnType<"0.7">) => { + setSmartAccountClient( + createSmartAccountClient({ + account, + paymaster: pimlicoClient, + chain, + userOperation: { + estimateFeesPerGas: async () => + (await pimlicoClient.getUserOperationGasPrice()) + .fast + }, + bundlerTransport: http(pimlicoUrl) // Use any bundler url + }) + ) + }) + }, [credential]) + + const createCredential = async () => { + const credential = await createWebAuthnCredential({ + name: "Wallet" + }) + localStorage.setItem("credential_safe", JSON.stringify(credential)) + setCredential(credential) + } + + const sendUserOperation = async ( + event: React.FormEvent + ) => { + event.preventDefault() + if (!smartAccountClient) return + + const to = zeroAddress + const value = "0" + + const hash = await smartAccountClient.sendUserOperation({ + calls: [ + { + to, + value: parseEther(value) + } + ], + paymaster: true + }) + setUserOpHash(hash) + + const { receipt } = + await smartAccountClient.waitForUserOperationReceipt({ + hash + }) + setHash(receipt.transactionHash) + } + + const signMessage = async () => { + if (!smartAccountClient) return + const message = "Hello, world!" + const signature = await smartAccountClient.signMessage({ + message + }) + + const isVerified = await publicClient.verifyMessage({ + address: smartAccountClient.account.address, + message, + signature + }) + + setSignature(signature) + setIsVerified(isVerified) + } + + const signTypedData = async () => { + if (!smartAccountClient) return + const signature = await smartAccountClient.signTypedData(typedData) + + const isVerified = await publicClient.verifyTypedData({ + ...typedData, + address: smartAccountClient.account.address, + signature + }) + setIsVerifiedTypedData(isVerified) + setSignatureTypedData(signature) + } + + if (!credential) + return ( + <> +

Safe Account

+ + + ) + if (!smartAccountClient) return

Loading...

+ + return ( + <> +

Safe Account

+

Address: {smartAccountClient?.account?.address}

+ +

Send User Operation

+ + + {userOpHash &&

User Operation Hash: {userOpHash}

} + {hash &&

Transaction Hash: {hash}

} + + +

Sign message

+ + {signature && ( +

+ Signature:

{signature}
+

+ )} + {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