diff --git a/src/app/recover-wallet/layout.tsx b/src/app/recover-wallet/layout.tsx new file mode 100644 index 000000000..d5b483085 --- /dev/null +++ b/src/app/recover-wallet/layout.tsx @@ -0,0 +1,18 @@ +import { generateMetadata } from '@/app/metadata' +import PageContainer from '@/components/0_Bruddle/PageContainer' +import React from 'react' + +// Standalone (outside the (mobile-ui) auth shell) on purpose: this page is for +// users who CANNOT log in, so it must not sit behind the login gate. noindex — +// it's reached only via a one-off ops-generated recovery link. +export const metadata = { + ...generateMetadata({ + title: 'Wallet Recovery', + description: 'Recover funds from a Peanut wallet using your passkey.', + }), + robots: { index: false, follow: false }, +} + +export default function RecoverWalletLayout({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/src/app/recover-wallet/page.tsx b/src/app/recover-wallet/page.tsx new file mode 100644 index 000000000..2ce8d0aa9 --- /dev/null +++ b/src/app/recover-wallet/page.tsx @@ -0,0 +1,272 @@ +'use client' + +/** + * Passkey wallet rescue — a login-bypassing recovery surface. + * + * Some early smart wallets (pre the signup test-transaction guard) ended up in a + * state where the user's device can still authenticate their passkey, but the + * ZeroDev passkey-SERVER login round-trip fails ("unexpected error"), so they + * can never reach the app to move their funds. The account is fine on-chain and + * is controlled by a passkey whose public key we persist server-side. + * + * This page rebuilds the kernel client straight from that stored pubkey + * (bypassing the broken login), proves it derives to the expected wallet + * address, and lets the user sign one withdrawal with their device. The private + * key never leaves their authenticator, so the worst a bad/forged link can do is + * fail to sign — it can never move anyone else's funds. + * + * Reached only via an ops-generated link: /recover-wallet?k=. + * Must run on the production origin (peanut.me) — the browser keys WebAuthn to + * the page domain, so the device only finds the credential there. + */ + +import { Button } from '@/components/0_Bruddle/Button' +import AddressLink from '@/components/Global/AddressLink' +import Card from '@/components/Global/Card' +import ErrorAlert from '@/components/Global/ErrorAlert' +import GeneralRecipientInput, { type GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' +import { Icon } from '@/components/Global/Icons/Icon' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { PUBLIC_CLIENTS_BY_CHAIN } from '@/app/actions/clients' +import { createKernelClientForChain, type KernelClientOptions } from '@/context/kernelClient.context' +import { + PEANUT_WALLET_CHAIN, + PEANUT_WALLET_TOKEN, + PEANUT_WALLET_TOKEN_DECIMALS, + PEANUT_WALLET_TOKEN_SYMBOL, +} from '@/constants/zerodev.consts' +import { type RecipientState } from '@/context/WithdrawFlowContext' +import { areEvmAddressesEqual, getExplorerUrl, isTxReverted } from '@/utils/general.utils' +import { decodeRecoveryKey, toRescueWebAuthnKey, type RecoveryKeyInput } from '@/utils/walletRescue.utils' +import { captureException } from '@sentry/nextjs' +import { useSearchParams } from 'next/navigation' +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import { type Address, encodeFunctionData, erc20Abi, formatUnits, isAddress } from 'viem' + +type Phase = 'loading' | 'invalid' | 'ready' | 'final' +type RescueClient = Awaited> + +// useSearchParams requires a Suspense boundary to keep the route from forcing +// the whole tree dynamic at build time. +export default function RecoverWalletPage() { + return ( + }> + + + ) +} + +function RecoverWalletInner() { + const searchParams = useSearchParams() + const blob = searchParams.get('k') + + const recoveryKey = useMemo(() => { + if (!blob) return null + try { + return decodeRecoveryKey(blob) + } catch { + return null + } + }, [blob]) + + const [phase, setPhase] = useState('loading') + const [fatal, setFatal] = useState('') + const [client, setClient] = useState(null) + const [balance, setBalance] = useState(0n) + const [recipient, setRecipient] = useState({ address: '', name: '' }) + const [inputChanging, setInputChanging] = useState(false) + const [recipientError, setRecipientError] = useState('') + const [isSigning, setIsSigning] = useState(false) + const [signError, setSignError] = useState('') + const [txHash, setTxHash] = useState('') + + const chainId = PEANUT_WALLET_CHAIN.id.toString() + const formattedBalance = formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS) + + // Build the kernel client from the stored pubkey, prove it derives to the + // expected address, and read the on-chain USDC balance. + useEffect(() => { + if (!recoveryKey) { + setPhase('invalid') + setFatal('This recovery link is invalid or has expired. Please ask support for a fresh one.') + return + } + // name MUST be undefined, not '' — GeneralRecipientInput shows + // `recipient.name ?? recipient.address`, and ?? only falls through on + // null/undefined, so an empty-string name would hide the prefilled + // destination while still enabling the button (invisible target). + if (recoveryKey.to) setRecipient({ address: recoveryKey.to, name: undefined }) + + let cancelled = false + ;(async () => { + try { + const entry = PUBLIC_CLIENTS_BY_CHAIN[chainId] + if (!entry) throw new Error(`chain ${chainId} not configured`) + const options: KernelClientOptions = { + bundlerUrl: entry.bundlerUrl, + paymasterUrl: entry.paymasterUrl, + } + const builtClient = await createKernelClientForChain( + entry.client, + entry.chain, + // Affected wallets all post-date the ZeroDev migration, so they + // use the plain (non-migration) kernel. A pre-migration key + // would derive a different address and trip the guard below. + true, + toRescueWebAuthnKey(recoveryKey), + undefined, + options + ) + const derived = builtClient.account!.address + if (!areEvmAddressesEqual(derived, recoveryKey.address)) { + throw new Error(`derived ${derived} != expected ${recoveryKey.address}`) + } + const onChainBalance = await entry.client.readContract({ + address: PEANUT_WALLET_TOKEN as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [recoveryKey.address], + }) + if (cancelled) return + // Keep the proven client so signing reuses the asserted account + // rather than rebuilding (and re-deriving) it. + setClient(builtClient) + setBalance(onChainBalance) + setPhase('ready') + } catch (error) { + console.error('[recover-wallet] init failed', error) + captureException(error, { tags: { error_type: 'wallet_rescue_init' } }) + if (cancelled) return + setPhase('invalid') + setFatal('We could not load this wallet for recovery. Please contact support.') + } + })() + return () => { + cancelled = true + } + }, [recoveryKey, chainId]) + + const recover = useCallback(async () => { + // `client` is the address-asserted client built at init — reuse it so we + // never sign from an unverified re-derivation. + if (!client || !isAddress(recipient.address) || balance <= 0n) return + setIsSigning(true) + setSignError('') + try { + const data = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient.address as Address, balance], + }) + const userOpHash = await client.sendUserOperation({ + account: client.account, + callData: await client.account!.encodeCalls([{ to: PEANUT_WALLET_TOKEN as Address, value: 0n, data }]), + }) + const receipt = await client.waitForUserOperationReceipt({ hash: userOpHash }) + if (receipt.receipt && isTxReverted(receipt.receipt)) { + throw new Error('transaction reverted') + } + setTxHash(receipt.receipt?.transactionHash ?? userOpHash) + setPhase('final') + } catch (error) { + console.error('[recover-wallet] sign failed', error) + captureException(error, { tags: { error_type: 'wallet_rescue_send' } }) + setSignError('We could not complete the recovery. Please retry, or contact support if it keeps failing.') + } finally { + setIsSigning(false) + } + }, [client, recipient.address, balance]) + + if (phase === 'loading') return + + if (phase === 'invalid') { + return ( +
+
+

