diff --git a/.github/workflows/ci_passkey_example.yml b/.github/workflows/ci_passkey_example.yml index 973f91227..329899175 100644 --- a/.github/workflows/ci_passkey_example.yml +++ b/.github/workflows/ci_passkey_example.yml @@ -21,8 +21,8 @@ jobs: cache: pnpm - run: | pnpm install - pnpm run -F "{examples/4337-passkeys}" lint - pnpm run -F "{examples/4337-passkeys}" build + pnpm run --filter "@safe-global/safe-modules-example-4337-passkeys" lint + pnpm run --filter "@safe-global/safe-modules-example-4337-passkeys" build env: VITE_WC_CLOUD_PROJECT_ID: ${{ secrets.VITE_WC_CLOUD_PROJECT_ID }} VITE_WC_4337_BUNDLER_URL: ${{ secrets.VITE_WC_4337_BUNDLER_URL }} diff --git a/examples/4337-passkeys-singleton-signer/package.json b/examples/4337-passkeys-singleton-signer/package.json index 58a374b76..02beaab14 100644 --- a/examples/4337-passkeys-singleton-signer/package.json +++ b/examples/4337-passkeys-singleton-signer/package.json @@ -1,5 +1,5 @@ { - "name": "@safe-global/safe-modules-example-4337-passkeys-singleton-signer", + "name": "@safe-global/safe-modules-example-4337-passkeys", "private": true, "version": "0.0.0", "type": "module", @@ -13,7 +13,8 @@ "dependencies": { "@account-abstraction/contracts": "0.7.0", "@safe-global/safe-4337": "0.3.0", - "@safe-global/safe-passkey": "0.2.0-alpha.1", + "@safe-global/safe-contracts": "^1.4.1-build.0", + "@safe-global/safe-passkey": "workspace:0.2.0-alpha.2", "@web3modal/ethers": "^4.1.11", "ethers": "^6.12.1", "react": "^18.3.1", diff --git a/examples/4337-passkeys/.env.example b/examples/4337-passkeys/.env.example deleted file mode 100644 index 30b09657d..000000000 --- a/examples/4337-passkeys/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -// Get projectId at https://cloud.walletconnect.com -VITE_WC_CLOUD_PROJECT_ID= -// 4337 Bundler URL. We recommend https://www.pimlico.io/ -VITE_WC_4337_BUNDLER_URL= - diff --git a/examples/4337-passkeys/.eslintrc.cjs b/examples/4337-passkeys/.eslintrc.cjs deleted file mode 100644 index 5d99e4c14..000000000 --- a/examples/4337-passkeys/.eslintrc.cjs +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - ignorePatterns: ['dist', '.eslintrc.cjs'], - extends: ['../../.eslintrc.js', 'plugin:react-hooks/recommended'], - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { - allowExportNames: ['meta', 'links', 'headers', 'loader', 'action'], - allowConstantExport: true, - }, - ], - }, -}; diff --git a/examples/4337-passkeys/.gitignore b/examples/4337-passkeys/.gitignore deleted file mode 100644 index a547bf36d..000000000 --- a/examples/4337-passkeys/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/examples/4337-passkeys/README.md b/examples/4337-passkeys/README.md deleted file mode 100644 index c18bf093f..000000000 --- a/examples/4337-passkeys/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Safe + 4337 + Passkeys example application - -This minimalistic example application demonstrates a Safe{Core} Smart Account deployment leveraging 4337 and Passkeys. It uses experimental and unaudited (at the moment of writing) contracts: [SafeSignerLaunchpad](https://github.com/safe-global/safe-modules/blob/main/modules/passkey/contracts/4337/SafeSignerLaunchpad.sol) and [SafeWebAuthnSignerProxy](https://github.com/safe-global/safe-modules/blob/main/modules/passkey/contracts/SafeWebAuthnSignerProxy.sol), which uses [FreshCryptoLib](https://github.com/rdubois-crypto/FreshCryptoLib/) under the hood. - -## Running the app - -### Clone the repository - -```bash -git clone https://github.com/safe-global/safe-modules.git -cd safe-modules -``` - -### Install dependencies - -```bash -pnpm install -``` - -### Fill in the environment variables - -```bash -cp .env.example .env -``` - -and fill in the variables in `.env` file. - -Helpful links: - -- 4337 Bundler: https://www.pimlico.io/ -- WalletConnect: https://cloud.walletconnect.com/ - -### Run the app in development mode - -```bash -pnpm run -F {examples/4337-passkeys} dev -``` - -## Config adjustments - -The application depends on a specific set of contracts deployed on a specific network. If you want to use your own contracts, you need to adjust the configuration in `src/config.ts` file. diff --git a/examples/4337-passkeys/index.html b/examples/4337-passkeys/index.html deleted file mode 100644 index a89d05fdf..000000000 --- a/examples/4337-passkeys/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Safe 4337 Passkeys Example - - -
- - - diff --git a/examples/4337-passkeys/package.json b/examples/4337-passkeys/package.json deleted file mode 100644 index 95f69e4d5..000000000 --- a/examples/4337-passkeys/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@safe-global/safe-modules-example-4337-passkeys", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "lint:fix": "pnpm run lint -- --fix", - "preview": "vite preview" - }, - "dependencies": { - "@account-abstraction/contracts": "0.7.0", - "@safe-global/safe-4337": "0.3.0", - "@safe-global/safe-passkey": "workspace:0.2.0-alpha.2", - "@web3modal/ethers": "^4.1.11", - "ethers": "^6.12.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.23.1" - }, - "devDependencies": { - "@types/react": "^18.3.2", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react-swc": "^3.6.0", - "react-router": "^6.23.1", - "typescript": "^5.4.5", - "vite": "^5.2.11", - "vite-plugin-commonjs": "^0.10.1" - } -} diff --git a/examples/4337-passkeys/public/safe-logo.svg b/examples/4337-passkeys/public/safe-logo.svg deleted file mode 100644 index cd62c9eaf..000000000 --- a/examples/4337-passkeys/public/safe-logo.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/examples/4337-passkeys/src/components/ConnectButton.tsx b/examples/4337-passkeys/src/components/ConnectButton.tsx deleted file mode 100644 index c9c93598c..000000000 --- a/examples/4337-passkeys/src/components/ConnectButton.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ConnectButton() { - return -} diff --git a/examples/4337-passkeys/src/components/ConnectWallet.tsx b/examples/4337-passkeys/src/components/ConnectWallet.tsx deleted file mode 100644 index c749029d3..000000000 --- a/examples/4337-passkeys/src/components/ConnectWallet.tsx +++ /dev/null @@ -1,9 +0,0 @@ -function ConnectWallet() { - return ( -
-

Please follow the instructions to connect your wallet by clicking the "Connect Wallet" button.

-
- ) -} - -export { ConnectWallet } diff --git a/examples/4337-passkeys/src/components/MissingAccountFundsCard.tsx b/examples/4337-passkeys/src/components/MissingAccountFundsCard.tsx deleted file mode 100644 index 71c7d618b..000000000 --- a/examples/4337-passkeys/src/components/MissingAccountFundsCard.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ethers } from 'ethers' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets' -import { useMemo, useState } from 'react' - -function MissingAccountFundsCard({ - provider, - missingAccountFunds, - safeAddress, -}: { - provider: ethers.Eip1193Provider - missingAccountFunds: bigint - safeAddress: string -}) { - const [loading, setLoading] = useState(false) - const ethFormatted = useMemo(() => ethers.formatEther(missingAccountFunds), [missingAccountFunds]) - - const handlePrefundClick = async () => { - setLoading(true) - const jsonRpcProvider = getJsonRpcProviderFromEip1193Provider(provider) - - const signer = await jsonRpcProvider.getSigner() - - try { - await signer - .sendTransaction({ - to: safeAddress, - value: missingAccountFunds, - }) - .then((tx) => tx.wait(1)) - } catch (error) { - console.error(error) - } finally { - setLoading(false) - } - } - - return ( -
-

You need to prefund your safe with {ethFormatted} ETH. Click the button below to prefund your safe.

- - -
- ) -} - -export { MissingAccountFundsCard } diff --git a/examples/4337-passkeys/src/components/SendNativeToken.tsx b/examples/4337-passkeys/src/components/SendNativeToken.tsx deleted file mode 100644 index bfb8aabf8..000000000 --- a/examples/4337-passkeys/src/components/SendNativeToken.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { FormEventHandler, useMemo, useState } from 'react' -import { ethers } from 'ethers' -import { useFeeData } from '../hooks/useFeeData.ts' -import { getMissingAccountFunds, getUnsignedUserOperation, packGasParameters, UnsignedPackedUserOperation } from '../logic/userOp.ts' -import { useUserOpGasLimitEstimation } from '../hooks/useUserOpGasEstimation.ts' -import { RequestStatus } from '../utils.ts' -import { MissingAccountFundsCard } from './MissingAccountFundsCard.tsx' - -type Props = { - balanceWei: bigint - onSend: (userOp: UnsignedPackedUserOperation) => Promise - walletProvider: ethers.Eip1193Provider - safeAddress: string - nonce: bigint - accountEntryPointBalance: bigint - signerAddress: string -} - -function SendNativeToken({ balanceWei, onSend, walletProvider, safeAddress, nonce, accountEntryPointBalance, signerAddress }: Props) { - const [amount, setAmount] = useState('') - const [to, setTo] = useState('') - const [error, setError] = useState('') - const [feeData, feeDataStatus] = useFeeData(walletProvider) - const [userOpHash, setUserOpHash] = useState(null) - - const userOp = useMemo( - () => - getUnsignedUserOperation( - { - to: to === '' ? `0x${'beef'.repeat(10)}` : to, - value: amount === '' ? '0x0' : ethers.parseEther(amount), - data: '0x', - operation: 0, - }, - safeAddress, - nonce, - ), - [safeAddress, nonce, to, amount], - ) - - const { userOpGasLimitEstimation, status: estimationStatus } = useUserOpGasLimitEstimation(userOp, signerAddress) - - const gasParametersReady = - feeDataStatus === RequestStatus.SUCCESS && - estimationStatus === RequestStatus.SUCCESS && - typeof userOpGasLimitEstimation !== 'undefined' && - feeData?.maxFeePerGas != null && - feeData?.maxPriorityFeePerGas != null - - const userOperationFee = useMemo(() => { - if (!gasParametersReady) { - return 0n - } - - // @ts-expect-error it is handled in the if statement above - return getMissingAccountFunds(feeData!.maxFeePerGas, userOpGasLimitEstimation!, 0n) - }, [feeData, gasParametersReady, userOpGasLimitEstimation]) - const missingAccountFundsForEntrypoint = - userOperationFee - accountEntryPointBalance > 0 ? userOperationFee - accountEntryPointBalance : 0n - const missingAccountFundsFromTheBalance = balanceWei - missingAccountFundsForEntrypoint - const maxAmount = missingAccountFundsFromTheBalance >= 0n ? ethers.formatEther(balanceWei - missingAccountFundsForEntrypoint) : '0' - - const send: FormEventHandler = async (e) => { - e.preventDefault() - setError('') - setUserOpHash(null) - - if (!ethers.isAddress(to)) { - setError('Invalid address') - return - } - - if (amount === null || ethers.parseEther(amount) > balanceWei || missingAccountFundsFromTheBalance < 0n) { - setError('Invalid amount') - return - } - - if (!gasParametersReady) return - - const userOpToSign: UnsignedPackedUserOperation = { - ...userOp, - ...packGasParameters({ - verificationGasLimit: userOpGasLimitEstimation.verificationGasLimit, - callGasLimit: userOpGasLimitEstimation.callGasLimit, - maxPriorityFeePerGas: feeData?.maxPriorityFeePerGas, - maxFeePerGas: feeData?.maxFeePerGas, - }), - preVerificationGas: userOpGasLimitEstimation.preVerificationGas, - } - try { - const userOpHash = await onSend(userOpToSign) - setUserOpHash(userOpHash) - } catch (e) { - console.error(e) - setError('Error sending transaction') - } - } - - return ( -
-

Send Native Token

- - -

Approx. fee: {ethers.formatEther(userOperationFee)} ETH

- {missingAccountFundsFromTheBalance < 0n && ( - - )} - - {error &&

{error}

} - {userOpHash && ( -

- Your transaction is confirming. Track it on{' '} - jiffyscan. ⏳ -

- )} - - ) -} - -export { SendNativeToken } diff --git a/examples/4337-passkeys/src/components/SwitchNetwork.tsx b/examples/4337-passkeys/src/components/SwitchNetwork.tsx deleted file mode 100644 index efe5cc4a7..000000000 --- a/examples/4337-passkeys/src/components/SwitchNetwork.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Eip1193Provider } from 'ethers' -import { switchToSepolia } from '../logic/wallets.ts' -import { useState } from 'react' - -function SwitchNetwork({ walletProvider }: { walletProvider: Eip1193Provider }) { - const [error, setError] = useState() - - const handleSwitchToSepoliaClick = () => { - setError(undefined) - try { - switchToSepolia(walletProvider) - } catch (error) { - if (error instanceof Error) { - setError(error.message) - } else { - setError('Unknown error when switching to Ethereum Sepolia test network') - } - } - } - - return ( -
-

Please switch to Ethereum Sepolia test network to continue

- - {error &&

Error: {error}

} -
- ) -} - -export { SwitchNetwork } diff --git a/examples/4337-passkeys/src/config.ts b/examples/4337-passkeys/src/config.ts deleted file mode 100644 index 25a4df593..000000000 --- a/examples/4337-passkeys/src/config.ts +++ /dev/null @@ -1,43 +0,0 @@ -// 11155111 = Sepolia testnet chain id -const APP_CHAIN_ID = 11155111 - -// Sep testnet shortname -// https://eips.ethereum.org/EIPS/eip-3770 -const APP_CHAIN_SHORTNAME = 'sep' - -/* - Some of the contracts used in the PoC app are still experimental, and not included in - the production deployment packages, thus we need to hardcode their addresses here. - Deployment commit: https://github.com/safe-global/safe-modules/commit/3853f34f31837e0a0aee47a4452564278f8c62ba -*/ -const SAFE_SIGNER_LAUNCHPAD_ADDRESS = '0x2804BAA6635d97281FB4d1F011B4BF55DD7A5325' - -const SAFE_4337_MODULE_ADDRESS = '0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226' - -const SAFE_MODULE_SETUP_ADDRESS = '0x2dd68b007B46fBe91B9A7c3EDa5A7a1063cB5b47' - -const WEBAUTHN_SIGNER_FACTORY_ADDRESS = '0xc40156AbFEE908E2e3269DA84fa9609bcCDDec60' - -const P256_VERIFIER_ADDRESS = '0xcA89CBa4813D5B40AeC6E57A30d0Eeb500d6531b' // FCLP256Verifier - -const SAFE_PROXY_FACTORY_ADDRESS = '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67' - -const SAFE_SINGLETON_ADDRESS = '0x29fcB43b46531BcA003ddC8FCB67FFE91900C762' - -const ENTRYPOINT_ADDRESS = '0x0000000071727De22E5E9d8BAf0edAc6f37da032' - -const XANDER_BLAZE_NFT_ADDRESS = '0xBb9ebb7b8Ee75CDBf64e5cE124731A89c2BC4A07' - -export { - SAFE_MODULE_SETUP_ADDRESS, - APP_CHAIN_ID, - ENTRYPOINT_ADDRESS, - SAFE_SIGNER_LAUNCHPAD_ADDRESS, - SAFE_4337_MODULE_ADDRESS, - SAFE_PROXY_FACTORY_ADDRESS, - SAFE_SINGLETON_ADDRESS, - XANDER_BLAZE_NFT_ADDRESS, - WEBAUTHN_SIGNER_FACTORY_ADDRESS, - P256_VERIFIER_ADDRESS, - APP_CHAIN_SHORTNAME, -} diff --git a/examples/4337-passkeys/src/hooks/UseOutletContext.tsx b/examples/4337-passkeys/src/hooks/UseOutletContext.tsx deleted file mode 100644 index d1241fbfb..000000000 --- a/examples/4337-passkeys/src/hooks/UseOutletContext.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useOutletContext as useOutletContextRRD } from 'react-router' -import { ethers } from 'ethers' - -type OutletContext = { - walletProvider: ethers.Eip1193Provider -} - -function useOutletContext() { - return useOutletContextRRD() -} - -export { useOutletContext } diff --git a/examples/4337-passkeys/src/hooks/useCodeAtAddress.ts b/examples/4337-passkeys/src/hooks/useCodeAtAddress.ts deleted file mode 100644 index 53c230e72..000000000 --- a/examples/4337-passkeys/src/hooks/useCodeAtAddress.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ethers } from 'ethers' -import { useEffect, useState } from 'react' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets' -import { RequestStatus } from '../utils' - -type Options = { - pollInterval?: number -} - -/** - * Custom hook that retrieves the code at a given address using an Eip1193Provider. - * - * @param provider - The Eip1193Provider instance. - * @param address - The address to retrieve the code from. - * @param opts - Optional configuration options. - * @returns An array containing the code and the request status. - */ -function useCodeAtAddress(provider: ethers.Eip1193Provider, address: string, opts?: Options): [string, RequestStatus] { - const [code, setCode] = useState('') - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let cancelled = false - - async function updateBalance() { - try { - const jsonRpcProvider = getJsonRpcProviderFromEip1193Provider(provider) - const balance = await jsonRpcProvider.getCode(address) - if (!cancelled) { - setCode(balance) - setStatus(RequestStatus.SUCCESS) - } - } catch (e) { - if (!cancelled) { - setStatus(RequestStatus.ERROR) - } - } - } - - if (provider) { - const pollInterval = opts?.pollInterval || 5000 - - updateBalance() - const interval = setInterval(updateBalance, pollInterval) - - return () => { - cancelled = true - clearInterval(interval) - } - } - }, [provider, address, opts?.pollInterval]) - - return [code, status] -} - -export { useCodeAtAddress } diff --git a/examples/4337-passkeys/src/hooks/useEntryPointAccountBalance.ts b/examples/4337-passkeys/src/hooks/useEntryPointAccountBalance.ts deleted file mode 100644 index d94f20c54..000000000 --- a/examples/4337-passkeys/src/hooks/useEntryPointAccountBalance.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Eip1193Provider } from 'ethers' -import { getAccountEntryPointBalance } from '../logic/userOp.ts' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets.ts' -import { useEffect, useState } from 'react' -import { RequestStatus } from '../utils.ts' - -function useEntryPointAccountBalance( - walletProvider: Eip1193Provider, - safeAddress: string, - opts: { pollInterval: number } = { pollInterval: 5000 }, -): [bigint | null, RequestStatus] { - const [nonce, setNonce] = useState(null) - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let cancelled = false - - const fetchBalance = async () => { - if (cancelled) return - setStatus(RequestStatus.LOADING) - try { - const provider = getJsonRpcProviderFromEip1193Provider(walletProvider) - const newNonce = await getAccountEntryPointBalance(provider, safeAddress) - if (!cancelled) { - setNonce(newNonce) - setStatus(RequestStatus.SUCCESS) - } - } catch (error) { - if (!cancelled) { - setStatus(RequestStatus.ERROR) - console.error('Error fetching nonce:', error) - } - } - } - - fetchBalance() - - const interval = setInterval(fetchBalance, opts.pollInterval) - return () => { - cancelled = true - clearInterval(interval) - } - }, [walletProvider, safeAddress, opts.pollInterval]) - - return [nonce, status] -} - -export { useEntryPointAccountBalance } diff --git a/examples/4337-passkeys/src/hooks/useEntryPointAccountNonce.ts b/examples/4337-passkeys/src/hooks/useEntryPointAccountNonce.ts deleted file mode 100644 index 142506348..000000000 --- a/examples/4337-passkeys/src/hooks/useEntryPointAccountNonce.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Eip1193Provider } from 'ethers' -import { getNonceFromEntryPoint } from '../logic/userOp.ts' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets.ts' -import { useEffect, useState } from 'react' -import { RequestStatus } from '../utils.ts' - -function useEntryPointAccountNonce( - walletProvider: Eip1193Provider, - safeAddress: string, - opts: { pollInterval: number } = { pollInterval: 5000 }, -): [bigint | null, RequestStatus] { - const [nonce, setNonce] = useState(null) - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let cancelled = false - - const fetchNonce = async () => { - if (cancelled) return - setStatus(RequestStatus.LOADING) - try { - const provider = getJsonRpcProviderFromEip1193Provider(walletProvider) - const newNonce = await getNonceFromEntryPoint(provider, safeAddress) - if (!cancelled) { - setNonce(newNonce) - setStatus(RequestStatus.SUCCESS) - } - } catch (error) { - if (!cancelled) { - setStatus(RequestStatus.ERROR) - console.error('Error fetching nonce:', error) - } - } - } - - fetchNonce() - - const interval = setInterval(fetchNonce, opts.pollInterval) - return () => { - cancelled = true - clearInterval(interval) - } - }, [walletProvider, safeAddress, opts.pollInterval]) - - return [nonce, status] -} - -export { useEntryPointAccountNonce } diff --git a/examples/4337-passkeys/src/hooks/useFeeData.ts b/examples/4337-passkeys/src/hooks/useFeeData.ts deleted file mode 100644 index 81e174778..000000000 --- a/examples/4337-passkeys/src/hooks/useFeeData.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ethers } from 'ethers' -import { useEffect, useState } from 'react' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets' -import { RequestStatus } from '../utils' - -type FeeData = Omit - -/** - * Applies a multiplier to the provided fee data. Pimlico bundler started requiring - * a 1.2x multiplier on gas prices. Customize it for your bundler or use their proprietary - * method that will return the prices with the multiplier applied. - * @param feeData The fee data to apply the multiplier to. - * @param multiplier The multiplier to apply to the fee data. - * @returns The fee data with the multiplier applied. - */ -function applyMultiplier(feeData: FeeData, multiplier: bigint = 120n): FeeData { - if (!feeData.gasPrice || !feeData.maxFeePerGas || !feeData.maxPriorityFeePerGas) return feeData - - return { - gasPrice: (feeData.gasPrice * multiplier) / 100n, - maxFeePerGas: (feeData.maxFeePerGas * multiplier) / 100n, - maxPriorityFeePerGas: (feeData.maxPriorityFeePerGas * multiplier) / 100n, - } -} - -/** - * Custom hook that fetches fee data using the provided Eip1193Provider. - * @param provider The Eip1193Provider instance. - * @returns A tuple containing the fee data and the request status. - */ -function useFeeData(provider: ethers.Eip1193Provider): [FeeData | undefined, RequestStatus] { - const [feeData, setFeeData] = useState() - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let isMounted = true - - setStatus(RequestStatus.LOADING) - const jsonRpcProvider = getJsonRpcProviderFromEip1193Provider(provider) - jsonRpcProvider - .getFeeData() - .then((feeData) => { - if (!isMounted) return - setFeeData(applyMultiplier(feeData)) - setStatus(RequestStatus.SUCCESS) - }) - .catch((error) => { - if (!isMounted) return - console.error(error) - setStatus(RequestStatus.ERROR) - }) - - return () => { - isMounted = false - } - }, [provider]) - - return [feeData, status] -} - -export { useFeeData } diff --git a/examples/4337-passkeys/src/hooks/useLocalStorageState.ts b/examples/4337-passkeys/src/hooks/useLocalStorageState.ts deleted file mode 100644 index 06682fcbd..000000000 --- a/examples/4337-passkeys/src/hooks/useLocalStorageState.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from 'react' -import { setItem, getItem } from '../logic/storage.ts' - -/** - * Custom hook that manages state in local storage. - * - * @template T - The type of the state value. - * @param {string} key - The key used to store the state value in local storage. - * @param {T} initialValue - The initial value of the state. - * @returns {[T, React.Dispatch>]} - An array containing the current state value and a function to update the state. - */ -function useLocalStorageState(key: string, initialValue: T): [T, React.Dispatch>] { - const [state, setState] = useState(() => { - const storedValue = getItem(key) - - // this naive hook might write 'undefined' or 'null' to local storage as a string - if (storedValue && storedValue !== 'undefined' && storedValue !== 'null') { - try { - return JSON.parse(storedValue) as T - } catch { - // trick eslint with a no-op - } - } - - return initialValue - }) - - useEffect(() => { - setItem(key, state) - }, [key, state]) - - return [state, setState] -} - -export { useLocalStorageState } diff --git a/examples/4337-passkeys/src/hooks/useNativeTokenBalance.ts b/examples/4337-passkeys/src/hooks/useNativeTokenBalance.ts deleted file mode 100644 index 188a02a9c..000000000 --- a/examples/4337-passkeys/src/hooks/useNativeTokenBalance.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ethers } from 'ethers' -import { useEffect, useState } from 'react' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets' -import { RequestStatus } from '../utils' - -type Options = { - pollInterval?: number -} - -/** - * Custom hook to fetch the balance of the native token for a given address. - * @param provider The Eip1193Provider instance. - * @param address The address for which to fetch the balance. - * @param opts Optional configuration options. - * @returns An array containing the balance as a bigint and the request status. - */ -function useNativeTokenBalance(provider: ethers.Eip1193Provider, address: string, opts?: Options): [bigint, RequestStatus] { - const [balance, setBalance] = useState(0n) - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let cancelled = false - - async function updateBalance() { - try { - const jsonRpcProvider = getJsonRpcProviderFromEip1193Provider(provider) - const balance = await jsonRpcProvider.getBalance(address) - if (!cancelled) { - setBalance(balance) - setStatus(RequestStatus.SUCCESS) - } - } catch (e) { - if (!cancelled) { - setStatus(RequestStatus.ERROR) - } - } - } - - if (provider) { - const pollInterval = opts?.pollInterval || 5000 - - updateBalance() - const interval = setInterval(updateBalance, pollInterval) - - return () => { - cancelled = true - clearInterval(interval) - } - } - }, [provider, address, opts?.pollInterval]) - - return [balance, status] -} - -export { useNativeTokenBalance } diff --git a/examples/4337-passkeys/src/hooks/useSignerAddressFromPubkeyCoords.ts b/examples/4337-passkeys/src/hooks/useSignerAddressFromPubkeyCoords.ts deleted file mode 100644 index 2c8a0e667..000000000 --- a/examples/4337-passkeys/src/hooks/useSignerAddressFromPubkeyCoords.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getSignerAddressFromPubkeyCoords } from '../logic/safe.ts' -import { RequestStatus } from '../utils.ts' -import { ethers } from 'ethers' -import { useEffect, useState } from 'react' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets.ts' - -/** - * Custom hook for getting the signer address from the pubkey coordinates. - * @param x The x coordinate of the pubkey. - * @param y The y coordinate of the pubkey. - * @returns The signer address. - */ -function useSignerAddressFromPubkeyCoords(provider: ethers.Eip1193Provider, x: string, y: string): [string | null, RequestStatus] { - const [signerAddress, setSignerAddress] = useState(null) - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let isMounted = true - async function getSignerAddress() { - setStatus(RequestStatus.LOADING) - - try { - const address = await getSignerAddressFromPubkeyCoords(getJsonRpcProviderFromEip1193Provider(provider), x, y) - if (!isMounted) return - setSignerAddress(address) - setStatus(RequestStatus.SUCCESS) - } catch (error) { - if (!isMounted) return - console.error(error) - setStatus(RequestStatus.ERROR) - } - } - - getSignerAddress() - - return () => { - isMounted = false - } - }, [provider, x, y]) - - return [signerAddress, status] -} - -export { useSignerAddressFromPubkeyCoords } diff --git a/examples/4337-passkeys/src/hooks/useUserOpGasEstimation.ts b/examples/4337-passkeys/src/hooks/useUserOpGasEstimation.ts deleted file mode 100644 index 875ae5a9d..000000000 --- a/examples/4337-passkeys/src/hooks/useUserOpGasEstimation.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useState, useEffect } from 'react' -import { UnsignedPackedUserOperation, UserOpGasLimitEstimation, estimateUserOpGasLimit } from '../logic/userOp' -import { RequestStatus } from '../utils' - -/** - * Custom hook for estimating the gas limit of a user operation. - * @param userOp The unsigned user operation. - * @returns An object containing the user operation gas limit estimation and the request status. - */ -function useUserOpGasLimitEstimation(userOp: UnsignedPackedUserOperation): { - userOpGasLimitEstimation: UserOpGasLimitEstimation | undefined - status: RequestStatus -} -function useUserOpGasLimitEstimation( - userOp: UnsignedPackedUserOperation, - signerAddress: string, -): { - userOpGasLimitEstimation: UserOpGasLimitEstimation | undefined - status: RequestStatus -} -function useUserOpGasLimitEstimation(userOp: UnsignedPackedUserOperation, signerAddress?: string) { - const [userOpGasLimitEstimation, setUserOpGasLimitEstimation] = useState(undefined) - const [status, setStatus] = useState(RequestStatus.NOT_REQUESTED) - - useEffect(() => { - let isMounted = true - async function estimate() { - setStatus(RequestStatus.LOADING) - - try { - const estimation = await estimateUserOpGasLimit(userOp, signerAddress) - if (!isMounted) return - // Increase the gas limit by 50%, otherwise the user op will fail during simulation with "verification more than gas limit" error - estimation.verificationGasLimit = '0x' + ((BigInt(estimation.verificationGasLimit) * 15n) / 10n).toString(16) - setUserOpGasLimitEstimation(estimation) - setStatus(RequestStatus.SUCCESS) - } catch (error) { - if (!isMounted) return - console.error(error) - setStatus(RequestStatus.ERROR) - } - } - - estimate() - - return () => { - isMounted = false - } - }, [userOp, signerAddress]) - - return { userOpGasLimitEstimation, status } -} - -export { useUserOpGasLimitEstimation } diff --git a/examples/4337-passkeys/src/index.css b/examples/4337-passkeys/src/index.css deleted file mode 100644 index cb3fdcdb7..000000000 --- a/examples/4337-passkeys/src/index.css +++ /dev/null @@ -1,100 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - justify-content: flex-start; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; - - @media screen and (max-width: 768px) { - font-size: 2.4em; - } -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} - -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.logo { - height: 6em; - will-change: filter; - transition: filter 300ms; - - @media screen and (max-width: 768px) { - height: 3em; - } -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} diff --git a/examples/4337-passkeys/src/logic/erc721.ts b/examples/4337-passkeys/src/logic/erc721.ts deleted file mode 100644 index b06f450b2..000000000 --- a/examples/4337-passkeys/src/logic/erc721.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ethers } from 'ethers' -import { getRandomUint256 } from '../utils' - -/** - * Encodes the data for a safe mint operation. - * @param to The address to mint the token to. - * @param tokenId The ID of the token to mint. - * @returns The encoded data for the safe mint operation. - */ -function encodeSafeMintData(to: string, tokenId: ethers.BigNumberish = getRandomUint256()): string { - const abi = ['function safeMint(address to, uint256 tokenId) external'] - return new ethers.Interface(abi).encodeFunctionData('safeMint', [to, tokenId]) -} - -export { encodeSafeMintData } diff --git a/examples/4337-passkeys/src/logic/passkeys.ts b/examples/4337-passkeys/src/logic/passkeys.ts deleted file mode 100644 index 028082931..000000000 --- a/examples/4337-passkeys/src/logic/passkeys.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { ethers } from 'ethers' -import { hexStringToUint8Array } from '../utils.ts' -import { getItem, setItem } from './storage.ts' - -type PasskeyCredential = { - id: 'string' - rawId: ArrayBuffer - response: { - clientDataJSON: ArrayBuffer - attestationObject: ArrayBuffer - getPublicKey(): ArrayBuffer - } - type: 'public-key' -} - -type PasskeyCredentialWithPubkeyCoordinates = PasskeyCredential & { - pubkeyCoordinates: { - x: string - y: string - } -} - -const PASSKEY_LOCALSTORAGE_KEY = 'passkeyId' - -/** - * Creates a passkey for signing. - * - * @returns A promise that resolves to a PasskeyCredentialWithPubkeyCoordinates object, which includes the passkey credential information and its public key coordinates. - * @throws Throws an error if the passkey generation fails or if the credential received is null. - */ -async function createPasskey(): Promise { - // Generate a passkey credential using WebAuthn API - const passkeyCredential = (await navigator.credentials.create({ - publicKey: { - pubKeyCredParams: [ - { - // ECDSA w/ SHA-256: https://datatracker.ietf.org/doc/html/rfc8152#section-8.1 - alg: -7, - type: 'public-key', - }, - ], - challenge: crypto.getRandomValues(new Uint8Array(32)), - rp: { - name: 'Safe Wallet', - }, - user: { - displayName: 'Safe Owner', - id: crypto.getRandomValues(new Uint8Array(32)), - name: 'safe-owner', - }, - timeout: 60000, - attestation: 'none', - }, - })) as PasskeyCredential | null - - if (!passkeyCredential) { - throw new Error('Failed to generate passkey. Received null as a credential') - } - - // Import the public key to later export it to get the XY coordinates - const key = await crypto.subtle.importKey( - 'spki', - passkeyCredential.response.getPublicKey(), - { - name: 'ECDSA', - namedCurve: 'P-256', - hash: { name: 'SHA-256' }, - }, - true, // boolean that marks the key as an exportable one - ['verify'], - ) - - // Export the public key in JWK format and extract XY coordinates - const exportedKeyWithXYCoordinates = await crypto.subtle.exportKey('jwk', key) - if (!exportedKeyWithXYCoordinates.x || !exportedKeyWithXYCoordinates.y) { - throw new Error('Failed to retrieve x and y coordinates') - } - - // Create a PasskeyCredentialWithPubkeyCoordinates object - const passkeyWithCoordinates: PasskeyCredentialWithPubkeyCoordinates = Object.assign(passkeyCredential, { - pubkeyCoordinates: { - x: '0x' + Buffer.from(exportedKeyWithXYCoordinates.x, 'base64').toString('hex'), - y: '0x' + Buffer.from(exportedKeyWithXYCoordinates.y, 'base64').toString('hex'), - }, - }) - - return passkeyWithCoordinates -} - -/** - * Extracts the signature into R and S values from the authenticator response. - * - * See: - * - - * - - */ -function extractSignature(response: AuthenticatorAssertionResponse): [bigint, bigint] { - const check = (x: boolean) => { - if (!x) { - throw new Error('invalid signature encoding') - } - } - - // Decode the DER signature. Note that we assume that all lengths fit into 8-bit integers, - // which is true for the kinds of signatures we are decoding but generally false. I.e. this - // code should not be used in any serious application. - const view = new DataView(response.signature) - - // check that the sequence header is valid - check(view.getUint8(0) === 0x30) - check(view.getUint8(1) === view.byteLength - 2) - - // read r and s - const readInt = (offset: number) => { - check(view.getUint8(offset) === 0x02) - const len = view.getUint8(offset + 1) - const start = offset + 2 - const end = start + len - const n = BigInt(ethers.hexlify(new Uint8Array(view.buffer.slice(start, end)))) - check(n < ethers.MaxUint256) - return [n, end] as const - } - const [r, sOffset] = readInt(2) - const [s] = readInt(sOffset) - - return [r, s] -} - -type Assertion = { - response: AuthenticatorAssertionResponse -} - -export type PasskeyLocalStorageFormat = { - rawId: string - pubkeyCoordinates: { - x: string - y: string - } -} - -/** - * Compute the additional client data JSON fields. This is the fields other than `type` and - * `challenge` (including `origin` and any other additional client data fields that may be - * added by the authenticator). - * - * See - */ -function extractClientDataFields(response: AuthenticatorAssertionResponse): string { - const clientDataJSON = new TextDecoder('utf-8').decode(response.clientDataJSON) - const match = 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 ethers.hexlify(ethers.toUtf8Bytes(fields)) -} - -/** - * Converts a PasskeyCredentialWithPubkeyCoordinates object to a format that can be stored in the local storage. - * The rawId is required for signing and pubkey coordinates are for our convenience. - * @param passkey - The passkey to be converted. - * @returns The passkey in a format that can be stored in the local storage. - */ -function toLocalStorageFormat(passkey: PasskeyCredentialWithPubkeyCoordinates): PasskeyLocalStorageFormat { - return { - rawId: Buffer.from(passkey.rawId).toString('hex'), - pubkeyCoordinates: passkey.pubkeyCoordinates, - } -} - -/** - * Retrieves a passkey from local storage. - * - * @returns The retrieved passkey in the format of a {@link PasskeyLocalStorageFormat}, or null if no valid passkey is found. - */ -function getPasskeyFromLocalStorage(): PasskeyLocalStorageFormat | null { - const passkey = getItem(PASSKEY_LOCALSTORAGE_KEY) - if (!passkey) return null - - try { - const passkeyJson = JSON.parse(passkey) - return isLocalStoragePasskey(passkeyJson) ? passkeyJson : null - } catch { - console.error('Failed to parse passkey from local storage') - return null - } -} - -/** - * Stores a passkey in local storage. - * - * This function takes a passkey of type PasskeyCredentialWithPubkeyCoordinates, converts it to a format that can be stored in local storage, - * and then stores it in local storage using a predefined key. - * - * @param passkey - The passkey to be stored in local storage. It must be of type PasskeyCredentialWithPubkeyCoordinates. - */ -function storePasskeyInLocalStorage(passkey: PasskeyCredentialWithPubkeyCoordinates): void { - setItem(PASSKEY_LOCALSTORAGE_KEY, toLocalStorageFormat(passkey)) -} -/** - * Signs data with a passkey. - * - * This function uses the WebAuthn API to sign data with a passkey. The passkey is identified by its ID. - * The function throws an error if the signing operation fails. - * - * @param passkeyId - The ID of the passkey to use for signing. - * @param data - The data to sign. - * @returns A promise that resolves to the signed data. The signed data is encoded using ethers.js's default ABI coder. - * @throws Throws an error if the signing operation fails. - */ -async function signWithPasskey(passkeyId: string, data: string): Promise { - const assertion = (await navigator.credentials.get({ - publicKey: { - challenge: ethers.getBytes(data), - allowCredentials: [{ type: 'public-key', id: hexStringToUint8Array(passkeyId) }], - userVerification: 'required', - }, - })) as Assertion | null - - if (!assertion) { - throw new Error('Failed to sign user operation') - } - - return ethers.AbiCoder.defaultAbiCoder().encode( - ['bytes', 'bytes', 'uint256[2]'], - [ - new Uint8Array(assertion.response.authenticatorData), - extractClientDataFields(assertion.response), - extractSignature(assertion.response), - ], - ) -} - -/** - * Checks if the provided value is in the format of a Local Storage Passkey. - * @param x The value to check. - * @returns A boolean indicating whether the value is in the format of a Local Storage Passkey. - */ -function isLocalStoragePasskey(x: unknown): x is PasskeyLocalStorageFormat { - return typeof x === 'object' && x !== null && 'rawId' in x && 'pubkeyCoordinates' in x -} - -export type { Assertion } -export { - createPasskey, - toLocalStorageFormat, - isLocalStoragePasskey, - extractSignature, - extractClientDataFields, - signWithPasskey, - getPasskeyFromLocalStorage, - storePasskeyInLocalStorage, -} diff --git a/examples/4337-passkeys/src/logic/safe.ts b/examples/4337-passkeys/src/logic/safe.ts deleted file mode 100644 index 559104e7b..000000000 --- a/examples/4337-passkeys/src/logic/safe.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { ethers } from 'ethers' -import { abi as SafeSignerLaunchpadAbi } from '@safe-global/safe-passkey/build/artifacts/contracts/4337/experimental/SafeSignerLaunchpad.sol/SafeSignerLaunchpad.json' -import { abi as SafeWebAuthnSignerFactoryAbi } from '@safe-global/safe-passkey/build/artifacts/contracts/SafeWebAuthnSignerFactory.sol/SafeWebAuthnSignerFactory.json' -import { abi as SetupModuleSetupAbi } from '@safe-global/safe-4337/build/artifacts/contracts/SafeModuleSetup.sol/SafeModuleSetup.json' -import { abi as Safe4337ModuleAbi } from '@safe-global/safe-4337/build/artifacts/contracts/Safe4337Module.sol/Safe4337Module.json' -import { abi as SafeProxyFactoryAbi } from '@safe-global/safe-4337/build/artifacts/@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol/SafeProxyFactory.json' -import type { Safe4337Module, SafeModuleSetup, SafeProxyFactory } from '@safe-global/safe-4337/dist/typechain-types/' -import type { SafeSignerLaunchpad, SafeWebAuthnSignerFactory } from '@safe-global/safe-passkey/dist/typechain-types/' - -import { - P256_VERIFIER_ADDRESS, - SAFE_PROXY_FACTORY_ADDRESS, - SAFE_SIGNER_LAUNCHPAD_ADDRESS, - WEBAUTHN_SIGNER_FACTORY_ADDRESS, -} from '../config' -import { PackedUserOperation } from './userOp' - -// Hardcoded because we cannot easily install @safe-global/safe-contracts because of conflicting ethers.js versions -const SafeProxyBytecode = - '0x608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea264697066735822122003d1488ee65e08fa41e58e888a9865554c535f2c77126a82cb4c0f917f31441364736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564' - -function getWebAuthnSignerFactoryContract(provider: ethers.JsonRpcApiProvider): SafeWebAuthnSignerFactory { - return new ethers.Contract( - WEBAUTHN_SIGNER_FACTORY_ADDRESS, - SafeWebAuthnSignerFactoryAbi, - provider, - ) as unknown as SafeWebAuthnSignerFactory -} - -/** - * Calculates the signer address from the given public key coordinates. - * @param provider The provider to use for the contract call. - * @param x The x-coordinate of the public key. - * @param y The y-coordinate of the public key. - * @returns The signer address. - */ -async function getSignerAddressFromPubkeyCoords(provider: ethers.JsonRpcApiProvider, x: string, y: string): Promise { - const WebAuthSignerFactory = getWebAuthnSignerFactoryContract(provider) - const signerAddress = await WebAuthSignerFactory.getSigner(x, y, P256_VERIFIER_ADDRESS) - - return signerAddress -} - -type SafeInitializer = { - singleton: string - signerFactory: string - signerX: string - signerY: string - signerVerifiers: string - setupTo: string - setupData: string - fallbackHandler: string -} - -function getLaunchpadInitializer(initializer: SafeInitializer): string { - const safeSignerLaunchpadInterface = new ethers.Interface(SafeSignerLaunchpadAbi) as unknown as SafeSignerLaunchpad['interface'] - - const launchpadInitializer = safeSignerLaunchpadInterface.encodeFunctionData('setup', [ - initializer.singleton, - initializer.signerFactory, - initializer.signerX, - initializer.signerY, - initializer.signerVerifiers, - initializer.setupTo, - initializer.setupData, - initializer.fallbackHandler, - ]) - - return launchpadInitializer -} - -/** - * Generates the deployment data for creating a new Safe contract proxy with a specified singleton address, initializer, and salt nonce. - * @param singleton The address of the singleton contract. - * @param initializer The initialization data for the Safe contract. - * @param saltNonce The salt nonce for the Safe contract. - * @returns The deployment data for creating the Safe contract proxy. - */ -function getSafeDeploymentData(singleton: string, initializer = '0x', saltNonce = ethers.ZeroHash): string { - const safeProxyFactoryInterface = new ethers.Interface(SafeProxyFactoryAbi) as unknown as SafeProxyFactory['interface'] - const deploymentData = safeProxyFactoryInterface.encodeFunctionData('createProxyWithNonce', [singleton, initializer, saltNonce]) - - return deploymentData -} - -/** - * Calculates the address of a safe contract based on the initializer, factory address, singleton address, and salt nonce. - * @param initializer - The initializer bytes. - * @param factoryAddress - The factory address used to create the safe contract. Defaults to SAFE_PROXY_FACTORY_ADDRESS. - * @param singleton - The singleton address used for the safe contract. Defaults to SAFE_SIGNER_LAUNCHPAD_ADDRESS. - * @param saltNonce - The salt nonce used for the safe contract. Defaults to ethers.ZeroHash. - * @returns The address of the safe contract. - */ -function getSafeAddress( - initializer: string, - factoryAddress = SAFE_PROXY_FACTORY_ADDRESS, - singleton = SAFE_SIGNER_LAUNCHPAD_ADDRESS, - saltNonce: ethers.BigNumberish = ethers.ZeroHash, -): string { - const deploymentCode = ethers.solidityPacked(['bytes', 'uint256'], [SafeProxyBytecode, singleton]) - const salt = ethers.solidityPackedKeccak256(['bytes32', 'uint256'], [ethers.solidityPackedKeccak256(['bytes'], [initializer]), saltNonce]) - return ethers.getCreate2Address(factoryAddress, salt, ethers.keccak256(deploymentCode)) -} - -/** - * Encodes the function call to enable modules in the SafeModuleSetup contract. - * - * @param modules - An array of module addresses. - * @returns The encoded function call data. - */ -function encodeSafeModuleSetupCall(modules: string[]): string { - const safeModuleSetupInterface = new ethers.Interface(SetupModuleSetupAbi) as unknown as SafeModuleSetup['interface'] - return safeModuleSetupInterface.encodeFunctionData('enableModules', [modules]) -} - -/** - * Encodes the necessary data for initializing a Safe contract and performing a user operation. - * @param setup - The SafeInitializer object containing the initialization parameters. - * @param to The address of the recipient of the operation. - * @param value The amount of value to be transferred in the operation. - * @param data The data payload of the operation. - * @param operation The type of operation (0 for CALL, 1 for DELEGATECALL). - * @returns The encoded data for initializing the Safe contract and performing the user operation. - */ -function getPromoteAccountAndExecuteUserOpData( - initializer: SafeInitializer, - to: string, - value: ethers.BigNumberish, - data: string, - operation: 0 | 1, -): string { - const safeSignerLaunchpadInterface = new ethers.Interface(SafeSignerLaunchpadAbi) as unknown as SafeSignerLaunchpad['interface'] - - const initializeThenUserOpData = safeSignerLaunchpadInterface.encodeFunctionData('promoteAccountAndExecuteUserOp', [ - initializer.signerFactory, - initializer.signerX, - initializer.signerY, - initializer.signerVerifiers, - to, - value, - data, - operation, - ]) - - return initializeThenUserOpData -} - -/** - * Encodes the user operation data for validating a user operation. - * @param userOp The packed user operation to be validated. - * @param userOpHash The hash of the user operation. - * @param missingAccountFunds The amount of missing account funds. - * @returns The encoded data for validating the user operation. - */ -function getValidateUserOpData(userOp: PackedUserOperation, userOpHash: string, missingAccountFunds: ethers.BigNumberish): string { - const safe4337ModuleInterface = new ethers.Interface(Safe4337ModuleAbi) as unknown as Safe4337Module['interface'] - - const validateUserOpData = safe4337ModuleInterface.encodeFunctionData('validateUserOp', [userOp, userOpHash, missingAccountFunds]) - - return validateUserOpData -} - -/** - * Encodes the parameters of a user operation for execution on Safe4337Module. - * @param to The address of the recipient of the operation. - * @param value The amount of value to be transferred in the operation. - * @param data The data payload of the operation. - * @param operation The type of operation (0 for CALL, 1 for DELEGATECALL). - * @returns The encoded data for the user operation. - */ -function getExecuteUserOpData(to: string, value: ethers.BigNumberish, data: string, operation: 0 | 1): string { - const safe4337ModuleInterface = new ethers.Interface(Safe4337ModuleAbi) as unknown as Safe4337Module['interface'] - - const executeUserOpData = safe4337ModuleInterface.encodeFunctionData('executeUserOpWithErrorString', [to, value, data, operation]) - - return executeUserOpData -} - -export type { SafeInitializer } -export { - getLaunchpadInitializer, - getPromoteAccountAndExecuteUserOpData, - getSignerAddressFromPubkeyCoords, - getSafeDeploymentData, - getSafeAddress, - getValidateUserOpData, - getExecuteUserOpData, - encodeSafeModuleSetupCall, -} diff --git a/examples/4337-passkeys/src/logic/safeWalletApp.ts b/examples/4337-passkeys/src/logic/safeWalletApp.ts deleted file mode 100644 index a605991fe..000000000 --- a/examples/4337-passkeys/src/logic/safeWalletApp.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { APP_CHAIN_SHORTNAME } from '../config.ts' - -function getSafeWalletAppSafeDashboardLink(safeAddress: string, chainShortName = APP_CHAIN_SHORTNAME): string { - return `https://app.safe.global/home?safe=${chainShortName}:${safeAddress}` -} - -export { getSafeWalletAppSafeDashboardLink } diff --git a/examples/4337-passkeys/src/logic/storage.ts b/examples/4337-passkeys/src/logic/storage.ts deleted file mode 100644 index 8535db3b6..000000000 --- a/examples/4337-passkeys/src/logic/storage.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Sets an item in the local storage. - * @param key - The key to set the item with. - * @param value - The value to be stored. It will be converted to a string using JSON.stringify. - * @template T - The type of the value being stored. - */ -function setItem(key: string, value: T) { - // to prevent silly mistakes with double stringifying - if (typeof value === 'string') { - localStorage.setItem(key, value) - } else { - localStorage.setItem(key, JSON.stringify(value)) - } -} - -/** - * Retrieves the value associated with the specified key from the local storage. - * - * @param key - The key of the item to retrieve. - * @returns The value associated with the key, or null if the key does not exist. - */ -function getItem(key: string): string | null { - return localStorage.getItem(key) -} - -export { setItem, getItem } diff --git a/examples/4337-passkeys/src/logic/userOp.ts b/examples/4337-passkeys/src/logic/userOp.ts deleted file mode 100644 index 198e70116..000000000 --- a/examples/4337-passkeys/src/logic/userOp.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { ethers } from 'ethers' -import { abi as EntryPointAbi } from '@account-abstraction/contracts/artifacts/EntryPoint.json' -import { IEntryPoint } from '@safe-global/safe-4337/dist/typechain-types' -import { - SafeInitializer, - getPromoteAccountAndExecuteUserOpData, - getExecuteUserOpData, - getLaunchpadInitializer, - getSafeAddress, - getSafeDeploymentData, - getValidateUserOpData, - getSignerAddressFromPubkeyCoords, -} from './safe' -import { - APP_CHAIN_ID, - ENTRYPOINT_ADDRESS, - SAFE_4337_MODULE_ADDRESS, - SAFE_PROXY_FACTORY_ADDRESS, - SAFE_SIGNER_LAUNCHPAD_ADDRESS, - XANDER_BLAZE_NFT_ADDRESS, -} from '../config' -import { encodeSafeMintData } from './erc721' -import { PasskeyLocalStorageFormat, signWithPasskey } from './passkeys' -import { calculateSafeOperationHash, unpackGasParameters, SafeUserOperation } from '@safe-global/safe-4337/dist/src/utils/userOp.js' -import { - PackedUserOperation as PackedUserOperationOgType, - UserOperation as UserOperationOgType, -} from '@safe-global/safe-4337/dist/src/utils/userOp' -import { buildSignatureBytes } from '@safe-global/safe-4337/dist/src/utils/execution' - -type UserOperation = UserOperationOgType & { sender: string } -type PackedUserOperation = PackedUserOperationOgType & { sender: string } -type UnsignedPackedUserOperation = Omit - -/** - * Dummy client data JSON fields. This can be used for gas estimations, as it pads the fields enough - * to account for variations in WebAuthn implementations. - */ -export const DUMMY_CLIENT_DATA_FIELDS = [ - `"origin":"http://safe.global"`, - `"padding":"This pads the clientDataJSON so that we can leave room for additional implementation specific fields for a more accurate 'preVerificationGas' estimate."`, -].join(',') - -/** - * Dummy authenticator data. This can be used for gas estimations, as it ensures that the correct - * authenticator flags are set. - */ -export const DUMMY_AUTHENTICATOR_DATA = new Uint8Array(37) -// Authenticator data is the concatenation of: -// - 32 byte SHA-256 hash of the relying party ID -// - 1 byte for the user verification flag -// - 4 bytes for the signature count -// We fill it all with `0xfe` and set the appropriate user verification flag. -DUMMY_AUTHENTICATOR_DATA.fill(0xfe) -DUMMY_AUTHENTICATOR_DATA[32] = 0x04 - -/** - * Encodes the given WebAuthn signature into a string. This computes the ABI-encoded signature parameters: - * ```solidity - * abi.encode(authenticatorData, clientDataFields, r, s); - * ``` - * - * @param authenticatorData - The authenticator data as a Uint8Array. - * @param clientDataFields - The client data fields as a string. - * @param r - The value of r as a bigint. - * @param s - The value of s as a bigint. - * @returns The encoded string. - */ -export function getSignatureBytes({ - authenticatorData, - clientDataFields, - r, - s, -}: { - authenticatorData: Uint8Array - clientDataFields: string - r: bigint - s: bigint -}): string { - // Helper functions - // Convert a number to a 64-byte hex string with padded upto Hex string with 32 bytes - const encodeUint256 = (x: bigint | number) => x.toString(16).padStart(64, '0') - // Calculate the byte size of the dynamic data along with the length parameter alligned to 32 bytes - const byteSize = (data: Uint8Array) => 32 * (Math.ceil(data.length / 32) + 1) // +1 is for the length parameter - // Encode dynamic data padded with zeros if necessary in 32 bytes chunks - const encodeBytes = (data: Uint8Array) => `${encodeUint256(data.length)}${ethers.hexlify(data).slice(2)}`.padEnd(byteSize(data) * 2, '0') - - // authenticatorData starts after the first four words. - const authenticatorDataOffset = 32 * 4 - // clientDataFields starts immediately after the authenticator data. - const clientDataFieldsOffset = authenticatorDataOffset + byteSize(authenticatorData) - - return ( - '0x' + - encodeUint256(authenticatorDataOffset) + - encodeUint256(clientDataFieldsOffset) + - encodeUint256(r) + - encodeUint256(s) + - encodeBytes(authenticatorData) + - encodeBytes(new TextEncoder().encode(clientDataFields)) - ) -} - -// Dummy signature for gas estimation. We require the 12 bytes of validity timestamp data -// so that the estimation doesn't revert. But we also want to use a dummy signature for -// more accurate `verificationGasLimit` (We want to run the P256 signature verification -// code) & `preVerificationGas` (The signature length in bytes should be accurate) estimate. -// The challenge is neither P256 Verification Gas nor signature length are stable, so we make -// a calculated guess. -const DUMMY_SIGNATURE_LAUNCHPAD = ethers.solidityPacked( - ['uint48', 'uint48', 'bytes'], - [ - 0, - 0, - ethers.AbiCoder.defaultAbiCoder().encode( - ['bytes', 'string', 'uint256', 'uint256'], - [ - DUMMY_AUTHENTICATOR_DATA, // authenticatorData without any extensions/attestated credential data is always 37 bytes long. - DUMMY_CLIENT_DATA_FIELDS, - `0x${'ec'.repeat(32)}`, - `0x${'d5a'.repeat(21)}f`, - ], - ), - ], -) - -/** - * Generates a dummy signature for a user operation. - * - * @param signer - The Ethereum address of the signer. - * @returns The dummy signature for a user operation. - */ -function dummySignatureUserOp(signer: string) { - return ethers.solidityPacked( - ['uint48', 'uint48', 'bytes'], - [ - 0, - 0, - buildSignatureBytes([ - { - signer, - data: getSignatureBytes({ - authenticatorData: DUMMY_AUTHENTICATOR_DATA, - clientDataFields: DUMMY_CLIENT_DATA_FIELDS, - r: BigInt(`0x${'ec'.repeat(32)}`), - s: BigInt(`0x${'d5a'.repeat(21)}f`), - }), - dynamic: true, - }, - ]), - ], - ) -} - -/** - * Generates the user operation initialization code. - * @param proxyFactory - The address of the proxy factory. - * @param deploymentData - The deployment data. - * @returns The user operation initialization code. - */ -function getUserOpInitCode(proxyFactory: string, deploymentData: string): string { - const userOpInitCode = ethers.solidityPacked(['address', 'bytes'], [proxyFactory, deploymentData]) - return userOpInitCode -} - -type UserOpCall = { - to: string - data: string - value: ethers.BigNumberish - operation: 0 | 1 -} - -/** - * Prepares a user operation with initialization. - * - * @param proxyFactoryAddress - The address of the proxy factory. - * @param initializer - The safe initializer. - * @param afterInitializationOpCall - Optional user operation call to be executed after initialization. - * @param saltNonce - The salt nonce. - * @returns The unsigned user operation. - */ -function prepareUserOperationWithInitialisation( - proxyFactoryAddress: string, - initializer: SafeInitializer, - afterInitializationOpCall?: UserOpCall, - saltNonce = ethers.ZeroHash, -): UnsignedPackedUserOperation { - const launchpadInitializer = getLaunchpadInitializer(initializer) - const predictedSafeAddress = getSafeAddress(launchpadInitializer, SAFE_PROXY_FACTORY_ADDRESS, SAFE_SIGNER_LAUNCHPAD_ADDRESS, saltNonce) - const safeDeploymentData = getSafeDeploymentData(SAFE_SIGNER_LAUNCHPAD_ADDRESS, launchpadInitializer, saltNonce) - const userOpCall = afterInitializationOpCall ?? { - to: XANDER_BLAZE_NFT_ADDRESS, - data: encodeSafeMintData(predictedSafeAddress), - value: 0, - operation: 0, - } - - const userOp = { - sender: predictedSafeAddress, - nonce: ethers.toBeHex(0), - initCode: getUserOpInitCode(proxyFactoryAddress, safeDeploymentData), - callData: getPromoteAccountAndExecuteUserOpData(initializer, userOpCall.to, userOpCall.value, userOpCall.data, userOpCall.operation), - ...packGasParameters({ - callGasLimit: ethers.toBeHex(2000000), - verificationGasLimit: ethers.toBeHex(2000000), - maxFeePerGas: ethers.toBeHex(10000000000), - maxPriorityFeePerGas: ethers.toBeHex(10000000000), - }), - preVerificationGas: ethers.toBeHex(2000000), - paymasterAndData: '0x', - } - - if (import.meta.env.DEV) { - console.log('Safe deployment data: ', safeDeploymentData) - console.log( - 'validateUserOp data for estimation: ', - getValidateUserOpData({ ...userOp, signature: DUMMY_SIGNATURE_LAUNCHPAD }, ethers.ZeroHash, 10000000000), - ) - } - - return userOp -} - -function getUnsignedUserOperation(call: UserOpCall, safeAddress: string, nonce: ethers.BigNumberish): UnsignedPackedUserOperation { - return { - sender: safeAddress, - nonce, - initCode: '0x', - callData: getExecuteUserOpData(call.to, call.value, call.data, call.operation), - accountGasLimits: ethers.solidityPacked(['uint128', 'uint128'], [2000000, 2000000]), - preVerificationGas: 2000000, - gasFees: ethers.solidityPacked(['uint128', 'uint128'], [10000000000, 10000000000]), - paymasterAndData: '0x', - } -} - -/** - * Retrieves the EIP-4337 bundler provider. - * @returns The EIP-4337 bundler provider. - */ -function getEip4337BundlerProvider(): ethers.JsonRpcProvider { - const provider = new ethers.JsonRpcProvider(import.meta.env.VITE_WC_4337_BUNDLER_URL, undefined, { - batchMaxCount: 1, - }) - return provider -} - -/** - * Returns an instance of the EntryPoint contract. - * @param provider - The ethers.js JsonRpcProvider to use for interacting with the Ethereum network. - * @param address - The Ethereum address of the deployed EntryPoint contract. - * @returns An instance of the EntryPoint contract. - */ -function getEntryPointContract(provider: ethers.JsonRpcApiProvider, address: string): IEntryPoint { - return new ethers.Contract(address, EntryPointAbi, provider) as unknown as IEntryPoint -} - -/** - * Retrieves the nonce from the entry point. - * - * @param provider - The ethers.js JsonRpcProvider to use for interacting with the Ethereum network. - * @param safeAddress - The Ethereum address of the safe for which to retrieve the nonce. - * @param entryPoint - The Ethereum address of the entry point. Defaults to {@link ENTRYPOINT_ADDRESS} if not provided. - * @returns A promise that resolves to the nonce for the provided safe address. - */ -async function getNonceFromEntryPoint( - provider: ethers.JsonRpcApiProvider, - safeAddress: string, - entryPoint = ENTRYPOINT_ADDRESS, -): Promise { - const entrypoint = getEntryPointContract(provider, entryPoint) - const nonce = await entrypoint.getNonce(safeAddress, 0) - - return nonce -} - -async function getAccountEntryPointBalance( - provider: ethers.JsonRpcApiProvider, - safeAddress: string, - entryPoint = ENTRYPOINT_ADDRESS, -): Promise { - const entrypoint = getEntryPointContract(provider, entryPoint) - const balance = await entrypoint.balanceOf(safeAddress) - - return balance -} - -type UserOpGasLimitEstimation = { - preVerificationGas: string - callGasLimit: string - verificationGasLimit: string - paymasterVerificationGasLimit: string - paymasterPostOpGasLimit: string -} - -/** - * Estimates the gas limit for a user operation. A dummy signature will be used. - * @param userOp - The user operation to estimate gas limit for. - * @param signerAddress - The signer address. - * @param entryPointAddress - The entry point address. Default value is ENTRYPOINT_ADDRESS. - * @returns A promise that resolves to the estimated gas limit for the user operation. - */ -async function estimateUserOpGasLimit( - userOp: UnsignedPackedUserOperation, - signerAddress?: string, - entryPointAddress = ENTRYPOINT_ADDRESS, -): Promise { - const provider = getEip4337BundlerProvider() - - const placeholderSignature = - (userOp.initCode.length > 0 && userOp.initCode !== '0x') || !signerAddress - ? DUMMY_SIGNATURE_LAUNCHPAD - : dummySignatureUserOp(signerAddress) - - const rpcUserOp = unpackUserOperationForRpc(userOp, placeholderSignature) - const estimation = await provider.send('eth_estimateUserOperationGas', [rpcUserOp, entryPointAddress]) - - return estimation -} - -/** - * Unpacks a user operation for use over the bundler RPC. - * @param userOp The user operation to unpack. - * @param signature The signature bytes for the user operation. - * @returns An unpacked `UserOperation` that can be used over bundler RPC. - */ -function unpackUserOperationForRpc(userOp: UnsignedPackedUserOperation, signature: ethers.BytesLike): UserOperation { - const initFields = - ethers.dataLength(userOp.initCode) > 0 - ? { - factory: ethers.getAddress(ethers.dataSlice(userOp.initCode, 0, 20)), - factoryData: ethers.dataSlice(userOp.initCode, 20), - } - : {} - const paymasterFields = - ethers.dataLength(userOp.paymasterAndData) > 0 - ? { - paymaster: ethers.getAddress(ethers.dataSlice(userOp.initCode, 0, 20)), - paymasterVerificationGasLimit: ethers.toBeHex(ethers.dataSlice(userOp.paymasterAndData, 20, 36)), - paymasterPostOpGasLimit: ethers.toBeHex(ethers.dataSlice(userOp.paymasterAndData, 36, 52)), - paymasterData: ethers.dataSlice(userOp.paymasterAndData, 52), - } - : {} - return { - sender: ethers.getAddress(userOp.sender), - nonce: ethers.toBeHex(userOp.nonce), - ...initFields, - callData: ethers.hexlify(userOp.callData), - callGasLimit: ethers.toBeHex(ethers.dataSlice(userOp.accountGasLimits, 16, 32)), - verificationGasLimit: ethers.toBeHex(ethers.dataSlice(userOp.accountGasLimits, 0, 16)), - preVerificationGas: ethers.toBeHex(userOp.preVerificationGas), - maxFeePerGas: ethers.toBeHex(ethers.dataSlice(userOp.gasFees, 16, 32)), - maxPriorityFeePerGas: ethers.toBeHex(ethers.dataSlice(userOp.gasFees, 0, 16)), - ...paymasterFields, - signature: ethers.hexlify(signature), - } -} - -/** - * Calculates the missing funds for an account. - * Missing funds is the amount of funds required by the entry point to execute the user operation. - * - * @param maxFeePerGas - The maximum fee per gas for the user operation. - * @param userOpGasLimitEstimation - The gas limit estimation for the user operation. - * @param currentEntryPointDeposit - The current deposit at the entry point. Defaults to 0n if not provided. - * @param multiplier - The multiplier used in the calculation. Defaults to 12n if not provided. - * @returns The missing funds for the account. - */ -function getMissingAccountFunds( - maxFeePerGas: bigint, - userOpGasLimitEstimation: UserOpGasLimitEstimation, - currentEntryPointDeposit = 0n, - multiplier = 12n, -): bigint { - return ( - (BigInt(maxFeePerGas) * - (BigInt(userOpGasLimitEstimation.preVerificationGas) + - BigInt(userOpGasLimitEstimation.callGasLimit) + - BigInt(userOpGasLimitEstimation.verificationGasLimit) + - BigInt(userOpGasLimitEstimation.paymasterVerificationGasLimit) + - BigInt(userOpGasLimitEstimation.paymasterPostOpGasLimit)) * - multiplier) / - 10n - - currentEntryPointDeposit - ) -} - -/** - * Packs a user operation gas parameters. - * @param op The UserOperation gas parameters to pack. - * @returns The packed UserOperation parameters. - */ -function packGasParameters( - op: Pick, -): Pick { - return { - accountGasLimits: ethers.solidityPacked(['uint128', 'uint128'], [op.verificationGasLimit, op.callGasLimit]), - gasFees: ethers.solidityPacked(['uint128', 'uint128'], [op.maxPriorityFeePerGas, op.maxFeePerGas]), - } -} - -/** - * Packs a UserOperation object into a string using the defaultAbiCoder. - * @param op The UserOperation object to pack. - * @returns The packed UserOperation as a string. - */ -function packUserOpData(op: UnsignedPackedUserOperation): string { - return ethers.AbiCoder.defaultAbiCoder().encode( - [ - 'address', // sender - 'uint256', // nonce - 'bytes32', // initCode - 'bytes32', // callData - 'bytes32', // accountGasLimits - 'uint256', // preVerificationGas - 'bytes32', // gasFees - 'bytes32', // paymasterAndData - ], - [ - op.sender, - op.nonce, - ethers.keccak256(op.initCode), - ethers.keccak256(op.callData), - op.accountGasLimits, - op.preVerificationGas, - op.gasFees, - ethers.keccak256(op.paymasterAndData), - ], - ) -} - -/** - * Off-chain replication of the function to calculate user operation hash from the entrypoint. - * @param op The user operation. - * @param entryPoint The entry point. - * @param chainId The chain ID. - * @returns The hash of the user operation. - */ -function getEntryPointUserOpHash( - op: UnsignedPackedUserOperation, - entryPoint: string = ENTRYPOINT_ADDRESS, - chainId: ethers.BigNumberish = APP_CHAIN_ID, -): string { - const userOpHash = ethers.keccak256(packUserOpData(op)) - const enc = ethers.AbiCoder.defaultAbiCoder().encode(['bytes32', 'address', 'uint256'], [userOpHash, entryPoint, chainId]) - return ethers.keccak256(enc) -} - -/** - * Signs and sends a user operation to the specified entry point on the blockchain. - * @param userOp The unsigned user operation to sign and send. - * @param passkey The passkey used for signing the user operation. - * @param entryPoint The entry point address on the blockchain. Defaults to ENTRYPOINT_ADDRESS if not provided. - * @param chainId The chain ID of the blockchain. Defaults to APP_CHAIN_ID if not provided. - * @returns User Operation hash promise. - * @throws An error if signing the user operation fails. - */ -async function signAndSendDeploymentUserOp( - userOp: UnsignedPackedUserOperation, - passkey: PasskeyLocalStorageFormat, - entryPoint: string = ENTRYPOINT_ADDRESS, - chainId: ethers.BigNumberish = APP_CHAIN_ID, -): Promise { - const userOpHash = getEntryPointUserOpHash(userOp, entryPoint, chainId) - - const safeInitOp = { - userOpHash, - validAfter: 0, - validUntil: 0, - entryPoint: ENTRYPOINT_ADDRESS, - } - - const safeInitOpHash = ethers.TypedDataEncoder.hash( - { verifyingContract: SAFE_SIGNER_LAUNCHPAD_ADDRESS, chainId }, - { - SafeInitOp: [ - { type: 'bytes32', name: 'userOpHash' }, - { type: 'uint48', name: 'validAfter' }, - { type: 'uint48', name: 'validUntil' }, - { type: 'address', name: 'entryPoint' }, - ], - }, - safeInitOp, - ) - - const passkeySignature = await signWithPasskey(passkey.rawId, safeInitOpHash) - - const signature = ethers.solidityPacked(['uint48', 'uint48', 'bytes'], [safeInitOp.validAfter, safeInitOp.validUntil, passkeySignature]) - - const rpcUserOp = unpackUserOperationForRpc(userOp, signature) - return await getEip4337BundlerProvider().send('eth_sendUserOperation', [rpcUserOp, entryPoint]) -} - -/** - * Signs and sends a user operation to the specified entry point on the blockchain. - * @param userOp The unsigned user operation to sign and send. - * @param passkey The passkey used for signing the user operation. - * @param provider The ethers.js JsonRpcProvider to use for interacting with the Ethereum network. - * @param entryPoint The entry point address on the blockchain. Defaults to ENTRYPOINT_ADDRESS if not provided. - * @param chainId The chain ID of the blockchain. Defaults to APP_CHAIN_ID if not provided. - * @returns User Operation hash promise. - * @throws An error if signing the user operation fails. - */ -async function signAndSendUserOp( - provider: ethers.JsonRpcApiProvider, - userOp: UnsignedPackedUserOperation, - passkey: PasskeyLocalStorageFormat, - entryPoint: string = ENTRYPOINT_ADDRESS, - chainId: ethers.BigNumberish = APP_CHAIN_ID, -): Promise { - const safeOp: SafeUserOperation = { - callData: userOp.callData, - nonce: userOp.nonce, - initCode: userOp.initCode, - paymasterAndData: userOp.paymasterAndData, - preVerificationGas: userOp.preVerificationGas, - entryPoint, - validAfter: 0, - validUntil: 0, - safe: userOp.sender, - ...unpackGasParameters(userOp), - } - - const safeOpHash = calculateSafeOperationHash(SAFE_4337_MODULE_ADDRESS, safeOp, chainId) - const passkeySignature = await signWithPasskey(passkey.rawId, safeOpHash) - const signatureBytes = buildSignatureBytes([ - { - signer: await getSignerAddressFromPubkeyCoords(provider, passkey.pubkeyCoordinates.x, passkey.pubkeyCoordinates.y), - data: passkeySignature, - dynamic: true, - }, - ]) - - const signature = ethers.solidityPacked(['uint48', 'uint48', 'bytes'], [0, 0, signatureBytes]) - if (import.meta.env.DEV) { - console.log('validateUserOp data for estimation: ', getValidateUserOpData({ ...userOp, signature }, ethers.ZeroHash, 10000000000)) - } - - const rpcUserOp = unpackUserOperationForRpc(userOp, signature) - return await getEip4337BundlerProvider().send('eth_sendUserOperation', [rpcUserOp, entryPoint]) -} - -export type { PackedUserOperation, UnsignedPackedUserOperation, UserOperation, UserOpGasLimitEstimation } - -export { - prepareUserOperationWithInitialisation, - packGasParameters, - getEip4337BundlerProvider, - getNonceFromEntryPoint, - getUnsignedUserOperation, - estimateUserOpGasLimit, - getMissingAccountFunds, - getAccountEntryPointBalance, - signAndSendDeploymentUserOp, - signAndSendUserOp, -} diff --git a/examples/4337-passkeys/src/logic/wallets.ts b/examples/4337-passkeys/src/logic/wallets.ts deleted file mode 100644 index 81c9382db..000000000 --- a/examples/4337-passkeys/src/logic/wallets.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { createWeb3Modal, defaultConfig } from '@web3modal/ethers/react' -import { ethers } from 'ethers' -import { numberToUnpaddedHex } from '../utils' - -const projectId = import.meta.env.VITE_WC_CLOUD_PROJECT_ID - -const sepolia = { - chainId: 11155111, - name: 'Sepolia test network', - currency: 'ETH', - explorerUrl: 'https://sepolia.etherscan.io', - rpcUrl: 'https://sepolia.gateway.tenderly.co', -} - -const metadata = { - name: 'Safe 4337 Passkeys Example', - description: 'An example application to deploy a 4337-compatible Safe Account with Passkeys signer', - url: 'https://safe.global', - icons: ['https://app.safe.global/favicons/favicon.ico'], -} - -createWeb3Modal({ - ethersConfig: defaultConfig({ metadata }), - chains: [sepolia], - projectId, -}) - -/** - * Switches the Ethereum provider to the Ethereum Sepolia test network. - * @param provider The Ethereum provider. - * @returns A promise that resolves to an unknown value. - */ -async function switchToSepolia(provider: ethers.Eip1193Provider): Promise { - return provider - .request({ - method: 'wallet_addEthereumChain', - params: [ - { - chainId: numberToUnpaddedHex(sepolia.chainId), - blockExplorerUrls: [sepolia.explorerUrl], - chainName: sepolia.name, - nativeCurrency: { - name: sepolia.currency, - symbol: sepolia.currency, - decimals: 18, - }, - rpcUrls: [sepolia.rpcUrl], - }, - ], - }) - .catch(() => - provider.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: numberToUnpaddedHex(sepolia.chainId) }], - }), - ) -} - -/** - * Converts an Eip1193Provider to a JsonRpcApiProvider. - * @param provider The Eip1193Provider to convert. - * @returns The converted JsonRpcApiProvider. - */ -function getJsonRpcProviderFromEip1193Provider(provider: ethers.Eip1193Provider): ethers.JsonRpcApiProvider { - return new ethers.BrowserProvider(provider) -} - -export { switchToSepolia, getJsonRpcProviderFromEip1193Provider } diff --git a/examples/4337-passkeys/src/main.tsx b/examples/4337-passkeys/src/main.tsx deleted file mode 100644 index 74c9da0db..000000000 --- a/examples/4337-passkeys/src/main.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import { createBrowserRouter, RouterProvider } from 'react-router-dom' - -// Keep the import of the wallets file to eval so w3m package can initialise -// the required globals -import './logic/wallets.ts' -import './index.css' -import { Root } from './routes/Root.tsx' -import { Home, loader as homeLoader } from './routes/Home.tsx' -import { DeploySafe, loader as deploySafeLoader } from './routes/DeploySafe.tsx' -import { CreatePasskey } from './routes/CreatePasskey.tsx' -import { CREATE_PASSKEY_ROUTE, DEPLOY_SAFE_ROUTE, HOME_ROUTE, SAFE_ROUTE } from './routes/constants.ts' -import { Safe, loader as safeLoader } from './routes/Safe.tsx' - -const router = createBrowserRouter([ - { - path: HOME_ROUTE, - element: , - children: [ - { index: true, loader: homeLoader, element: }, - { path: CREATE_PASSKEY_ROUTE, element: }, - { path: DEPLOY_SAFE_ROUTE, loader: deploySafeLoader, element: }, - { path: SAFE_ROUTE, loader: safeLoader, element: }, - ], - }, -]) - -const rootElement = document.getElementById('root') - -if (!rootElement) { - throw new Error('Root element not found') -} - -ReactDOM.createRoot(rootElement).render( - - - , -) diff --git a/examples/4337-passkeys/src/routes/CreatePasskey.tsx b/examples/4337-passkeys/src/routes/CreatePasskey.tsx deleted file mode 100644 index 5b4fe39aa..000000000 --- a/examples/4337-passkeys/src/routes/CreatePasskey.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useState } from 'react' -import { redirect, useNavigate } from 'react-router-dom' -import { DEPLOY_SAFE_ROUTE, HOME_ROUTE } from './constants.ts' -import { createPasskey, getPasskeyFromLocalStorage, storePasskeyInLocalStorage } from '../logic/passkeys.ts' - -async function loader() { - const passkey = getPasskeyFromLocalStorage() - if (passkey) { - return redirect(HOME_ROUTE) - } - - return null -} - -function CreatePasskey() { - const [error, setError] = useState() - const navigate = useNavigate() - - const handleCreatePasskeyClick = async () => { - setError(undefined) - try { - const passkey = await createPasskey() - - storePasskeyInLocalStorage(passkey) - - navigate(DEPLOY_SAFE_ROUTE, { replace: true }) - } catch (error) { - if (error instanceof Error) { - setError(error.message) - } else { - setError('Unknown error') - } - } - } - - return ( -
-