Wallet recovery

+ +
+
+ ) + } + + if (phase === 'final') { + return ( +
+
+

Funds on the way 🎉

+ + + Sent to + + + {formattedBalance} {PEANUT_WALLET_TOKEN_SYMBOL} + + + View on explorer + + + +
+
+ ) + } + + // phase === 'ready' + const nothingToRecover = balance <= 0n + return ( +
+
+
+

+ {recoveryKey?.label ? `${recoveryKey.label}, let's` : "Let's"} recover your funds +

+

+ Sign with the passkey on this device to move your balance to any address. +

+
+ + + + Wallet + + + {formattedBalance} {PEANUT_WALLET_TOKEN_SYMBOL} + + + + {nothingToRecover ? ( + + ) : ( + <> + { + setRecipient(update.recipient) + setRecipientError(update.errorMessage) + setInputChanging(update.isChanging) + }} + /> + + {!!signError && } + + )} +
+
+ ) +} diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 6ae52ee85..f4b834285 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -44,6 +44,7 @@ export const DEDICATED_ROUTES = [ 'notifications', 'recover-funds', 'card-recovery', + 'recover-wallet', // Public pages (existing) 'm', // merchant landing pages (/m/[slug]) — added on main; register so the catch-all never treats it as a recipient diff --git a/src/utils/__tests__/walletRescue.utils.test.ts b/src/utils/__tests__/walletRescue.utils.test.ts new file mode 100644 index 000000000..96e5e027e --- /dev/null +++ b/src/utils/__tests__/walletRescue.utils.test.ts @@ -0,0 +1,60 @@ +import { decodeRecoveryKey, encodeRecoveryKey, toRescueWebAuthnKey, type RecoveryKeyInput } from '../walletRescue.utils' + +// Synthetic fixture — not a real user. authenticatorIdHash + blob were computed +// independently with `keccak256(b64ToBytes(credId))` and base64url(JSON). +const CRED_ID = 'dGVzdC1jcmVkZW50aWFsLWlk' +const FIXTURE: RecoveryKeyInput = { + pubX: '0x2b100abd8d5d282665c2169975b8a858dacf5129fb6e696c80b1e56d7f4175db', + pubY: '0x3ce7cc7b15895297c56dc8be3b10b9070b20d2d33e985f452d5563fa57d069c2', + credId: CRED_ID, + address: '0x7389Ee339bb870c586FDe8e980eDf0B75F5ffb7C', +} +const EXPECTED_AUTH_ID_HASH = '0x35284358f5f974f25625bab32d0848696dec20d51a3197ed478f891a20fbc569' +const BLOB = + 'eyJwdWJYIjoiMHgyYjEwMGFiZDhkNWQyODI2NjVjMjE2OTk3NWI4YTg1OGRhY2Y1MTI5ZmI2ZTY5NmM4MGIxZTU2ZDdmNDE3NWRiIiwicHViWSI6IjB4M2NlN2NjN2IxNTg5NTI5N2M1NmRjOGJlM2IxMGI5MDcwYjIwZDJkMzNlOTg1ZjQ1MmQ1NTYzZmE1N2QwNjljMiIsImNyZWRJZCI6ImRHVnpkQzFqY21Wa1pXNTBhV0ZzTFdsayIsImFkZHJlc3MiOiIweDczODlFZTMzOWJiODcwYzU4NkZEZThlOTgwZURmMEI3NUY1ZmZiN0MifQ' + +describe('toRescueWebAuthnKey', () => { + it('rebuilds the WebAuthnKey from persisted passkey material', () => { + const key = toRescueWebAuthnKey(FIXTURE) + expect(key.pubX).toBe(BigInt(FIXTURE.pubX)) + expect(key.pubY).toBe(BigInt(FIXTURE.pubY)) + expect(key.authenticatorId).toBe(CRED_ID) + expect(key.authenticatorIdHash).toBe(EXPECTED_AUTH_ID_HASH) + expect(key.rpID).toBe('') + }) + + it('accepts coords without a 0x prefix', () => { + const key = toRescueWebAuthnKey({ ...FIXTURE, pubX: FIXTURE.pubX.slice(2), pubY: FIXTURE.pubY.slice(2) }) + expect(key.pubX).toBe(BigInt(FIXTURE.pubX)) + expect(key.pubY).toBe(BigInt(FIXTURE.pubY)) + }) +}) + +describe('decodeRecoveryKey', () => { + it('decodes a valid base64url blob', () => { + expect(decodeRecoveryKey(BLOB)).toEqual(FIXTURE) + }) + + it('round-trips through encodeRecoveryKey', () => { + const withExtras: RecoveryKeyInput = { + ...FIXTURE, + to: '0xA63C78bAd9aF4bECb75D5AEA1Ba02DD1ab55839b', + label: 'Test', + } + expect(decodeRecoveryKey(encodeRecoveryKey(withExtras))).toEqual(withExtras) + }) + + it('rejects a malformed blob', () => { + expect(() => decodeRecoveryKey('not-valid-json')).toThrow('malformed') + }) + + it('rejects an invalid wallet address', () => { + expect(() => decodeRecoveryKey(encodeRecoveryKey({ ...FIXTURE, address: '0x123' as `0x${string}` }))).toThrow( + 'invalid wallet address' + ) + }) + + it('rejects an invalid public key', () => { + expect(() => decodeRecoveryKey(encodeRecoveryKey({ ...FIXTURE, pubX: 'zzzz' }))).toThrow('invalid public key') + }) +}) diff --git a/src/utils/walletRescue.utils.ts b/src/utils/walletRescue.utils.ts new file mode 100644 index 000000000..7a4b791ec --- /dev/null +++ b/src/utils/walletRescue.utils.ts @@ -0,0 +1,96 @@ +import { keccak256, type Hex } from 'viem' +import { b64ToBytes } from '@zerodev/webauthn-key' + +/** + * Public passkey material we persist server-side for every smart wallet + * (the P-256 pubkey coordinates + the WebAuthn credential id). None of this is + * secret — the private key never leaves the user's authenticator — so it is + * safe to hand to the browser in a one-off recovery link. + */ +export interface RecoveryKeyInput { + /** P-256 public key X coordinate, hex (with or without 0x). */ + pubX: string + /** P-256 public key Y coordinate, hex (with or without 0x). */ + pubY: string + /** WebAuthn credential id (base64url) — the authenticator id. */ + credId: string + /** Smart-wallet address this key is expected to derive to (asserted on build). */ + address: Hex + /** Optional pre-filled destination to sweep funds to. */ + to?: Hex + /** Optional user-facing label (e.g. a first name) for the rescue screen. */ + label?: string +} + +/** + * The exact shape `@zerodev/webauthn-key`'s `toWebAuthnKey()` returns, minus the + * passkey-server round-trip. ZeroDev's passkey validator signs on web via the + * browser WebAuthn API keyed only by `authenticatorId`, so this object is enough + * to build a fully-functional signing kernel client. + */ +export interface RescueWebAuthnKey { + pubX: bigint + pubY: bigint + authenticatorId: string + authenticatorIdHash: Hex + rpID: string +} + +const ensureHexPrefix = (value: string): Hex => (value.startsWith('0x') ? (value as Hex) : (`0x${value}` as Hex)) + +/** + * Reconstruct the ZeroDev WebAuthnKey from persisted passkey material. This is + * the bypass for the broken passkey-server *login*: a user whose device can + * still authenticate but whose `toWebAuthnKey({mode: Login})` round-trip fails + * can still build a signing kernel client from the pubkey we already hold. + * + * `authenticatorIdHash` mirrors the upstream lib exactly: + * `keccak256(bytes(base64url(credId)))`. + */ +export function toRescueWebAuthnKey(input: RecoveryKeyInput): RescueWebAuthnKey { + return { + pubX: BigInt(ensureHexPrefix(input.pubX)), + pubY: BigInt(ensureHexPrefix(input.pubY)), + authenticatorId: input.credId, + authenticatorIdHash: keccak256(b64ToBytes(input.credId)), + // Unused on web — the validator signs via the page-origin WebAuthn API. + rpID: '', + } +} + +const isHex = (value: unknown): value is string => typeof value === 'string' && /^(0x)?[0-9a-fA-F]+$/.test(value) +const isAddress = (value: unknown): value is Hex => typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value) + +/** + * Decode the base64url-encoded JSON blob carried in the `?k=` recovery-link + * param. Throws on anything malformed so the page can show a clean error rather + * than building a bogus kernel client. + */ +export function decodeRecoveryKey(blob: string): RecoveryKeyInput { + let parsed: unknown + try { + parsed = JSON.parse(new TextDecoder().decode(b64ToBytes(blob))) + } catch { + throw new Error('Recovery link is malformed') + } + if (typeof parsed !== 'object' || parsed === null) throw new Error('Recovery link is malformed') + const { pubX, pubY, credId, address, to, label } = parsed as Record + if (!isHex(pubX) || !isHex(pubY)) throw new Error('Recovery link has an invalid public key') + if (typeof credId !== 'string' || credId.length === 0) throw new Error('Recovery link is missing a credential id') + if (!isAddress(address)) throw new Error('Recovery link has an invalid wallet address') + if (to !== undefined && !isAddress(to)) throw new Error('Recovery link has an invalid destination address') + return { + pubX, + pubY, + credId, + address, + ...(to !== undefined ? { to } : {}), + ...(typeof label === 'string' ? { label } : {}), + } +} + +/** Encode a recovery blob for an ops-generated link (inverse of decodeRecoveryKey). */ +export function encodeRecoveryKey(input: RecoveryKeyInput): string { + const json = new TextEncoder().encode(JSON.stringify(input)) + return Buffer.from(json).toString('base64url') +}