First, create a passkey to sign transactions

-

- Passkey is a secure authentication method that replaces traditional passwords. It uses public key cryptography and unique - cryptographic keys to securely log users into websites and apps. To access a passkey, users must unlock their devices using - biometrics like a fingerprint, Face ID or a device PIN. This makes passkeys much more secure than passwords and resistant to - phishing attacks. -
- For the Safe Account, passkeys serve as credentials for verifying user operations on-chain. This added layer of authentication - ensures that only the passkey holder can access and perform actions with the account. -

- - - {error && ( -
-

Error: {error}

-
- )} -
- ) -} - -export { CreatePasskey, loader } diff --git a/examples/4337-passkeys/src/routes/DeploySafe.tsx b/examples/4337-passkeys/src/routes/DeploySafe.tsx deleted file mode 100644 index bacd4e90d..000000000 --- a/examples/4337-passkeys/src/routes/DeploySafe.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useMemo, useState } from 'react' -import { LoaderFunction, Navigate, redirect, useLoaderData } from 'react-router-dom' -import { encodeSafeModuleSetupCall, getLaunchpadInitializer, getSafeAddress } from '../logic/safe' -import type { SafeInitializer } from '../logic/safe' -import { - SAFE_4337_MODULE_ADDRESS, - SAFE_MODULE_SETUP_ADDRESS, - SAFE_PROXY_FACTORY_ADDRESS, - SAFE_SINGLETON_ADDRESS, - WEBAUTHN_SIGNER_FACTORY_ADDRESS, - P256_VERIFIER_ADDRESS, -} from '../config' -import { getPasskeyFromLocalStorage, PasskeyLocalStorageFormat } from '../logic/passkeys' -import { - UnsignedPackedUserOperation, - getMissingAccountFunds, - packGasParameters, - prepareUserOperationWithInitialisation, - signAndSendDeploymentUserOp, -} from '../logic/userOp' -import { useUserOpGasLimitEstimation } from '../hooks/useUserOpGasEstimation' -import { RequestStatus } from '../utils' -import { MissingAccountFundsCard } from '../components/MissingAccountFundsCard.tsx' -import { useFeeData } from '../hooks/useFeeData' -import { useNativeTokenBalance } from '../hooks/useNativeTokenBalance' -import { useCodeAtAddress } from '../hooks/useCodeAtAddress' -import { getSafeRoute, HOME_ROUTE } from './constants.ts' - -import { useOutletContext } from '../hooks/UseOutletContext.tsx' - -const loader: LoaderFunction = async () => { - const passkey = getPasskeyFromLocalStorage() - - if (!passkey) { - return redirect(HOME_ROUTE) - } - - const initializer: SafeInitializer = { - singleton: SAFE_SINGLETON_ADDRESS, - fallbackHandler: SAFE_4337_MODULE_ADDRESS, - signerFactory: WEBAUTHN_SIGNER_FACTORY_ADDRESS, - signerX: passkey.pubkeyCoordinates.x, - signerY: passkey.pubkeyCoordinates.y, - signerVerifiers: P256_VERIFIER_ADDRESS, - setupTo: SAFE_MODULE_SETUP_ADDRESS, - setupData: encodeSafeModuleSetupCall([SAFE_4337_MODULE_ADDRESS]), - } - const launchpadInitializer = getLaunchpadInitializer(initializer) - const safeAddress = getSafeAddress(launchpadInitializer) - - return { passkey, safeAddress } -} - -function DeploySafe() { - const { passkey, safeAddress } = useLoaderData() as { safeAddress: string; passkey: PasskeyLocalStorageFormat } - const { walletProvider } = useOutletContext() - const [safeCode, safeCodeStatus] = useCodeAtAddress(walletProvider, safeAddress) - - const initializer: SafeInitializer = useMemo( - () => ({ - singleton: SAFE_SINGLETON_ADDRESS, - fallbackHandler: SAFE_4337_MODULE_ADDRESS, - signerFactory: WEBAUTHN_SIGNER_FACTORY_ADDRESS, - signerX: passkey.pubkeyCoordinates.x, - signerY: passkey.pubkeyCoordinates.y, - signerVerifiers: P256_VERIFIER_ADDRESS, - setupTo: SAFE_MODULE_SETUP_ADDRESS, - setupData: encodeSafeModuleSetupCall([SAFE_4337_MODULE_ADDRESS]), - }), - [passkey.pubkeyCoordinates.x, passkey.pubkeyCoordinates.y], - ) - - const unsignedUserOperation = useMemo( - () => prepareUserOperationWithInitialisation(SAFE_PROXY_FACTORY_ADDRESS, initializer), - [initializer], - ) - - const [feeData, feeDataStatus] = useFeeData(walletProvider) - const { userOpGasLimitEstimation, status: estimationStatus } = useUserOpGasLimitEstimation(unsignedUserOperation) - const gasParametersReady = - feeDataStatus === RequestStatus.SUCCESS && - estimationStatus === RequestStatus.SUCCESS && - typeof userOpGasLimitEstimation !== 'undefined' && - feeData?.maxFeePerGas != null && - feeData?.maxPriorityFeePerGas != null - - const [safeBalance, safeBalanceStatus] = useNativeTokenBalance(walletProvider, unsignedUserOperation.sender) - const [userOpHash, setUserOpHash] = useState() - - const deployed = safeCodeStatus === RequestStatus.SUCCESS && safeCode !== '0x' - const missingFunds = gasParametersReady ? getMissingAccountFunds(feeData?.maxFeePerGas, userOpGasLimitEstimation) : 0n - const isMissingFunds = !deployed && safeBalanceStatus === RequestStatus.SUCCESS && safeBalance === 0n - const readyToDeploy = !userOpHash && !deployed && gasParametersReady && !isMissingFunds - - const gasParametersError = feeDataStatus === RequestStatus.ERROR || estimationStatus === RequestStatus.ERROR - const gasParametersLoading = feeDataStatus === RequestStatus.LOADING || estimationStatus === RequestStatus.LOADING - - const handleDeploySafeClick = async () => { - if (!gasParametersReady) return - - const userOpToSign: UnsignedPackedUserOperation = { - ...unsignedUserOperation, - ...packGasParameters({ - verificationGasLimit: userOpGasLimitEstimation.verificationGasLimit, - callGasLimit: userOpGasLimitEstimation.callGasLimit, - maxPriorityFeePerGas: feeData?.maxPriorityFeePerGas, - maxFeePerGas: feeData?.maxFeePerGas, - }), - preVerificationGas: userOpGasLimitEstimation.preVerificationGas, - } - - const bundlerUserOpHash = await signAndSendDeploymentUserOp(userOpToSign, passkey) - setUserOpHash(bundlerUserOpHash) - } - - if (deployed) { - return - } - - return ( -
-

Safe Address: {unsignedUserOperation.sender}

- - {userOpHash && ( -

- Your Safe is being deployed. Track the user operation on{' '} - jiffyscan. Once deployed, the page will - automatically redirect to the Safe dashboard.⏳ -

- )} - - {isMissingFunds ? ( - <> - {gasParametersLoading &&

Estimating gas parameters...

} - {gasParametersError &&

Failed to estimate gas limit

} - {gasParametersReady && ( - - )} - - ) : null} - - {readyToDeploy && } -
- ) -} - -export { DeploySafe, loader } diff --git a/examples/4337-passkeys/src/routes/Home.tsx b/examples/4337-passkeys/src/routes/Home.tsx deleted file mode 100644 index 14c95ba3f..000000000 --- a/examples/4337-passkeys/src/routes/Home.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Navigate, redirect, useLoaderData } from 'react-router-dom' -import { getPasskeyFromLocalStorage, PasskeyLocalStorageFormat } from '../logic/passkeys.ts' -import { encodeSafeModuleSetupCall, getLaunchpadInitializer, getSafeAddress, type SafeInitializer } from '../logic/safe.ts' -import { - P256_VERIFIER_ADDRESS, - SAFE_4337_MODULE_ADDRESS, - SAFE_MODULE_SETUP_ADDRESS, - SAFE_SINGLETON_ADDRESS, - WEBAUTHN_SIGNER_FACTORY_ADDRESS, -} from '../config.ts' -import { useCodeAtAddress } from '../hooks/useCodeAtAddress.ts' -import { RequestStatus } from '../utils.ts' -import { DEPLOY_SAFE_ROUTE, CREATE_PASSKEY_ROUTE, getSafeRoute } from './constants.ts' - -import { useOutletContext } from '../hooks/UseOutletContext.tsx' - -type LoaderData = { - passkey: PasskeyLocalStorageFormat - safeAddress: string -} - -async function loader(): Promise { - const passkey = getPasskeyFromLocalStorage() - if (!passkey) { - return redirect(CREATE_PASSKEY_ROUTE) - } - - const initializer: SafeInitializer = { - singleton: SAFE_SINGLETON_ADDRESS, - fallbackHandler: SAFE_4337_MODULE_ADDRESS, - signerFactory: WEBAUTHN_SIGNER_FACTORY_ADDRESS, - signerX: passkey.pubkeyCoordinates.x, - signerY: passkey.pubkeyCoordinates.y, - signerVerifiers: P256_VERIFIER_ADDRESS, - setupTo: SAFE_MODULE_SETUP_ADDRESS, - setupData: encodeSafeModuleSetupCall([SAFE_4337_MODULE_ADDRESS]), - } - const launchpadInitializer = getLaunchpadInitializer(initializer) - const safeAddress = getSafeAddress(launchpadInitializer) - - return { passkey, safeAddress } -} - -// This page doesn't have a UI, it just determines where to redirect the user based on the state. -function Home() { - const { safeAddress, passkey } = useLoaderData() as LoaderData - const { walletProvider } = useOutletContext() - const [safeCode, requestStatus] = useCodeAtAddress(walletProvider, safeAddress) - - if (requestStatus === RequestStatus.LOADING) { - return

Loading...

- } - - if (requestStatus === RequestStatus.SUCCESS && safeCode !== '0x') { - return - } - - if (requestStatus === RequestStatus.SUCCESS && safeCode === '0x') { - return - } - - return

You shouldn't have landed on this page, but somehow you did it! Here's an Easter egg for you: 🐣

-} - -export { Home, loader } diff --git a/examples/4337-passkeys/src/routes/Root.tsx b/examples/4337-passkeys/src/routes/Root.tsx deleted file mode 100644 index 99bcd6ff6..000000000 --- a/examples/4337-passkeys/src/routes/Root.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Outlet } from 'react-router-dom' -import { useWeb3ModalAccount, useWeb3ModalProvider } from '@web3modal/ethers/react' -import safeLogo from '/safe-logo.svg' -import ConnectButton from '../components/ConnectButton.tsx' -import { ConnectWallet } from '../components/ConnectWallet.tsx' -import { APP_CHAIN_ID } from '../config.ts' -import { SwitchNetwork } from '../components/SwitchNetwork.tsx' - -function Root() { - const { walletProvider } = useWeb3ModalProvider() - const { chainId } = useWeb3ModalAccount() - - let content = <>{walletProvider && } - if (!walletProvider) { - content = - } else if (chainId !== APP_CHAIN_ID) { - content = - } - - return ( - <> -
- - Safe logo - - -
- -
-
-

4337 + Passkeys demo

- - {content} - - ) -} - -export { Root } diff --git a/examples/4337-passkeys/src/routes/Safe.tsx b/examples/4337-passkeys/src/routes/Safe.tsx deleted file mode 100644 index 23dc76393..000000000 --- a/examples/4337-passkeys/src/routes/Safe.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { ethers, isAddress } from 'ethers' -import { LoaderFunction, Navigate, redirect, useLoaderData, useParams } from 'react-router-dom' -import { RequestStatus } from '../utils' -import { useNativeTokenBalance } from '../hooks/useNativeTokenBalance' -import { useCodeAtAddress } from '../hooks/useCodeAtAddress' -import { getSafeWalletAppSafeDashboardLink } from '../logic/safeWalletApp.ts' -import { HOME_ROUTE } from './constants.ts' -import { useOutletContext } from '../hooks/UseOutletContext.tsx' -import { SendNativeToken } from '../components/SendNativeToken.tsx' -import { useEntryPointAccountNonce } from '../hooks/useEntryPointAccountNonce.ts' -import { useEntryPointAccountBalance } from '../hooks/useEntryPointAccountBalance.ts' -import { signAndSendUserOp, UnsignedPackedUserOperation } from '../logic/userOp.ts' -import { getPasskeyFromLocalStorage, PasskeyLocalStorageFormat } from '../logic/passkeys.ts' -import { getJsonRpcProviderFromEip1193Provider } from '../logic/wallets.ts' -import { useSignerAddressFromPubkeyCoords } from '../hooks/useSignerAddressFromPubkeyCoords.ts' - -const loader: LoaderFunction = async ({ params }) => { - const { safeAddress } = params - const passkey = getPasskeyFromLocalStorage() - - if (!isAddress(safeAddress) || !passkey) { - return redirect(HOME_ROUTE) - } - - return { passkey } -} - -function Safe() { - const { safeAddress } = useParams<{ safeAddress: string }>() - const { walletProvider } = useOutletContext() - const { passkey } = useLoaderData() as { passkey: PasskeyLocalStorageFormat } - const [signerAddress, signerAddressStatus] = useSignerAddressFromPubkeyCoords( - walletProvider, - passkey.pubkeyCoordinates.x, - passkey.pubkeyCoordinates.y, - ) - const [safeCode, safeCodeStatus] = useCodeAtAddress(walletProvider, safeAddress || '') - const [safeBalance, safeBalanceStatus] = useNativeTokenBalance(walletProvider, safeAddress || '') - const [safeNonce, safeNonceStatus] = useEntryPointAccountNonce(walletProvider, safeAddress || '') - const [safeEntryPointBalance, safeEntryPointBalanceStatus] = useEntryPointAccountBalance(walletProvider, safeAddress || '') - - const handleSendFunds = async (userOp: UnsignedPackedUserOperation) => { - const userOpHash = signAndSendUserOp(getJsonRpcProviderFromEip1193Provider(walletProvider), userOp, passkey) - - return userOpHash - } - - const notDeployed = safeCodeStatus === RequestStatus.SUCCESS && safeCode === '0x' - if (notDeployed) { - return - } - - if (!safeAddress) { - return null - } - - if ( - safeCodeStatus === RequestStatus.ERROR || - safeBalanceStatus === RequestStatus.ERROR || - safeNonceStatus === RequestStatus.ERROR || - safeEntryPointBalanceStatus === RequestStatus.ERROR || - signerAddressStatus === RequestStatus.ERROR - ) { - return
Error loading Safe data. Please refresh the page.
- } - - if (safeNonce === null || safeEntryPointBalance === null || signerAddress === null) { - return
Loading...
- } - - return ( -
-

- Safe Address: {safeAddress} -

- -

Balance: {safeBalanceStatus === RequestStatus.SUCCESS ? ethers.formatEther(safeBalance) : 'Loading...'}

- -

- EntryPoint Balance:{' '} - {safeEntryPointBalanceStatus === RequestStatus.SUCCESS ? ethers.formatEther(safeEntryPointBalance) : 'Loading...'} -

- - -
- ) -} - -export { Safe, loader } diff --git a/examples/4337-passkeys/src/routes/constants.ts b/examples/4337-passkeys/src/routes/constants.ts deleted file mode 100644 index 9e9c844a6..000000000 --- a/examples/4337-passkeys/src/routes/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -const HOME_ROUTE = '/' -const CREATE_PASSKEY_ROUTE = '/create-passkey' -const DEPLOY_SAFE_ROUTE = '/deploy-safe' -const SAFE_ROUTE = '/safe/:safeAddress' - -const getSafeRoute = (safeAddress: string) => `/safe/${safeAddress}` - -export { HOME_ROUTE, CREATE_PASSKEY_ROUTE, DEPLOY_SAFE_ROUTE, SAFE_ROUTE, getSafeRoute } diff --git a/examples/4337-passkeys/src/utils.ts b/examples/4337-passkeys/src/utils.ts deleted file mode 100644 index 1a2df1ad7..000000000 --- a/examples/4337-passkeys/src/utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Converts a number to an unpadded hexadecimal string. - * Metamask requires unpadded chain id for wallet_switchEthereumChain and wallet_addEthereumChain. - * - * @param n - The number to convert. - * @param withPrefix - Whether to include the "0x" prefix in the result. Default is true. - * @returns The unpadded hexadecimal string representation of the number. - */ -function numberToUnpaddedHex(n: number, withPrefix = true): string { - const hex = n.toString(16) - return `${withPrefix ? '0x' : ''}${hex}` -} - -/** - * Generates a random 256-bit unsigned integer. - * - * @returns {bigint} A random 256-bit unsigned integer. - * - * This function uses the Web Crypto API's `crypto.getRandomValues()` method to generate - * a uniformly distributed random value within the range of 256-bit unsigned integers - * (from 0 to 2^256 - 1). - */ -function getRandomUint256(): bigint { - const dest = new Uint8Array(32) // Create a typed array capable of storing 32 bytes or 256 bits - - crypto.getRandomValues(dest) // Fill the typed array with cryptographically secure random values - - let result = 0n - for (let i = 0; i < dest.length; i++) { - result |= BigInt(dest[i]) << BigInt(8 * i) // Combine individual bytes into one bigint - } - - return result -} - -/** - * Converts a hexadecimal string to a Uint8Array. - * - * @param hexString The hexadecimal string to convert. - * @returns The Uint8Array representation of the hexadecimal string. - */ -function hexStringToUint8Array(hexString: string): Uint8Array { - const arr = [] - for (let i = 0; i < hexString.length; i += 2) { - arr.push(parseInt(hexString.substr(i, 2), 16)) - } - return new Uint8Array(arr) -} - -/** - * Represents the status of a request. - */ -enum RequestStatus { - NOT_REQUESTED, - LOADING, - SUCCESS, - ERROR, -} - -export { RequestStatus, numberToUnpaddedHex, getRandomUint256, hexStringToUint8Array } diff --git a/examples/4337-passkeys/src/vite-env.d.ts b/examples/4337-passkeys/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/examples/4337-passkeys/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/4337-passkeys/tsconfig.json b/examples/4337-passkeys/tsconfig.json deleted file mode 100644 index a7fc6fbf2..000000000 --- a/examples/4337-passkeys/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/examples/4337-passkeys/tsconfig.node.json b/examples/4337-passkeys/tsconfig.node.json deleted file mode 100644 index 42872c59f..000000000 --- a/examples/4337-passkeys/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/examples/4337-passkeys/vite.config.ts b/examples/4337-passkeys/vite.config.ts deleted file mode 100644 index afa472812..000000000 --- a/examples/4337-passkeys/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig, loadEnv } from 'vite' -import react from '@vitejs/plugin-react-swc' -import commonjs from 'vite-plugin-commonjs' - -const REQUIRED_ENV_VARS = ['VITE_WC_CLOUD_PROJECT_ID', 'VITE_WC_4337_BUNDLER_URL'] - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd()) - - for (const key of REQUIRED_ENV_VARS) { - if (!env[key]) { - throw new Error(`Environment variable ${key} is missing`) - } - } - - return { - plugins: [react(), commonjs()], - } -}) diff --git a/modules/passkey/.env.example b/modules/passkey/.env.example index 7f45f45f4..6c371fde1 100644 --- a/modules/passkey/.env.example +++ b/modules/passkey/.env.example @@ -5,5 +5,3 @@ PK="" CUSTOM_NODE_URL="" # Used for etherscan verification ETHERSCAN_API_KEY="" -# Used for deploying contracts -DEPLOYMENT_ENTRY_POINT_ADDRESS="0x0000000071727De22E5E9d8BAf0edAc6f37da032" diff --git a/modules/passkey/README.md b/modules/passkey/README.md index cb0a0255a..034dc8a18 100644 --- a/modules/passkey/README.md +++ b/modules/passkey/README.md @@ -25,7 +25,7 @@ Use of `SafeWebAuthnSignerProxy` provides gas savings compared to the whole cont ### [SafeWebAuthnSignerFactory](./contracts/SafeWebAuthnSignerFactory.sol) -The `SafeWebAuthnSignerFactory` contract deploys the `SafeWebAuthnSignerProxy` contract with the public key coordinates and verifier information. The factory contract also supports signature verification for the public key and signature information without deploying the signer contract, which is used during the validation of ERC-4337 user operations by the experimental `SafeSignerLaunchpad` contract. Using [ISafeSignerFactory](./contracts/interfaces/ISafeSignerFactory.sol) interface and this factory contract address, new signers can be deployed. +The `SafeWebAuthnSignerFactory` contract deploys the `SafeWebAuthnSignerProxy` contract with the public key coordinates and verifier information. The factory contract also supports signature verification for the public key and signature information without deploying the signer contract. Using [ISafeSignerFactory](./contracts/interfaces/ISafeSignerFactory.sol) interface and this factory contract address, new signers can be deployed. ### [WebAuthn](./contracts/libraries/WebAuthn.sol) diff --git a/modules/passkey/contracts/4337/experimental/README.md b/modules/passkey/contracts/4337/experimental/README.md index 6fe28d034..05e597bac 100644 --- a/modules/passkey/contracts/4337/experimental/README.md +++ b/modules/passkey/contracts/4337/experimental/README.md @@ -2,9 +2,9 @@ This directory contains additional support contracts for using passkeys with Safes over ERC-4337. -> These contracts are marked as experimental as they are only needed when deploying Safes with initial passkey owners that are required for verifying the very first ERC-4337 user operation with `initCode`. We do not, however, recommend this as it would tie your Safe account's address to a passkey which may not be always available. In particular: the WebAuthn authenticator that stores a device-bound credential that does not allow for backups may be lost, the domain the credential is tied to may no longer be available, you lose access to the passkey provider where your WebAuthn credentials are stored (for example, you no longer have an iPhone or MacBook with access to your iCloud keychain passkeys). +> These contracts are only needed when deploying Safes with initial passkey owners that are required for verifying the very first ERC-4337 user operation with `initCode`. We do not, however, recommend this as it would tie your Safe account's address to a passkey which may not be always available. In particular: the WebAuthn authenticator that stores a device-bound credential that does not allow for backups may be lost, the domain the credential is tied to may no longer be available, you lose access to the passkey provider where your WebAuthn credentials are stored (for example, you no longer have an iPhone or MacBook with access to your iCloud keychain passkeys), etc. > -> As such, for the moment, we recommend that Safes be created with an ownership structure or recovery mechanism that allows passkey owners to be rotated in case access to the WebAuthn credential is lost. +> **As such, for the moment, we recommend that Safes be created with an ownership structure or recovery mechanism that allows passkey owners to be rotated in case access to the WebAuthn credential is lost.** ## Overview @@ -12,128 +12,19 @@ The core contract provided by the `passkey` module is the `SafeWebAuthnSignerFac There is one notable caveat when using the `passkey` module with ERC-4337 specifically, which is that ERC-4337 user operations can only deploy exactly one `CREATE2` contract whose address matches the `sender` of the user operation. This means that deploying both the Safe account and its WebAuthn credential owner in a user operation's `initCode` is not possible. -In order to work around this limitation, there are two possible workarounds that can be used: +In order to work around this limitation, this directory contains a shared signer, the `SafeWebAuthnSharedSigner`: a singleton that can be used as a Safe owner. The shared signer uses account storage for its configuration in order to circumvent any ERC-4337 restrictions on storage during `initCode`. This implementation allows for n/m (including 1/1) Safes with a single passkey owner to be able to execute transactions over ERC-4337. Note that since the signer is a single contract, it can only be used to represent a single passkey owner for any given Safe; however, additional passkey owners can still be added by using the `SafeWebAuthnSignerFactory` to deploy additional WebAuthn signer contracts and adding them as owners to the Safe. Additionally, multiple `SafeWebAuthnSharedSigner` contracts can be deployed and coexist in order to allow more passkeys to be used as additional owners with ERC-4337. -1. Using a "launchpad" contract, the `SafeSignerLaunchpad`: this implementation provides an alternative `singleton` implementation for the account that is used **only** for the first user operation and makes use of a `ISafeSignerFactory` to validate the WebAuthn signature without deploying the owner in the validation phase of the ERC-4337 user operation (i.e. `validateUserOp`). The WebAuthn owner is then deployed in the execution phase of the user operation, once there are no more restrictions on what is allowed to execute. This implementation allows for 1/1 Safes with a single passkey owner to be able to execute transactions over ERC-4337. -2. Using a shared signer, the `SafeWebAuthnSharedSigner`: this implementation provides a shared signer that can be used as a Safe owner. The shared signer uses account storage for its configuration in order to circumvent any ERC-4337 restrictions on storage during `initCode`. This implementation allows for n/m (including 1/1) Safes with a single passkey owner to be able to execute transactions over ERC-4337. Note that since the signer is a single contract, it can only be used to represent a single passkey owner for any given Safe; however, additional passkey owners can still be added by using the `SafeWebAuthnSignerFactory` to deploy additional WebAuthn signer contracts and adding them as owners to the Safe. - -Note that this restriction only applies if **you want to use the passkey module with a Safe over 4337 without any additional EOA owners**. If _any_ of the following applies to you, then the contracts provided in this directory are **not** required: +Note that this restriction only applies if **you want to use the passkey module with a Safe over 4337 without any additional EOA owners**. If _any_ of the following applies to you, then the contract provided in this directory is **not** required: - You want to deploy a Safe that is also owned by more than `threshold` additional EOA signers, in this case you can use the EOAs to sign the first ERC-4337 user operation that deploys the account and include in the execution phase a call to the `SafeWebAuthnSignerFactory` to deploy the passkey owner. - You want to deploy the Safe outside of ERC-4337, the WebAuthn signer instance as well as the Safe account can be deployed permissionlessly, so their creation can be batched together in a single transaction when deploying the Safe. Once the Safe and the WebAuthn signer are deployed, they can be used regularly over ERC-4337. - The passkey owner is already deployed, in this case, the standard ERC-4337 deployment process would apply where you would simply add the already created WebAuthn signer instance as an owner to the Safe. -## [Safe Signer Launchpad](./SafeSignerLaunchpad.sol) - -The Safe signer launchpad is used as a `singleton` implementation for the very first user operation. The signature check in the ERC-4337 validation phase is done **without** deploying the WebAuthn signer for the passkey credential using a well-defined `ISafeSignerFactory` interface. In the case the user operation is validated and then executed, the launchpad will: - -1. Set the `singleton` to the actual Safe implementation to use -2. Deploy the WebAuthn signer, so that it can be used normally over ERC-4337 for the following user operations - -Note that for the very first user operation, an EIP-712 `SafeInitOp` is signed instead of the usual `SafeOp` that is used by the canonical ERC-4337 module. This is done in order to distinguish signatures of a launchpad Safe's user operation from a standard ERC-4337 Safe user operation. - -### Init Code Example - -The `initCode` should be generated with: - -```solitidy -SafeProxyFactory proxyFactory = ...; -SafeSignerLaunchpad launchpad = ...; -Safe singleton = ...; -SafeWebAuthnSignerFactory signerFactory = ...; - -uint256 signerX = ...; -uint256 signerY = ...; -uint256 signerVerifiers = ...; - -address initializer = ...; -bytes memory initializerData = ...; -address fallbackHandler = ...; - -bytes memory initCode = abi.encodePacked( - proxyFactory, - abi.encodeCall( - launchpad.setup, - ( - address(singleton), - address(signerFactory), - signerX, - signerY, - signerVerifiers, - initializer, - initializerData, - fallbackHandler - ) - ) -); -``` - -### User Operation Execution Flow - -```mermaid -sequenceDiagram - participant CS as Browser - actor U as User - actor B as Bundler - participant EP as EntryPoint - participant SPF as SafeProxyFactory - participant SP as SafeProxy - participant SSL as SafeSignerLaunchpad - participant S as Safe - participant SWASF as SafeWebAuthnSignerFactory - participant SWASP as SafeWebAuthnSignerProxy - participant SWASS as SafeWebAuthnSignerSingleton - participant WAV as WebAuthn library - participant PV as P256Verifier - actor T as Target - - U->>+CS: Create Credential (User calls `navigator.credentials.create(...)`) - CS->>U: Decode public key from the return value - U->>SWASF: Get signer address (signer might not be deployed yet) - SWASF->>U: Signer address - U->>+B: Submit UserOp with `initCode` to deploy a SafeProxy with SafeSignerLaunchpad as singleton - - note over EP: account creation - B->>+EP: handleOps([userOp], ...) - EP->>+SPF: createProxyWithNonce(launchpad, setup, ...) - SPF->>SP: CREATE2 - SPF->>SP: setup(...) - SP-->>SSL: DELEGATECALL - SP->>S: DELEGATECALL setup(...) - S-->>SPF: ok - SPF-->>-EP: Proxy address - - note over EP: validation phase - EP->>+SP: validateUserOp(...) - SP-->>SSL: DELEGATECALL - SP->>SWASF: isValidSignatureForSigner(...) - SWASF->>SWASS: isValidSignature(...) || configuration - SWASS->>WAV: verifyWebAuthnSignatureAllowMalleability(...) - WAV->>PV: ecverify(...) - PV-->>WAV: Signature verification result - WAV-->>SWASS: Signature verification result - SWASS-->>SWASF: Signature verification result - SWASF-->>SP: ERC-1271 magic value - opt Pay required fee - SP->>EP: Perform fee payment - end - SP-->>-EP: Validation response - - note over EP: execution phase - EP->>+SP: promoteAccountAndExecuteUserOp(...) - SP-->>SSL: DELEGATECALL - SP->>SP: set Safe as singleton - SP->>SWASF: createSigner(...) - SP->>T: Execute user operation - T-->>SP: Result - SP-->>-EP: Result -``` - ## [Safe WebAuthn Shared Signer](./SafeWebAuthnSharedSigner.sol) -Alternatively, the shared signer can be used in order as an owner for the Safe. This method is simpler than the launchpad, in that there is no special setup step, and the standard Safe implementation and canonical ERC-4337 module can be used. The only additional requirement is that the Safe `setup` must delegate call into the `SafeWebAuthnSharedSigner` instance in order for it to set its configuration. When paired with the `Safe4337Module`, the `MultiSend` contract can be used to both enable the ERC-4337 support in the Safe as well as configure the WebAuthn credential in the WebAuthn shared signer. +The shared signer can be used as an owner for the Safe. This method has no special setup step, and the standard Safe implementation and canonical ERC-4337 module can be used. The only additional requirement is that the Safe `setup` must delegate call into the `SafeWebAuthnSharedSigner` instance in order for it to set its configuration. When paired with the `Safe4337Module`, the `MultiSend` contract can be used to both enable the ERC-4337 support in the Safe as well as configure the WebAuthn credential in the WebAuthn shared signer. -Because the shared signer is a single contract address, it can only ever represent a single passkey owner for Safe. However, additional passkey owners can be added for sWebAuthn signers created with the canonical `SafeWebAuthnSignerFactory` contract, and can even co-exist with the `SafeWebAuthnSharedSigner` owner (so there is no need to re-deploy a signer for the original WebAuthn credential represented by the shared signer). +Because the shared signer is a single contract address, it can only ever represent a single passkey owner for Safe. However, additional passkey owners can be added for WebAuthn signers created with the canonical `SafeWebAuthnSignerFactory` contract, and can even co-exist with the `SafeWebAuthnSharedSigner` owner (so there is no need to re-deploy a signer for the original WebAuthn credential represented by the shared signer) or multiple singleton instances of `SafeWebAuthnSharedSigner` can be deployed and co-exist as owners. ### Init Code Example diff --git a/modules/passkey/contracts/4337/experimental/SafeSignerLaunchpad.sol b/modules/passkey/contracts/4337/experimental/SafeSignerLaunchpad.sol deleted file mode 100644 index 030b34c66..000000000 --- a/modules/passkey/contracts/4337/experimental/SafeSignerLaunchpad.sol +++ /dev/null @@ -1,395 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -pragma solidity >=0.8.0 <0.9.0; - -import {IAccount} from "@account-abstraction/contracts/interfaces/IAccount.sol"; -import {PackedUserOperation} from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol"; -import {_packValidationData} from "@account-abstraction/contracts/core/Helpers.sol"; -import {SafeStorage} from "@safe-global/safe-contracts/contracts/libraries/SafeStorage.sol"; - -import {ISafeSignerFactory, P256} from "../../interfaces/ISafeSignerFactory.sol"; -import {ISafe} from "../../interfaces/ISafe.sol"; -import {ERC1271} from "../../libraries/ERC1271.sol"; - -/** - * @title Safe Launchpad for Custom ECDSA Signing Schemes. - * @dev A launchpad account implementation that enables the creation of Safes that use custom ECDSA signing schemes that - * require additional contract deployments over ERC-4337. Note that it is not safe to rely on this launchpad for - * deploying Safes that has an initial threshold greater than 1. This is because the first user operation (which can - * freely change the owner structure) will only ever require a single signature to execute, so effectively the initial - * owner will always have ultimate control over the Safe during the first user operation and can undo any changes to the - * `threshold` during the `setup` phase. - * @custom:security-contact bounty@safe.global - */ -contract SafeSignerLaunchpad is IAccount, SafeStorage { - /** - * @notice The EIP-712 type-hash for the domain separator used for verifying Safe initialization signatures. - * @custom:computed-as keccak256("EIP712Domain(uint256 chainId,address verifyingContract)") - */ - bytes32 private constant _DOMAIN_SEPARATOR_TYPEHASH = 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218; - - /** - * @notice The storage slot used for the target Safe singleton address to promote to. - * @custom:computed-as keccak256("SafeSignerLaunchpad.targetSingleton") - 1 - * @dev This value is intentionally computed to be a hash -1 as a precaution to avoid any potential issues from - * unintended hash collisions. - */ - uint256 private constant _TARGET_SINGLETON_SLOT = 0x610b07c5cf4b478e92ab041de73a412736c750f1bf07a613600b24b3a8bd597e; - - /** - * @notice The keccak256 hash of the EIP-712 SafeInitOp struct, representing the user operation to execute alongside initialization. - * {bytes32} userOpHash - The user operation hash being executed. - * {uint48} validAfter - A timestamp representing from when the user operation is valid. - * {uint48} validUntil - A timestamp representing until when the user operation is valid, or 0 to indicated "forever". - * {address} entryPoint - The address of the entry point that will execute the user operation. - * @custom:computed-as keccak256("SafeInitOp(bytes32 userOpHash,uint48 validAfter,uint48 validUntil,address entryPoint)") - */ - bytes32 private constant _SAFE_INIT_OP_TYPEHASH = 0x25838d3914a61e3531f21f12b8cd3110a5f9d478292d07dd197859a5c4eaacb2; - - /** - * @notice An error indicating that the entry point used when deploying a new module instance is invalid. - */ - error InvalidEntryPoint(); - - /** - * @notice An error indicating a `CALL` to a function that should only be `DELEGATECALL`-ed from an account proxy. - */ - error NotProxied(); - - /** - * @notice An error indicating that the call validating or executing a user operation was not called by the - * supported entry point contract. - */ - error UnsupportedEntryPoint(); - - /** - * @notice An error indicating an attempt to setup an account that has already been initialized. - */ - error AlreadyInitialized(); - - /** - * @notice An error indicating an attempt to execute a user operation on an account that has not been initialized. - */ - error NotInitialized(); - - /** - * @notice An error indicating that the account was initialized with an invalid Safe singleton address. - */ - error InvalidSingleton(); - - /** - * @notice An error indicating that the account was changed to use an invalid threshold value. Accounts initialized - * with the Safe launchpad must be initialized with a threshold of 1 as a single owner has complete control of the - * account during the first user operation. - */ - error InvalidThreshold(); - - /** - * @notice An error indicating that the user operation `callData` does not correspond to the supported execution - * function `promoteAccountAndExecuteUserOp`. - */ - error UnsupportedExecutionFunction(bytes4 selector); - - /** - * @notice An error indicating that the user operation failed to execute successfully. - */ - error ExecutionFailed(); - - /** - * @dev Address of the launchpad contract itself. it is used for determining whether or not the contract is being - * `DELEGATECALL`-ed from a proxy. - */ - address private immutable _SELF; - - /** - * @notice The address of the ERC-4337 entry point contract that this launchpad supports. - */ - address public immutable SUPPORTED_ENTRYPOINT; - - /** - * @notice Create a new launchpad contract instance. - * @param entryPoint The address of the ERC-4337 entry point contract that this launchpad supports. - */ - constructor(address entryPoint) { - if (entryPoint == address(0)) { - revert InvalidEntryPoint(); - } - - _SELF = address(this); - SUPPORTED_ENTRYPOINT = entryPoint; - } - - /** - * @notice Validates the call is done via a proxy contract via `DELEGATECALL`, and that the launchpad is not being - * called directly. - */ - modifier onlyProxy() { - if (address(this) == _SELF) { - revert NotProxied(); - } - _; - } - - /** - * @notice Validates the call is initiated by the supported entry point. - */ - modifier onlySupportedEntryPoint() { - if (msg.sender != SUPPORTED_ENTRYPOINT) { - revert UnsupportedEntryPoint(); - } - _; - } - - /** - * @notice Accept transfers. - * @dev The launchpad accepts transfers to allow funding of the account in case it was deployed and initialized - * without pre-funding. Note that it only accepts transfers if it is called via a proxy contract. - */ - receive() external payable onlyProxy {} - - /** - * @notice Sets up the account with the specified initialization parameters. - * @dev This function can only be called by a proxy contract that has not yet been initialized. Internally, it calls - * the Safe singleton setup function on the account with some pre-determined parameters. In particular, it uses a - * fixed ownership structure for the deployed Safe. - * @param singleton The singleton to evolve into during the setup. - * @param signerFactory The custom ECDSA signer factory to use for creating an owner. - * @param signerX The X coordinate of the signer's public key. - * @param signerY The Y coordinate of the signer's public key. - * @param signerVerifiers The P-256 verifiers to use. - * @param initializer The Safe setup initializer address. - * @param initializerData The calldata for the setup `DELEGATECALL`. - * @param fallbackHandler Handler for fallback calls to the Safe. - */ - function setup( - address singleton, - address signerFactory, - uint256 signerX, - uint256 signerY, - P256.Verifiers signerVerifiers, - address initializer, - bytes calldata initializerData, - address fallbackHandler - ) external onlyProxy { - if (_targetSingleton() != address(0)) { - revert AlreadyInitialized(); - } - - if (singleton == address(0)) { - revert InvalidSingleton(); - } - _setTargetSingleton(singleton); - - address[] memory owners = new address[](1); - owners[0] = ISafeSignerFactory(signerFactory).getSigner(signerX, signerY, signerVerifiers); - - // Call the Safe setup function, making sure to replace the `singleton` that the proxy uses. This is important - // in order to ensure that the Safe setup function works as expected, in case it calls Safe methods. - SafeStorage.singleton = singleton; - ISafe(address(this)).setup(owners, 1, initializer, initializerData, fallbackHandler, address(0), 0, payable(address(0))); - SafeStorage.singleton = _SELF; - - // We need to check that the setup did not change the threshold to an unsupported value. This is to prevent - // false security assumptions where the `setup` can be used to add owners and increase the threshold. Since - // the user operation validation in this contract checks only a single signature, a threshold other than 1 would - // be ignored for the first user operation before the account gets promoted to a Safe. Since the single owner - // can change the ownership structure in that single user operation, it is not safe to assume that a threshold - // other than 1 will be respected. In order to change ownership structures during account creation, instead - // encode the ownership structure changes in the actual user operation. - if (threshold != 1) { - revert InvalidThreshold(); - } - } - - /** - * @notice Computes the EIP-712 domain separator for Safe launchpad operations. - * @return domainSeparatorHash The EIP-712 domain separator hash for this contract. - */ - function domainSeparator() public view returns (bytes32) { - return keccak256(abi.encode(_DOMAIN_SEPARATOR_TYPEHASH, block.chainid, _SELF)); - } - - /** - * @notice Compute the {SafeInitOp} hash of the first user operation that initializes the Safe. - * @dev The hash is generated using the keccak256 hash function and the EIP-712 standard. It is signed by the Safe - * owner that is specified as part of the {SafeInit}. Using a completely separate hash from the {SafeInit} allows - * the account address to remain the same regardless of the first user operation that gets executed by the account. - * @param userOpHash The ERC-4337 user operation hash. - * @param validAfter The timestamp the user operation is valid from. - * @param validUntil The timestamp the user operation is valid until. - * @return operationHash The Safe initialization user operation hash. - */ - function getOperationHash(bytes32 userOpHash, uint48 validAfter, uint48 validUntil) public view returns (bytes32 operationHash) { - operationHash = keccak256( - abi.encodePacked( - bytes2(0x1901), - domainSeparator(), - keccak256(abi.encode(_SAFE_INIT_OP_TYPEHASH, userOpHash, validAfter, validUntil, SUPPORTED_ENTRYPOINT)) - ) - ); - } - - /** - * @notice Validates a user operation provided by the entry point. - * @inheritdoc IAccount - */ - function validateUserOp( - PackedUserOperation calldata userOp, - bytes32 userOpHash, - uint256 missingAccountFunds - ) external override onlySupportedEntryPoint returns (uint256 validationData) { - bytes4 selector = bytes4(userOp.callData[:4]); - if (selector != this.promoteAccountAndExecuteUserOp.selector) { - revert UnsupportedExecutionFunction(selector); - } - - (address signerFactory, uint256 signerX, uint256 signerY, P256.Verifiers signerVerifiers) = abi.decode( - userOp.callData[4:], - (address, uint256, uint256, P256.Verifiers) - ); - - validationData = _validateSignatures(userOp, userOpHash, signerFactory, signerX, signerY, signerVerifiers); - if (missingAccountFunds > 0) { - // solhint-disable-next-line no-inline-assembly - assembly ("memory-safe") { - // The `pop` is necessary here because solidity 0.5.0 enforces "strict" assembly blocks and "statements - // (elements of a block) are disallowed if they return something onto the stack at the end". This is not - // well documented, the quote is taken from . The - // compiler will throw an error if we keep the success value on the stack - pop(call(gas(), caller(), missingAccountFunds, 0, 0, 0, 0)) - } - } - } - - /** - * @notice Completes the account initialization by promoting the account to proxy to the target Safe singleton and - * deploying the signer contract, and then executes the user operation. - * @dev This function is only ever called by the entry point as part of the execution phase of the user operation. - * Validation of the parameters, that they match the ones provided at initialization, is done by {validateUserOp} - * as part of the the ERC-4337 user operation validation phase. - * @param signerFactory The custom ECDSA signer factory to use for creating the owner. - * @param signerX The X coordinate of the signer's public key. - * @param signerY The Y coordinate of the signer's public key. - * @param signerVerifiers The P-256 verifiers to use. - * @param to Destination address of the user operation. - * @param value Ether value of the user operation. - * @param data Data payload of the user operation. - * @param operation Operation type of the user operation. - */ - function promoteAccountAndExecuteUserOp( - address signerFactory, - uint256 signerX, - uint256 signerY, - P256.Verifiers signerVerifiers, - address to, - uint256 value, - bytes memory data, - uint8 operation - ) external onlySupportedEntryPoint { - address singleton = _targetSingleton(); - if (singleton == address(0)) { - revert NotInitialized(); - } - - SafeStorage.singleton = singleton; - _setTargetSingleton(address(0)); - - ISafeSignerFactory(signerFactory).createSigner(signerX, signerY, signerVerifiers); - - bool success; - // solhint-disable-next-line no-inline-assembly - assembly ("memory-safe") { - switch operation - case 0 { - success := call(gas(), to, value, add(data, 0x20), mload(data), 0, 0) - } - case 1 { - success := delegatecall(gas(), to, add(data, 0x20), mload(data), 0, 0) - } - default { - // The operation does not match one of the expected enum values, revert with the appropriate panic. - // See . - mstore(0x00, hex"4e487b71") - mstore(0x04, 0x21) - revert(0, 0x24) - } - } - - if (!success) { - revert ExecutionFailed(); - } - } - - /** - * @dev Validates that the user operation is correctly signed and returns an ERC-4337 packed validation data - * of `validAfter || validUntil || authorizer`: - * - `authorizer`: 20-byte address, 0 for valid signature or 1 to mark signature failure (this module does not make use of signature aggregators). - * - `validUntil`: 6-byte timestamp value, or zero for "infinite". The user operation is valid only up to this time. - * - `validAfter`: 6-byte timestamp. The user operation is valid only after this time. - * @param userOp User operation struct. - * @param signerFactory The custom ECDSA signer factory to use for creating the owner. - * @param signerX The X coordinate of the signer's public key. - * @param signerY The Y coordinate of the signer's public key. - * @param signerVerifiers The P-256 verifiers to use. - * @return validationData An integer indicating the result of the validation. - */ - function _validateSignatures( - PackedUserOperation calldata userOp, - bytes32 userOpHash, - address signerFactory, - uint256 signerX, - uint256 signerY, - P256.Verifiers signerVerifiers - ) internal view returns (uint256 validationData) { - uint48 validAfter; - uint48 validUntil; - bytes calldata signature; - { - bytes calldata sig = userOp.signature; - validAfter = uint48(bytes6(sig[0:6])); - validUntil = uint48(bytes6(sig[6:12])); - signature = sig[12:]; - } - - bytes32 operationHash = getOperationHash(userOpHash, validAfter, validUntil); - - bool failure; - if (owners[ISafeSignerFactory(signerFactory).getSigner(signerX, signerY, signerVerifiers)] == address(0)) { - failure = true; - } else { - try - ISafeSignerFactory(signerFactory).isValidSignatureForSigner(operationHash, signature, signerX, signerY, signerVerifiers) - returns (bytes4 magicValue) { - failure = magicValue != ERC1271.MAGIC_VALUE; - } catch { - failure = true; - } - } - - // The timestamps are validated by the entry point, therefore we will not check them again - validationData = _packValidationData(failure, validUntil, validAfter); - } - - /** - * @notice Reads the configured target Safe singleton address to promote to from storage. - * @return value The target Safe singleton address. - */ - function _targetSingleton() internal view returns (address value) { - // solhint-disable-next-line no-inline-assembly - assembly ("memory-safe") { - // Note that we explicitly don't mask the address, as Solidity will generate masking code for every time - // the variable is read. - value := sload(_TARGET_SINGLETON_SLOT) - } - } - - /** - * @notice Sets an target Safe singleton address to promote to in storage. - * @param value The target Safe singleton address. - */ - function _setTargetSingleton(address value) internal { - // solhint-disable-next-line no-inline-assembly - assembly ("memory-safe") { - sstore(_TARGET_SINGLETON_SLOT, value) - } - } -} diff --git a/modules/passkey/src/deploy/launchpad.ts b/modules/passkey/src/deploy/launchpad.ts deleted file mode 100644 index dd9b4730a..000000000 --- a/modules/passkey/src/deploy/launchpad.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { DeployFunction } from 'hardhat-deploy/types' - -const ENTRY_POINT = process.env.DEPLOYMENT_ENTRY_POINT_ADDRESS - -const deploy: DeployFunction = async ({ deployments, getNamedAccounts }) => { - const { deployer } = await getNamedAccounts() - const { deploy } = deployments - - const entryPoint = await deployments.getOrNull('EntryPoint').then((deployment) => deployment?.address ?? ENTRY_POINT) - - await deploy('SafeSignerLaunchpad', { - from: deployer, - args: [entryPoint], - log: true, - deterministicDeployment: true, - }) -} - -deploy.dependencies = ['entrypoint'] - -export default deploy diff --git a/modules/passkey/test/4337/experimental/Safe4337Module.spec.ts b/modules/passkey/test/4337/experimental/Safe4337Module.spec.ts deleted file mode 100644 index ccd3242e0..000000000 --- a/modules/passkey/test/4337/experimental/Safe4337Module.spec.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { expect } from 'chai' -import { deployments, ethers } from 'hardhat' -import { buildSignatureBytes, logGas } from '@safe-global/safe-4337/src/utils/execution' -import { - buildSafeUserOpTransaction, - buildPackedUserOperationFromSafeUserOperation, - calculateSafeOperationHash, - packGasParameters, -} from '@safe-global/safe-4337/src/utils/userOp' -import { chainId, encodeMultiSendTransactions } from '@safe-global/safe-4337/test/utils/encoding' -import { WebAuthnCredentials } from '../../utils/webauthnShim' -import { decodePublicKey, encodeWebAuthnSignature } from '../../../src/utils/webauthn' - -describe('Safe4337Module', () => { - const setupTests = deployments.createFixture(async ({ deployments }) => { - const { - SafeModuleSetup, - SafeL2, - SafeProxyFactory, - MultiSend, - FCLP256Verifier, - Safe4337Module, - SafeSignerLaunchpad, - EntryPoint, - SafeWebAuthnSignerFactory, - SafeWebAuthnSharedSigner, - } = await deployments.fixture() - - const [user] = await ethers.getSigners() - const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address) - const module = await ethers.getContractAt(Safe4337Module.abi, Safe4337Module.address) - const proxyFactory = await ethers.getContractAt(SafeProxyFactory.abi, SafeProxyFactory.address) - const multiSend = await ethers.getContractAt('MultiSend', MultiSend.address) - const safeModuleSetup = await ethers.getContractAt(SafeModuleSetup.abi, SafeModuleSetup.address) - const signerLaunchpad = await ethers.getContractAt('SafeSignerLaunchpad', SafeSignerLaunchpad.address) - const singleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address) - const signerFactory = await ethers.getContractAt('SafeWebAuthnSignerFactory', SafeWebAuthnSignerFactory.address) - const sharedSigner = await ethers.getContractAt('SafeWebAuthnSharedSigner', SafeWebAuthnSharedSigner.address) - const verifiers = BigInt(FCLP256Verifier.address) - - const navigator = { - credentials: new WebAuthnCredentials(), - } - - return { - user, - proxyFactory, - multiSend, - safeModuleSetup, - module, - entryPoint, - signerLaunchpad, - singleton, - signerFactory, - sharedSigner, - verifiers, - navigator, - } - }) - - describe('SafeSignerLaunchpad', () => { - describe('executeUserOp - new account', () => { - it('should execute user operation', async () => { - const { user, proxyFactory, safeModuleSetup, module, entryPoint, signerLaunchpad, singleton, signerFactory, navigator, verifiers } = - await setupTests() - - const credential = navigator.credentials.create({ - publicKey: { - rp: { - name: 'Safe', - id: 'safe.global', - }, - user: { - id: ethers.getBytes(ethers.id('chucknorris')), - name: 'chucknorris', - displayName: 'Chuck Norris', - }, - challenge: ethers.toBeArray(Date.now()), - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - }, - }) - const publicKey = decodePublicKey(credential.response) - const signerAddress = await signerFactory.getSigner(publicKey.x, publicKey.y, verifiers) - - const launchpadInitializer = signerLaunchpad.interface.encodeFunctionData('setup', [ - singleton.target, - signerFactory.target, - publicKey.x, - publicKey.y, - verifiers, - safeModuleSetup.target, - safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]]), - module.target, - ]) - const safeSalt = Date.now() - const safe = await proxyFactory.createProxyWithNonce.staticCall(signerLaunchpad.target, launchpadInitializer, safeSalt) - const userOp = { - sender: safe, - nonce: ethers.toBeHex(await entryPoint.getNonce(safe, 0)), - initCode: ethers.solidityPacked( - ['address', 'bytes'], - [ - proxyFactory.target, - proxyFactory.interface.encodeFunctionData('createProxyWithNonce', [signerLaunchpad.target, launchpadInitializer, safeSalt]), - ], - ), - callData: signerLaunchpad.interface.encodeFunctionData('promoteAccountAndExecuteUserOp', [ - signerFactory.target, - publicKey.x, - publicKey.y, - verifiers, - user.address, - ethers.parseEther('0.5'), - '0x', - 0, - ]), - preVerificationGas: ethers.toBeHex(60000), - ...packGasParameters({ - verificationGasLimit: 1000000, - callGasLimit: 2500000, - maxPriorityFeePerGas: 10000000000, - maxFeePerGas: 10000000000, - }), - paymasterAndData: '0x', - } - - const safeInitOp = { - userOpHash: await entryPoint.getUserOpHash({ ...userOp, signature: '0x' }), - validAfter: 0, - validUntil: 0, - entryPoint: entryPoint.target, - } - const safeInitOpHash = ethers.TypedDataEncoder.hash( - { verifyingContract: await signerLaunchpad.getAddress(), chainId: await chainId() }, - { - SafeInitOp: [ - { type: 'bytes32', name: 'userOpHash' }, - { type: 'uint48', name: 'validAfter' }, - { type: 'uint48', name: 'validUntil' }, - { type: 'address', name: 'entryPoint' }, - ], - }, - safeInitOp, - ) - - const assertion = navigator.credentials.get({ - publicKey: { - challenge: ethers.getBytes(safeInitOpHash), - rpId: 'safe.global', - allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], - userVerification: 'required', - }, - }) - const signature = ethers.solidityPacked( - ['uint48', 'uint48', 'bytes'], - [safeInitOp.validAfter, safeInitOp.validUntil, encodeWebAuthnSignature(assertion.response)], - ) - - await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) - expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) - expect(await ethers.provider.getCode(safe)).to.equal('0x') - expect(await ethers.provider.getCode(signerAddress)).to.equal('0x') - - await logGas('WebAuthn signer Safe deployment', entryPoint.handleOps([{ ...userOp, signature }], user.address)) - - expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) - expect(await ethers.provider.getCode(safe)).to.not.equal('0x') - expect(await ethers.provider.getCode(signerAddress)).to.not.equal('0x') - - const [implementation] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], await ethers.provider.getStorage(safe, 0)) - expect(implementation).to.equal(singleton.target) - - const safeInstance = singleton.attach(safe) as typeof singleton - expect(await safeInstance.getOwners()).to.deep.equal([signerAddress]) - }) - }) - }) - - describe('SafeWebAuthnSharedSigner', () => { - describe('executeUserOp - new account', () => { - it('should execute user operation', async () => { - const { user, proxyFactory, multiSend, safeModuleSetup, module, entryPoint, singleton, sharedSigner, navigator, verifiers } = - await setupTests() - - const credential = navigator.credentials.create({ - publicKey: { - rp: { - name: 'Safe', - id: 'safe.global', - }, - user: { - id: ethers.getBytes(ethers.id('chucknorris')), - name: 'chucknorris', - displayName: 'Chuck Norris', - }, - challenge: ethers.toBeArray(Date.now()), - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - }, - }) - const publicKey = decodePublicKey(credential.response) - - const initializer = singleton.interface.encodeFunctionData('setup', [ - [sharedSigner.target], - 1, - multiSend.target, - multiSend.interface.encodeFunctionData('multiSend', [ - encodeMultiSendTransactions([ - { - op: 1 as const, - to: safeModuleSetup.target, - data: safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]]), - }, - { - op: 1 as const, - to: sharedSigner.target, - data: sharedSigner.interface.encodeFunctionData('configure', [{ ...publicKey, verifiers }]), - }, - ]), - ]), - module.target, - ethers.ZeroAddress, - 0, - ethers.ZeroAddress, - ]) - const safeSalt = Date.now() - const safe = await proxyFactory.createProxyWithNonce.staticCall(singleton.target, initializer, safeSalt) - - const safeOp = buildSafeUserOpTransaction( - safe, - user.address, - ethers.parseEther('0.5'), - '0x', - await entryPoint.getNonce(safe, 0), - await entryPoint.getAddress(), - false, - false, - { - initCode: ethers.solidityPacked( - ['address', 'bytes'], - [ - proxyFactory.target, - proxyFactory.interface.encodeFunctionData('createProxyWithNonce', [singleton.target, initializer, safeSalt]), - ], - ), - verificationGasLimit: 700000, - }, - ) - const safeOpHash = await module.getOperationHash( - buildPackedUserOperationFromSafeUserOperation({ - safeOp, - signature: '0x', - }), - ) - - const assertion = navigator.credentials.get({ - publicKey: { - challenge: ethers.getBytes(safeOpHash), - rpId: 'safe.global', - allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], - userVerification: 'required', - }, - }) - const signature = buildSignatureBytes([ - { - signer: sharedSigner.target as string, - data: encodeWebAuthnSignature(assertion.response), - dynamic: true, - }, - ]) - - await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) - expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) - expect(await ethers.provider.getCode(safe)).to.equal('0x') - expect(await sharedSigner.getConfiguration(safe)).to.deep.equal([0n, 0n, 0n]) - - await logGas( - 'WebAuthn signer Safe deployment', - entryPoint.handleOps([buildPackedUserOperationFromSafeUserOperation({ safeOp, signature })], user.address), - ) - - expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) - expect(await ethers.provider.getCode(safe)).to.not.equal('0x') - expect(await sharedSigner.getConfiguration(safe)).to.deep.equal([publicKey.x, publicKey.y, verifiers]) - - const [implementation] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], await ethers.provider.getStorage(safe, 0)) - expect(implementation).to.equal(singleton.target) - - const safeInstance = singleton.attach(safe) as typeof singleton - expect(await safeInstance.getOwners()).to.deep.equal([sharedSigner.target]) - }) - }) - - describe('executeUserOp - existing account', () => { - it('should execute user operation', async () => { - const { user, proxyFactory, multiSend, safeModuleSetup, module, entryPoint, singleton, sharedSigner, navigator, verifiers } = - await setupTests() - const credential = navigator.credentials.create({ - publicKey: { - rp: { - name: 'Safe', - id: 'safe.global', - }, - user: { - id: ethers.getBytes(ethers.id('chucknorris')), - name: 'chucknorris', - displayName: 'Chuck Norris', - }, - challenge: ethers.toBeArray(Date.now()), - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - }, - }) - const publicKey = decodePublicKey(credential.response) - - const initializer = singleton.interface.encodeFunctionData('setup', [ - [sharedSigner.target], - 1, - multiSend.target, - multiSend.interface.encodeFunctionData('multiSend', [ - encodeMultiSendTransactions([ - { - op: 1 as const, - to: safeModuleSetup.target, - data: safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]]), - }, - { - op: 1 as const, - to: sharedSigner.target, - data: sharedSigner.interface.encodeFunctionData('configure', [{ ...publicKey, verifiers }]), - }, - ]), - ]), - module.target, - ethers.ZeroAddress, - 0, - ethers.ZeroAddress, - ]) - const safeSalt = Date.now() - const safe = await proxyFactory.createProxyWithNonce.staticCall(singleton, initializer, safeSalt) - await proxyFactory.createProxyWithNonce(singleton, initializer, safeSalt) - - const safeOp = buildSafeUserOpTransaction( - safe, - user.address, - ethers.parseEther('0.5'), - '0x', - await entryPoint.getNonce(safe, 0), - await entryPoint.getAddress(), - ) - const safeOpHash = calculateSafeOperationHash(await module.getAddress(), safeOp, await chainId()) - const assertion = navigator.credentials.get({ - publicKey: { - challenge: ethers.getBytes(safeOpHash), - rpId: 'safe.global', - allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], - userVerification: 'required', - }, - }) - const signature = buildSignatureBytes([ - { - signer: sharedSigner.target as string, - data: encodeWebAuthnSignature(assertion.response), - dynamic: true, - }, - ]) - - await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) - expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) - - const userOp = buildPackedUserOperationFromSafeUserOperation({ safeOp, signature }) - await logGas('WebAuthn signer Safe operation', entryPoint.handleOps([userOp], user.address)) - - expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) - }) - }) - }) -}) diff --git a/modules/passkey/test/4337/experimental/SafeSignerLaunchpad.spec.ts b/modules/passkey/test/4337/experimental/SafeSignerLaunchpad.spec.ts deleted file mode 100644 index b4e884ee2..000000000 --- a/modules/passkey/test/4337/experimental/SafeSignerLaunchpad.spec.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { setBalance } from '@nomicfoundation/hardhat-network-helpers' -import { expect } from 'chai' -import { deployments, ethers } from 'hardhat' - -import { SafeSignerLaunchpad, PackedUserOperationStruct } from '../../../typechain-types/contracts/4337/experimental/SafeSignerLaunchpad' -import * as ERC1271 from '../../utils/erc1271' - -describe('SafeSignerLaunchpad', () => { - const setupTests = deployments.createFixture(async () => { - const { EntryPoint, SafeSignerLaunchpad, SafeProxyFactory, SafeL2, MultiSend } = await deployments.run() - - const safeSingleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address) - const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address) - const proxyFactory = await ethers.getContractAt('SafeProxyFactory', SafeProxyFactory.address) - const signerLaunchpad = await ethers.getContractAt('SafeSignerLaunchpad', SafeSignerLaunchpad.address) - const multiSend = await ethers.getContractAt('MultiSend', MultiSend.address) - - const entryPointImpersonator = await ethers.getImpersonatedSigner(EntryPoint.address) - await setBalance(EntryPoint.address, ethers.parseEther('100')) - - const deployProxyWithoutSetup = async () => { - const proxy = await ethers.getContractAt( - 'SafeSignerLaunchpad', - await proxyFactory.createProxyWithNonce.staticCall(signerLaunchpad, '0x', 0), - ) - await proxyFactory.createProxyWithNonce(signerLaunchpad, '0x', 0) - return proxy - } - - const MockContract = await ethers.getContractFactory('MockContract') - const mockSignerFactory = await MockContract.deploy() - const mockSigner = await MockContract.deploy() - const defaultParams = { - singleton: safeSingleton, - signerFactory: await ethers.getContractAt('ISafeSignerFactory', mockSignerFactory), - signerX: ethers.id('publicKey.x'), - signerY: ethers.id('publicKey.y'), - signerVerifiers: ethers.dataSlice(ethers.id('verifiers'), 0, 22), - initializer: ethers.ZeroAddress, - initializerData: '0x', - fallbackHandler: ethers.ZeroAddress, - } - const deployDefaultProxy = async () => { - const setup = signerLaunchpad.interface.encodeFunctionData('setup', [ - await defaultParams.singleton.getAddress(), - await defaultParams.signerFactory.getAddress(), - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - defaultParams.initializer, - defaultParams.initializerData, - defaultParams.fallbackHandler, - ]) - await mockSignerFactory.givenCalldataReturnAddress( - defaultParams.signerFactory.interface.encodeFunctionData('getSigner', [ - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ]), - mockSigner, - ) - const proxy = await ethers.getContractAt( - 'SafeSignerLaunchpad', - await proxyFactory.createProxyWithNonce.staticCall(signerLaunchpad, setup, 0), - ) - await proxyFactory.createProxyWithNonce(signerLaunchpad, setup, 0) - return proxy - } - - async function getUserOp(proxy: SafeSignerLaunchpad, overrides: Partial = {}) { - return { - sender: await proxy.getAddress(), - nonce: await entryPoint.getNonce(proxy, 0), - initCode: '0x', - callData: proxy.interface.encodeFunctionData('promoteAccountAndExecuteUserOp', [ - await defaultParams.signerFactory.getAddress(), - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ethers.ZeroAddress, - 0, - '0x', - 0, - ]), - accountGasLimits: `0x${'01'.repeat(16)}${'02'.repeat(16)}`, - preVerificationGas: `0x${'03'.repeat(16)}`, - gasFees: `0x${'04'.repeat(16)}${'05'.repeat(16)}`, - paymasterAndData: `0x01020304`, - signature: `0x`, - ...overrides, - } - } - - return { - safeSingleton, - multiSend, - entryPoint, - entryPointImpersonator, - proxyFactory, - signerLaunchpad, - deployProxyWithoutSetup, - mockSignerFactory, - mockSigner, - defaultParams, - deployDefaultProxy, - getUserOp, - } - }) - - describe('constructor', function () { - it('Should set immutables', async () => { - const { entryPoint, signerLaunchpad } = await setupTests() - - expect(await signerLaunchpad.SUPPORTED_ENTRYPOINT()).to.equal(entryPoint.target) - }) - - it('Should revert on invalid EntryPoint', async () => { - const SafeSignerLaunchpad = await ethers.getContractFactory('SafeSignerLaunchpad') - await expect(SafeSignerLaunchpad.deploy(ethers.ZeroAddress)).to.be.revertedWithCustomError(SafeSignerLaunchpad, 'InvalidEntryPoint') - }) - }) - - describe('receive', function () { - it('Should accept Ether transfers', async () => { - const { deployProxyWithoutSetup } = await setupTests() - - const proxy = await deployProxyWithoutSetup() - - await expect(proxy.fallback!({ value: ethers.parseEther('1') })).to.not.be.reverted - expect(await ethers.provider.getBalance(proxy)).to.equal(ethers.parseEther('1')) - }) - - it('Should revert if transferred to the singleton directly', async () => { - const { signerLaunchpad } = await setupTests() - - await expect(signerLaunchpad.fallback!({ value: ethers.parseEther('1') })).to.be.revertedWithCustomError( - signerLaunchpad, - 'NotProxied', - ) - }) - }) - - describe('setup', function () { - it('Should setup the proxy account', async () => { - const { deployProxyWithoutSetup, mockSignerFactory, defaultParams, safeSingleton } = await setupTests() - - const signer = ethers.getAddress(ethers.hexlify(ethers.randomBytes(20))) - await mockSignerFactory.givenCalldataReturnAddress( - defaultParams.signerFactory.interface.encodeFunctionData('getSigner', [ - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ]), - signer, - ) - - const proxy = await deployProxyWithoutSetup() - await expect( - proxy.setup( - defaultParams.singleton, - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - defaultParams.initializer, - defaultParams.initializerData, - defaultParams.fallbackHandler, - ), - ) - .to.emit(safeSingleton.attach(await proxy.getAddress()), 'SafeSetup') - .withArgs(await proxy.getAddress(), [signer], 1, defaultParams.initializer, defaultParams.fallbackHandler) - }) - - it('Should revert on invalid singleton address', async () => { - const { deployProxyWithoutSetup, defaultParams } = await setupTests() - - const proxy = await deployProxyWithoutSetup() - await expect( - proxy.setup( - ethers.ZeroAddress, - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - defaultParams.initializer, - defaultParams.initializerData, - defaultParams.fallbackHandler, - ), - ).to.revertedWithCustomError(proxy, 'InvalidSingleton') - }) - - it('Should revert if already set up', async () => { - const { deployDefaultProxy } = await setupTests() - - const proxy = await deployDefaultProxy() - await expect( - proxy.setup(ethers.ZeroAddress, ethers.ZeroAddress, 0, 0, 0, ethers.ZeroAddress, '0x', ethers.ZeroAddress), - ).to.be.revertedWithCustomError(proxy, 'AlreadyInitialized') - }) - - it('Should revert if threshold is not exactly 1 after setup', async () => { - const { deployProxyWithoutSetup, mockSignerFactory, defaultParams } = await setupTests() - - const signer = ethers.getAddress(ethers.hexlify(ethers.randomBytes(20))) - await mockSignerFactory.givenCalldataReturnAddress( - defaultParams.signerFactory.interface.encodeFunctionData('getSigner', [ - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ]), - signer, - ) - - const MockContract = await ethers.getContractFactory('MockContract') - const mockSingleton = await MockContract.deploy() - await mockSingleton.givenAnyReturnBool(true) - - const proxy = await deployProxyWithoutSetup() - await expect( - proxy.setup( - mockSingleton, - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - defaultParams.initializer, - defaultParams.initializerData, - defaultParams.fallbackHandler, - ), - ).to.revertedWithCustomError(proxy, 'InvalidThreshold') - }) - - it('Should revert if called to the singleton directly', async () => { - const { signerLaunchpad } = await setupTests() - - await expect( - signerLaunchpad.setup(ethers.ZeroAddress, ethers.ZeroAddress, 0, 0, 0, ethers.ZeroAddress, '0x', ethers.ZeroAddress), - ).to.be.revertedWithCustomError(signerLaunchpad, 'NotProxied') - }) - }) - - describe('domainSeparator', function () { - it('Should return the correct domain separator hash', async () => { - const { deployDefaultProxy, signerLaunchpad } = await setupTests() - - const { chainId } = await ethers.provider.getNetwork() - const proxy = await deployDefaultProxy() - - expect(await proxy.domainSeparator()).to.equal( - ethers.TypedDataEncoder.hashDomain({ - chainId, - verifyingContract: await signerLaunchpad.getAddress(), - }), - ) - }) - }) - - describe('getOperationHash', function () { - it('Should return the correct operation hash', async () => { - const { deployDefaultProxy, entryPoint, signerLaunchpad } = await setupTests() - - const { chainId } = await ethers.provider.getNetwork() - const proxy = await deployDefaultProxy() - - const userOpHash = ethers.randomBytes(32) - const validAfter = 0x010203040506 - const validUntil = 0x060504030201 - - expect(await proxy.getOperationHash(userOpHash, validAfter, validUntil)).to.equal( - ethers.TypedDataEncoder.hash( - { - chainId, - verifyingContract: await signerLaunchpad.getAddress(), - }, - { - SafeInitOp: [ - { name: 'userOpHash', type: 'bytes32' }, - { name: 'validAfter', type: 'uint48' }, - { name: 'validUntil', type: 'uint48' }, - { name: 'entryPoint', type: 'address' }, - ], - }, - { - userOpHash, - validAfter, - validUntil, - entryPoint: await entryPoint.getAddress(), - }, - ), - ) - }) - }) - - describe('validateUserOp', function () { - it('Should return valid result when signature is verified', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator, mockSignerFactory, mockSigner, defaultParams } = - await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy) - const validAfter = 0x010203040506 - const validUntil = 0x060504030201 - - const userOpHash = await entryPoint.getUserOpHash(userOp) - const safeInitHash = await proxy.getOperationHash(userOpHash, validAfter, validUntil) - const signature = ethers.randomBytes(42) - - await mockSignerFactory.givenCalldataReturnAddress( - defaultParams.signerFactory.interface.encodeFunctionData('getSigner', [ - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ]), - mockSigner, - ) - await mockSignerFactory.givenCalldataReturnBytes32( - defaultParams.signerFactory.interface.encodeFunctionData('isValidSignatureForSigner', [ - safeInitHash, - signature, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ]), - ethers.AbiCoder.defaultAbiCoder().encode(['bytes4'], [ERC1271.MAGIC_VALUE]), - ) - - expect( - await proxy.connect(entryPointImpersonator).validateUserOp.staticCall( - { - ...userOp, - signature: ethers.solidityPacked(['uint48', 'uint48', 'bytes'], [validAfter, validUntil, signature]), - }, - userOpHash, - 0, - ), - ).to.equal(ethers.solidityPacked(['uint48', 'uint48', 'address'], [validAfter, validUntil, ethers.ZeroAddress])) - }) - - it('Should transfer pre-fund if specified', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator, mockSignerFactory, mockSigner, defaultParams } = - await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy, { signature: `0x${'00'.repeat(12)}` }) - const userOpHash = await entryPoint.getUserOpHash(userOp) - const preFund = ethers.parseEther('1') - - await mockSignerFactory.givenMethodReturnAddress(defaultParams.signerFactory.interface.getFunction('getSigner').selector, mockSigner) - await mockSignerFactory.givenMethodReturnBytes32( - defaultParams.signerFactory.interface.getFunction('isValidSignatureForSigner').selector, - ethers.AbiCoder.defaultAbiCoder().encode(['bytes4'], [ERC1271.MAGIC_VALUE]), - ) - - const balanceBefore = await ethers.provider.getBalance(entryPoint) - - await setBalance(await proxy.getAddress(), preFund) - expect(await proxy.connect(entryPointImpersonator).validateUserOp.staticCall(userOp, userOpHash, preFund)).to.equal(0) - const transactionReceipt = await proxy - .connect(entryPointImpersonator) - .validateUserOp(userOp, userOpHash, preFund) - .then((tx) => tx.wait()) - - expect(await ethers.provider.getBalance(proxy)).to.equal(0) - expect(await ethers.provider.getBalance(entryPoint)).to.equal( - balanceBefore + preFund - transactionReceipt!.gasUsed * transactionReceipt!.gasPrice, - ) - }) - - it('Should transfer pre-fund even on invalid signature', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator, mockSignerFactory, mockSigner, defaultParams } = - await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy, { signature: `0x${'00'.repeat(12)}` }) - const userOpHash = await entryPoint.getUserOpHash(userOp) - const preFund = ethers.parseEther('1') - - await mockSignerFactory.givenMethodReturnAddress(defaultParams.signerFactory.interface.getFunction('getSigner').selector, mockSigner) - await mockSignerFactory.givenMethodReturnBytes32( - defaultParams.signerFactory.interface.getFunction('isValidSignatureForSigner').selector, - ethers.ZeroHash, - ) - - const balanceBefore = await ethers.provider.getBalance(entryPoint) - - await setBalance(await proxy.getAddress(), preFund) - expect(await proxy.connect(entryPointImpersonator).validateUserOp.staticCall(userOp, userOpHash, preFund)).to.equal(1) - const transactionReceipt = await proxy - .connect(entryPointImpersonator) - .validateUserOp(userOp, userOpHash, preFund) - .then((tx) => tx.wait()) - - expect(await ethers.provider.getBalance(proxy)).to.equal(0) - expect(await ethers.provider.getBalance(entryPoint)).to.equal( - balanceBefore + preFund - transactionReceipt!.gasUsed * transactionReceipt!.gasPrice, - ) - }) - - it('Should return invalid result if signer is not an owner', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator, mockSignerFactory, defaultParams } = await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy, { - callData: proxy.interface.encodeFunctionData('promoteAccountAndExecuteUserOp', [ - await defaultParams.signerFactory.getAddress(), - BigInt(defaultParams.signerX) + 1n, - defaultParams.signerY, - defaultParams.signerVerifiers, - ethers.ZeroAddress, - 0, - '0x', - 0, - ]), - signature: `0x${'00'.repeat(12)}`, - }) - const userOpHash = await entryPoint.getUserOpHash(userOp) - - const otherSigner = ethers.getAddress(ethers.hexlify(ethers.randomBytes(20))) - await mockSignerFactory.givenMethodReturnAddress(defaultParams.signerFactory.interface.getFunction('getSigner').selector, otherSigner) - await mockSignerFactory.givenMethodReturnBytes32( - defaultParams.signerFactory.interface.getFunction('isValidSignatureForSigner').selector, - ethers.AbiCoder.defaultAbiCoder().encode(['bytes4'], [ERC1271.MAGIC_VALUE]), - ) - - expect(await proxy.connect(entryPointImpersonator).validateUserOp.staticCall(userOp, userOpHash, 0)).to.equal(1) - }) - - it('Should return invalid result if signature verification fails', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator, mockSignerFactory, mockSigner, defaultParams } = - await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy, { signature: `0x${'00'.repeat(12)}` }) - const userOpHash = await entryPoint.getUserOpHash(userOp) - - await mockSignerFactory.givenMethodReturnAddress(defaultParams.signerFactory.interface.getFunction('getSigner').selector, mockSigner) - await mockSignerFactory.givenMethodReturnBytes32( - defaultParams.signerFactory.interface.getFunction('isValidSignatureForSigner').selector, - ethers.ZeroHash, - ) - - expect(await proxy.connect(entryPointImpersonator).validateUserOp.staticCall(userOp, userOpHash, 0)).to.equal(1) - }) - - it('Should return invalid result if signature verification reverts', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator, mockSignerFactory, mockSigner, defaultParams } = - await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy, { signature: `0x${'00'.repeat(12)}` }) - const userOpHash = await entryPoint.getUserOpHash(userOp) - - await mockSignerFactory.givenMethodReturnAddress(defaultParams.signerFactory.interface.getFunction('getSigner').selector, mockSigner) - await mockSignerFactory.givenMethodRevertWithMessage( - defaultParams.signerFactory.interface.getFunction('isValidSignatureForSigner').selector, - 'error', - ) - - expect(await proxy.connect(entryPointImpersonator).validateUserOp.staticCall(userOp, userOpHash, 0)).to.equal(1) - }) - - it('Should revert it not called by the entry point', async () => { - const { deployDefaultProxy, getUserOp, entryPoint } = await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy) - const userOpHash = await entryPoint.getUserOpHash(userOp) - - await expect(proxy.validateUserOp(userOp, userOpHash, 0)).to.be.revertedWithCustomError(proxy, 'UnsupportedEntryPoint') - }) - - it('Should revert it calldata is not for `promoteAccountAndExecuteUserOp`', async () => { - const { deployDefaultProxy, getUserOp, entryPoint, entryPointImpersonator } = await setupTests() - - const proxy = await deployDefaultProxy() - - const userOp = await getUserOp(proxy, { callData: '0x01020304' }) - const userOpHash = await entryPoint.getUserOpHash(userOp) - - await expect(proxy.connect(entryPointImpersonator).validateUserOp.staticCall(userOp, userOpHash, 0)) - .to.be.revertedWithCustomError(proxy, 'UnsupportedExecutionFunction') - .withArgs('0x01020304') - }) - }) - - describe('promoteAccountAndExecuteUserOp', function () { - for (const [name, operation] of [ - ['CALL', 0], - ['DELEGATECALL', 1], - ]) { - it(`Should execute the ${name} user operation`, async () => { - const { deployDefaultProxy, multiSend, mockSignerFactory, mockSigner, defaultParams, safeSingleton, entryPointImpersonator } = - await setupTests() - - const proxy = await deployDefaultProxy() - - const MockContract = await ethers.getContractFactory('MockContract') - const mockTarget = await MockContract.deploy() - const mockData = '0x010203040506' - await mockTarget.givenCalldataReturnBool(mockData, true) - - const [to, data] = - operation === 0 - ? [await mockTarget.getAddress(), mockData] - : [ - await multiSend.getAddress(), - multiSend.interface.encodeFunctionData('multiSend', [ - ethers.solidityPacked( - ['uint8', 'address', 'uint256', 'uint256', 'bytes'], - [0, await mockTarget.getAddress(), 0, ethers.dataLength(mockData), mockData], - ), - ]), - ] - - const createSignerData = defaultParams.signerFactory.interface.encodeFunctionData('createSigner', [ - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ]) - await mockSignerFactory.givenCalldataReturnAddress(createSignerData, mockSigner) - - await expect( - proxy - .connect(entryPointImpersonator) - .promoteAccountAndExecuteUserOp( - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - to, - 0, - data, - operation, - ), - ).to.not.be.reverted - expect(await mockSignerFactory.invocationCountForCalldata(createSignerData)).to.equal(1) - expect(await ethers.provider.getStorage(proxy, 0)).to.equal( - ethers.AbiCoder.defaultAbiCoder().encode(['address'], [await safeSingleton.getAddress()]), - ) - expect(await mockTarget.invocationCountForMethod(mockData)).to.equal(1) - }) - - it(`Should revert if the ${name} user operation reverts`, async () => { - const { deployDefaultProxy, multiSend, mockSignerFactory, mockSigner, defaultParams, entryPointImpersonator } = await setupTests() - - const proxy = await deployDefaultProxy() - - const MockContract = await ethers.getContractFactory('MockContract') - const mockTarget = await MockContract.deploy() - await mockTarget.givenAnyRevertWithMessage('error') - - const [to, data] = - operation === 0 - ? [await mockTarget.getAddress(), '0x'] - : [ - await multiSend.getAddress(), - multiSend.interface.encodeFunctionData('multiSend', [ - ethers.solidityPacked( - ['uint8', 'address', 'uint256', 'uint256', 'bytes'], - [0, await mockTarget.getAddress(), 0, 0, '0x'], - ), - ]), - ] - - await mockSignerFactory.givenMethodReturnAddress( - defaultParams.signerFactory.interface.getFunction('createSigner').selector, - mockSigner, - ) - - await expect( - proxy - .connect(entryPointImpersonator) - .promoteAccountAndExecuteUserOp( - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - to, - 0, - data, - operation, - ), - ).to.be.revertedWithCustomError(proxy, 'ExecutionFailed') - }) - } - - it(`Should transfer Ether as part of the user operation`, async () => { - const { deployDefaultProxy, mockSignerFactory, mockSigner, defaultParams, entryPointImpersonator } = await setupTests() - - const proxy = await deployDefaultProxy() - const target = ethers.getAddress(ethers.hexlify(ethers.randomBytes(20))) - const value = ethers.parseEther('1') - - await mockSignerFactory.givenMethodReturnAddress( - defaultParams.signerFactory.interface.getFunction('createSigner').selector, - mockSigner, - ) - - await setBalance(await proxy.getAddress(), value) - await proxy - .connect(entryPointImpersonator) - .promoteAccountAndExecuteUserOp( - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - target, - value, - '0x', - 0, - ) - expect(await ethers.provider.getBalance(target)).to.equal(value) - }) - - it(`Should revert if not called by entry point`, async () => { - const { deployDefaultProxy, defaultParams } = await setupTests() - - const proxy = await deployDefaultProxy() - - await expect( - proxy.promoteAccountAndExecuteUserOp( - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ethers.ZeroAddress, - 0, - '0x', - 0, - ), - ).to.be.revertedWithCustomError(proxy, 'UnsupportedEntryPoint') - }) - - it(`Should revert if not initialized`, async () => { - const { deployProxyWithoutSetup, defaultParams, entryPointImpersonator } = await setupTests() - - const proxy = await deployProxyWithoutSetup() - - await expect( - proxy - .connect(entryPointImpersonator) - .promoteAccountAndExecuteUserOp( - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ethers.ZeroAddress, - 0, - '0x', - 0, - ), - ).to.be.revertedWithCustomError(proxy, 'NotInitialized') - }) - - it(`Should revert if not initialized`, async () => { - const { deployDefaultProxy, mockSignerFactory, mockSigner, defaultParams, entryPointImpersonator } = await setupTests() - - const proxy = await deployDefaultProxy() - - await mockSignerFactory.givenMethodReturnAddress( - defaultParams.signerFactory.interface.getFunction('createSigner').selector, - mockSigner, - ) - - await expect( - proxy - .connect(entryPointImpersonator) - .promoteAccountAndExecuteUserOp( - defaultParams.signerFactory, - defaultParams.signerX, - defaultParams.signerY, - defaultParams.signerVerifiers, - ethers.ZeroAddress, - 0, - '0x', - 42, - ), - ).to.be.revertedWithPanic(0x21) - }) - }) -}) diff --git a/modules/passkey/test/4337/experimental/SafeWebAuthnSharedSigner.spec.ts b/modules/passkey/test/4337/experimental/SafeWebAuthnSharedSigner.spec.ts index ac1300945..4cedfa4e8 100644 --- a/modules/passkey/test/4337/experimental/SafeWebAuthnSharedSigner.spec.ts +++ b/modules/passkey/test/4337/experimental/SafeWebAuthnSharedSigner.spec.ts @@ -1,9 +1,22 @@ +import { buildSignatureBytes } from '@safe-global/safe-4337/src/utils/execution' +import { + buildPackedUserOperationFromSafeUserOperation, + buildSafeUserOpTransaction, + calculateSafeOperationHash, +} from '@safe-global/safe-4337/src/utils/userOp' +import { encodeMultiSendTransactions } from '@safe-global/safe-4337/test/utils/encoding' import { expect } from 'chai' import { deployments, ethers } from 'hardhat' import * as ERC1271 from '../../utils/erc1271' -import { DUMMY_AUTHENTICATOR_DATA, base64UrlEncode, getSignatureBytes } from '../../../src/utils/webauthn' -import { encodeWebAuthnSigningMessage } from '../../utils/webauthnShim' +import { + DUMMY_AUTHENTICATOR_DATA, + base64UrlEncode, + decodePublicKey, + encodeWebAuthnSignature, + getSignatureBytes, +} from '../../../src/utils/webauthn' +import { WebAuthnCredentials, encodeWebAuthnSigningMessage } from '../../utils/webauthnShim' const SIGNER_MAPPING_SLOT = BigInt(ethers.id('SafeWebAuthnSharedSigner.signer')) - 1n @@ -276,4 +289,241 @@ describe('SafeWebAuthnSharedSigner', () => { expect(await target['isValidSignature(bytes,bytes)'](data, signature)).to.equal('0x00000000') }) }) + + describe('Safe4337Module', () => { + const setupTests = deployments.createFixture(async ({ deployments }) => { + const { + SafeModuleSetup, + SafeL2, + SafeProxyFactory, + MultiSend, + FCLP256Verifier, + Safe4337Module, + EntryPoint, + SafeWebAuthnSharedSigner, + } = await deployments.fixture() + + const [user] = await ethers.getSigners() + const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address) + const module = await ethers.getContractAt(Safe4337Module.abi, Safe4337Module.address) + const proxyFactory = await ethers.getContractAt(SafeProxyFactory.abi, SafeProxyFactory.address) + const multiSend = await ethers.getContractAt('MultiSend', MultiSend.address) + const safeModuleSetup = await ethers.getContractAt(SafeModuleSetup.abi, SafeModuleSetup.address) + const singleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address) + const sharedSigner = await ethers.getContractAt('SafeWebAuthnSharedSigner', SafeWebAuthnSharedSigner.address) + const verifiers = BigInt(FCLP256Verifier.address) + + const navigator = { + credentials: new WebAuthnCredentials(), + } + + return { + user, + proxyFactory, + multiSend, + safeModuleSetup, + module, + entryPoint, + singleton, + sharedSigner, + verifiers, + navigator, + } + }) + + describe('executeUserOp - new account', () => { + it('should execute user operation', async () => { + const { user, proxyFactory, multiSend, safeModuleSetup, module, entryPoint, singleton, sharedSigner, navigator, verifiers } = + await setupTests() + + const credential = navigator.credentials.create({ + publicKey: { + rp: { + name: 'Safe', + id: 'safe.global', + }, + user: { + id: ethers.getBytes(ethers.id('chucknorris')), + name: 'chucknorris', + displayName: 'Chuck Norris', + }, + challenge: ethers.toBeArray(Date.now()), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }) + const publicKey = decodePublicKey(credential.response) + + const initializer = singleton.interface.encodeFunctionData('setup', [ + [sharedSigner.target], + 1, + multiSend.target, + multiSend.interface.encodeFunctionData('multiSend', [ + encodeMultiSendTransactions([ + { + op: 1 as const, + to: safeModuleSetup.target, + data: safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]]), + }, + { + op: 1 as const, + to: sharedSigner.target, + data: sharedSigner.interface.encodeFunctionData('configure', [{ ...publicKey, verifiers }]), + }, + ]), + ]), + module.target, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]) + const safeSalt = Date.now() + const safe = await proxyFactory.createProxyWithNonce.staticCall(singleton.target, initializer, safeSalt) + + const safeOp = buildSafeUserOpTransaction( + safe, + user.address, + ethers.parseEther('0.5'), + '0x', + await entryPoint.getNonce(safe, 0), + await entryPoint.getAddress(), + false, + false, + { + initCode: ethers.solidityPacked( + ['address', 'bytes'], + [ + proxyFactory.target, + proxyFactory.interface.encodeFunctionData('createProxyWithNonce', [singleton.target, initializer, safeSalt]), + ], + ), + verificationGasLimit: 700000, + }, + ) + const safeOpHash = await module.getOperationHash( + buildPackedUserOperationFromSafeUserOperation({ + safeOp, + signature: '0x', + }), + ) + + const assertion = navigator.credentials.get({ + publicKey: { + challenge: ethers.getBytes(safeOpHash), + rpId: 'safe.global', + allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], + userVerification: 'required', + }, + }) + const signature = buildSignatureBytes([ + { + signer: sharedSigner.target as string, + data: encodeWebAuthnSignature(assertion.response), + dynamic: true, + }, + ]) + + await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) + expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) + expect(await ethers.provider.getCode(safe)).to.equal('0x') + expect(await sharedSigner.getConfiguration(safe)).to.deep.equal([0n, 0n, 0n]) + + await entryPoint.handleOps([buildPackedUserOperationFromSafeUserOperation({ safeOp, signature })], user.address) + + expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) + expect(await ethers.provider.getCode(safe)).to.not.equal('0x') + expect(await sharedSigner.getConfiguration(safe)).to.deep.equal([publicKey.x, publicKey.y, verifiers]) + + const [implementation] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], await ethers.provider.getStorage(safe, 0)) + expect(implementation).to.equal(singleton.target) + + const safeInstance = singleton.attach(safe) as typeof singleton + expect(await safeInstance.getOwners()).to.deep.equal([sharedSigner.target]) + }) + }) + + describe('executeUserOp - existing account', () => { + it('should execute user operation', async () => { + const { user, proxyFactory, multiSend, safeModuleSetup, module, entryPoint, singleton, sharedSigner, navigator, verifiers } = + await setupTests() + + const credential = navigator.credentials.create({ + publicKey: { + rp: { + name: 'Safe', + id: 'safe.global', + }, + user: { + id: ethers.getBytes(ethers.id('chucknorris')), + name: 'chucknorris', + displayName: 'Chuck Norris', + }, + challenge: ethers.toBeArray(Date.now()), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }) + const publicKey = decodePublicKey(credential.response) + + const initializer = singleton.interface.encodeFunctionData('setup', [ + [sharedSigner.target], + 1, + multiSend.target, + multiSend.interface.encodeFunctionData('multiSend', [ + encodeMultiSendTransactions([ + { + op: 1 as const, + to: safeModuleSetup.target, + data: safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]]), + }, + { + op: 1 as const, + to: sharedSigner.target, + data: sharedSigner.interface.encodeFunctionData('configure', [{ ...publicKey, verifiers }]), + }, + ]), + ]), + module.target, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]) + const safeSalt = Date.now() + const safe = await proxyFactory.createProxyWithNonce.staticCall(singleton, initializer, safeSalt) + await proxyFactory.createProxyWithNonce(singleton, initializer, safeSalt) + + const safeOp = buildSafeUserOpTransaction( + safe, + user.address, + ethers.parseEther('0.5'), + '0x', + await entryPoint.getNonce(safe, 0), + await entryPoint.getAddress(), + ) + const { chainId } = await ethers.provider.getNetwork() + const safeOpHash = calculateSafeOperationHash(await module.getAddress(), safeOp, chainId) + const assertion = navigator.credentials.get({ + publicKey: { + challenge: ethers.getBytes(safeOpHash), + rpId: 'safe.global', + allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], + userVerification: 'required', + }, + }) + const signature = buildSignatureBytes([ + { + signer: sharedSigner.target as string, + data: encodeWebAuthnSignature(assertion.response), + dynamic: true, + }, + ]) + + await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) + expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) + + const userOp = buildPackedUserOperationFromSafeUserOperation({ safeOp, signature }) + await entryPoint.handleOps([userOp], user.address) + + expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) + }) + }) + }) }) diff --git a/modules/passkey/test/4337/local-bundler/experimental/SafeSignerLaunchpad.spec.ts b/modules/passkey/test/4337/local-bundler/experimental/SafeSignerLaunchpad.spec.ts deleted file mode 100644 index 274a17eda..000000000 --- a/modules/passkey/test/4337/local-bundler/experimental/SafeSignerLaunchpad.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { expect } from 'chai' -import { deployments, ethers, network } from 'hardhat' -import { packGasParameters, unpackUserOperation } from '@safe-global/safe-4337/dist/src/utils/userOp' -import { bundlerRpc, prepareAccounts, waitForUserOp } from '@safe-global/safe-4337-local-bundler' -import { WebAuthnCredentials } from '../../../utils/webauthnShim' -import { decodePublicKey, encodeWebAuthnSignature } from '../../../../src/utils/webauthn' - -describe('Safe WebAuthn Signer Launchpad [@4337]', () => { - before(function () { - if (network.name !== 'localhost') { - this.skip() - } - }) - - const setupTests = deployments.createFixture(async ({ deployments }) => { - const { - EntryPoint, - Safe4337Module, - SafeSignerLaunchpad, - SafeProxyFactory, - SafeModuleSetup, - SafeL2, - FCLP256Verifier, - SafeWebAuthnSignerFactory, - } = await deployments.run() - const [user] = await prepareAccounts() - const bundler = bundlerRpc() - - const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address) - const module = await ethers.getContractAt(Safe4337Module.abi, Safe4337Module.address) - const proxyFactory = await ethers.getContractAt(SafeProxyFactory.abi, SafeProxyFactory.address) - const safeModuleSetup = await ethers.getContractAt(SafeModuleSetup.abi, SafeModuleSetup.address) - const signerLaunchpad = await ethers.getContractAt('SafeSignerLaunchpad', SafeSignerLaunchpad.address) - const singleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address) - const verifier = await ethers.getContractAt('IP256Verifier', FCLP256Verifier.address) - const signerFactory = await ethers.getContractAt('SafeWebAuthnSignerFactory', SafeWebAuthnSignerFactory.address) - - const navigator = { - credentials: new WebAuthnCredentials(), - } - - return { - user, - bundler, - proxyFactory, - safeModuleSetup, - module, - entryPoint, - signerLaunchpad, - singleton, - signerFactory, - navigator, - verifier, - SafeL2, - } - }) - - it('should execute a user op and deploy a WebAuthn signer', async () => { - const { - user, - bundler, - proxyFactory, - safeModuleSetup, - module, - entryPoint, - signerLaunchpad, - singleton, - signerFactory, - navigator, - verifier, - SafeL2, - } = await setupTests() - - const { chainId } = await ethers.provider.getNetwork() - const verifiers = ethers.solidityPacked(['uint16', 'address'], [0, await verifier.getAddress()]) - - const credential = navigator.credentials.create({ - publicKey: { - rp: { - name: 'Safe', - id: 'safe.global', - }, - user: { - id: ethers.getBytes(ethers.id('chucknorris')), - name: 'chucknorris', - displayName: 'Chuck Norris', - }, - challenge: ethers.toBeArray(Date.now()), - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - }, - }) - const publicKey = decodePublicKey(credential.response) - const signerAddress = await signerFactory.getSigner(publicKey.x, publicKey.y, verifiers) - - const launchpadInitializer = signerLaunchpad.interface.encodeFunctionData('setup', [ - singleton.target, - signerFactory.target, - publicKey.x, - publicKey.y, - verifiers, - safeModuleSetup.target, - safeModuleSetup.interface.encodeFunctionData('enableModules', [[module.target]]), - module.target, - ]) - const safeSalt = Date.now() - const safe = await proxyFactory.createProxyWithNonce.staticCall(signerLaunchpad.target, launchpadInitializer, safeSalt) - - const packedUserOp = { - sender: safe, - nonce: ethers.toBeHex(await entryPoint.getNonce(safe, 0)), - initCode: ethers.solidityPacked( - ['address', 'bytes'], - [ - proxyFactory.target, - proxyFactory.interface.encodeFunctionData('createProxyWithNonce', [signerLaunchpad.target, launchpadInitializer, safeSalt]), - ], - ), - callData: signerLaunchpad.interface.encodeFunctionData('promoteAccountAndExecuteUserOp', [ - signerFactory.target, - publicKey.x, - publicKey.y, - verifiers, - user.address, - ethers.parseEther('0.5'), - '0x', - 0, - ]), - ...packGasParameters({ - verificationGasLimit: 700000, - callGasLimit: 2000000, - maxFeePerGas: 10000000000, - maxPriorityFeePerGas: 10000000000, - }), - preVerificationGas: ethers.toBeHex(60000), - paymasterAndData: '0x', - signature: '0x', - } - - const safeInitOp = { - userOpHash: await entryPoint.getUserOpHash(packedUserOp), - validAfter: 0, - validUntil: 0, - entryPoint: entryPoint.target, - } - const safeInitOpHash = ethers.TypedDataEncoder.hash( - { verifyingContract: await signerLaunchpad.getAddress(), chainId }, - { - SafeInitOp: [ - { type: 'bytes32', name: 'userOpHash' }, - { type: 'uint48', name: 'validAfter' }, - { type: 'uint48', name: 'validUntil' }, - { type: 'address', name: 'entryPoint' }, - ], - }, - safeInitOp, - ) - expect(await signerLaunchpad.getOperationHash(safeInitOp.userOpHash, safeInitOp.validAfter, safeInitOp.validUntil)).to.equal( - safeInitOpHash, - ) - - const assertion = navigator.credentials.get({ - publicKey: { - challenge: ethers.getBytes(safeInitOpHash), - rpId: 'safe.global', - allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], - userVerification: 'required', - }, - }) - const signature = ethers.solidityPacked( - ['uint48', 'uint48', 'bytes'], - [safeInitOp.validAfter, safeInitOp.validUntil, encodeWebAuthnSignature(assertion.response)], - ) - - await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) - expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) - expect(await ethers.provider.getCode(safe)).to.equal('0x') - expect(await ethers.provider.getCode(signerAddress)).to.equal('0x') - - const userOp = await unpackUserOperation({ ...packedUserOp, signature }) - await bundler.sendUserOperation(userOp, await entryPoint.getAddress()) - await waitForUserOp(userOp) - - expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) - expect(await ethers.provider.getCode(safe)).to.not.equal('0x') - expect(await ethers.provider.getCode(signerAddress)).to.not.equal('0x') - - const [implementation] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], await ethers.provider.getStorage(safe, 0)) - expect(implementation).to.equal(singleton.target) - - const safeInstance = await ethers.getContractAt(SafeL2.abi, safe) - expect(await safeInstance.getOwners()).to.deep.equal([signerAddress]) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc7622d9c..a790e88c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: specifier: ^5.4.5 version: 5.4.5 - examples/4337-passkeys: + examples/4337-passkeys-singleton-signer: dependencies: '@account-abstraction/contracts': specifier: 0.7.0 @@ -93,6 +93,9 @@ importers: '@safe-global/safe-4337': specifier: 0.3.0 version: 0.3.0(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + '@safe-global/safe-contracts': + specifier: ^1.4.1-build.0 + version: 1.4.1-build.0(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)) '@safe-global/safe-passkey': specifier: workspace:0.2.0-alpha.2 version: link:../../modules/passkey @@ -134,55 +137,6 @@ importers: specifier: ^0.10.1 version: 0.10.1 - examples/4337-passkeys-singleton-signer: - dependencies: - '@account-abstraction/contracts': - specifier: 0.7.0 - version: 0.7.0 - '@safe-global/safe-4337': - specifier: 0.3.0 - version: 0.3.0(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - '@safe-global/safe-passkey': - specifier: 0.2.0-alpha.1 - version: 0.2.0-alpha.1(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - '@web3modal/ethers': - specifier: ^4.1.11 - version: 4.2.2(@types/react@18.3.3)(bufferutil@4.0.8)(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(utf-8-validate@5.0.10) - ethers: - specifier: ^6.12.1 - version: 6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-router-dom: - specifier: ^6.23.1 - version: 6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - devDependencies: - '@types/react': - specifier: ^18.3.2 - version: 18.3.3 - '@types/react-dom': - specifier: ^18.3.0 - version: 18.3.0 - '@vitejs/plugin-react-swc': - specifier: ^3.6.0 - version: 3.7.0(vite@5.2.11(@types/node@20.14.0)) - react-router: - specifier: ^6.23.1 - version: 6.23.1(react@18.3.1) - typescript: - specifier: ^5.4.5 - version: 5.4.5 - vite: - specifier: ^5.2.11 - version: 5.2.11(@types/node@20.14.0) - vite-plugin-commonjs: - specifier: ^0.10.1 - version: 0.10.1 - modules/4337: dependencies: '@safe-global/safe-contracts': @@ -1300,9 +1254,6 @@ packages: '@safe-global/safe-deployments@1.36.0': resolution: {integrity: sha512-9MbDJveRR64AbmzjIpuUqmDBDtOZpXpvkyhTUs+5UOPT3WgSO375/ZTO7hZpywP7+EmxnjkGc9EoxjGcC4TAyw==} - '@safe-global/safe-passkey@0.2.0-alpha.1': - resolution: {integrity: sha512-rmxZH79J0ynQf9WqyWHbHWbv1K7s6X1hFpp55f4euMO9taoU7Ymw5p+8iBgQNe4EWy4NjRbtfK9NKD7JaLoYjQ==} - '@safe-global/safe-singleton-factory@1.0.26': resolution: {integrity: sha512-79xL013OAenMyQZLIDLzuRoO4z/nH3AHLG93PdD1RQltAP98QEoeMjGeWwOkLq26pLMM9OeFz4MPuWtZ2qxWmw==} @@ -6103,13 +6054,6 @@ snapshots: dependencies: semver: 7.6.2 - '@safe-global/safe-passkey@0.2.0-alpha.1(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10))': - dependencies: - '@safe-global/safe-contracts': 1.4.1-build.0(ethers@6.12.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - cbor: 9.0.2 - transitivePeerDependencies: - - ethers - '@safe-global/safe-singleton-factory@1.0.26': {} '@scure/base@1.1.6': {}