diff --git a/frontend/wallet/app/dashboard/page.tsx b/frontend/wallet/app/dashboard/page.tsx index 80217066..179c05b9 100644 --- a/frontend/wallet/app/dashboard/page.tsx +++ b/frontend/wallet/app/dashboard/page.tsx @@ -16,7 +16,7 @@ import { WalletConnectApprovalModal } from '@/components/WalletConnectApprovalMo import { DepositModal } from '@/components/DepositModal' import { TxDetailSheet, type TxRecord } from '@/components/TxDetailSheet' import { useInactivityLock } from '@/hooks/useInactivityLock' -import { ensureFeePayer } from '@/lib/feePayer' +import { ensureFeePayer, isFeePayerPrfDowngrade, getFeePayerDiagnostics } from '@/lib/feePayer' import { fetchPrices } from '@/lib/fetchPrice' import { change24h, historyKey, isComparableTotal, readHistory, recordSnapshot, writeHistory } from '@/lib/balanceHistory' import { buildFriendbotUrl, getNativeAssetContractId, getNetwork, getNetworkName } from '@/lib/network' @@ -135,6 +135,9 @@ function DashboardPageContent() { const [wraithOutCursor, setWraithOutCursor] = useState(null) const [hasMorePages, setHasMorePages] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) + // PRF downgrade: surfaced as a dismissible banner (issue #629). + const [prfDowngradeDismissed, setPrfDowngradeDismissed] = useState(false) + const [showPrfDowngrade, setShowPrfDowngrade] = useState(false) // Shoulder-surfing guard. Persisted, but read after mount so the server and // client render the same first paint. @@ -216,7 +219,11 @@ function DashboardPageContent() { // Establish the fee-payer for this session (idempotent, fire-and-forget). // PRF wallets keep the seed in sessionStorage only — never copied to // localStorage — so the lock protects it at rest (ADR 0003, C3). - void ensureFeePayer() + void ensureFeePayer().then(() => { + // After the fee-payer is established, check whether a silent PRF→legacy + // downgrade occurred (issue #629). Show a banner if so. + setShowPrfDowngrade(isFeePayerPrfDowngrade(getFeePayerDiagnostics())) + }) }, [router]) const fetchData = useCallback(async () => { @@ -755,6 +762,42 @@ function DashboardPageContent() { )} + {/* ── PRF downgrade warning banner (issue #629) ── */} + {!loading && showPrfDowngrade && !prfDowngradeDismissed && ( +
+
+

+ Fee payer: PRF unavailable on this device +

+ +
+

+ This wallet requested a WebAuthn PRF result but the authenticator didn't provide one. It fell back to the legacy fee payer, which will look like a different wallet on a PRF-capable device. If this is unexpected, copy the diagnostics in Settings → Fee Payer and share them. +

+ +
+ )} + {/* ── Sweep prompt: contract SAC balance detected ── */} {!loading && contractXlm > 0 && !sweepDismissed && (
{showTutorial && } -
-
- - {/* Logo + wordmark */} -
-
- -
-
-

- VEIL -

-

- Your passkey is your wallet -

-
-
+
+
+ + {/* ── Hero ── */} +
+ + {/* Biometric pulse ring */} +
+ +
- {/* Main card */} - - - - {step === 'landing' && ( -
- - - {error && ( -

- {error} + {/* Headline + lede */} +

+

+ Your passkey is{' '} + + your wallet. + +

+

+ No seed phrase. No password. Just your biometric — securing a smart contract wallet on Stellar.

- )} +
- )} - {(step === 'registering' || step === 'deploying') && ( -
-
-
+ {/* ── CTAs ── */} + {step === 'landing' && ( +
+ + + {error && ( +

+ {error} +

+ )}
-

- {step === 'registering' ? 'Waiting for biometric...' : 'Deploying wallet on-chain...'} -

-

- {step === 'registering' - ? 'Approve the passkey prompt on your device' - : `Broadcasting to ${network.displayName}`} -

-
- )} - - {step === 'done' && address && ( -
-
- - - - -

- Wallet created + )} + + {/* ── In-progress states ── */} + {(step === 'registering' || step === 'deploying') && ( +

+
+
+
+

+ {step === 'registering' ? 'Waiting for biometric...' : 'Deploying wallet on-chain...'} +

+

+ {step === 'registering' + ? 'Approve the passkey prompt on your device' + : `Broadcasting to ${network.displayName}`}

+ )} + + {/* ── Success ── */} + {step === 'done' && address && ( +
+
+ + + + +

+ Wallet created +

+
-
-

- YOUR WALLET ADDRESS -

-
- {address.slice(0, 8)}...{address.slice(-8)} +
+

+ YOUR WALLET ADDRESS +

+
+ {address.slice(0, 8)}...{address.slice(-8)} +
+ +
+ )} + + {/* ── WebAuthn footnote ── */} +

+ Secured by{' '} + WebAuthn + {' '}on{' '} + Stellar +

- -
- )} - - {/* Footer */} -

- No seed phrase. No private key. Powered by{' '} - Stellar Soroban -

- +
-
) diff --git a/frontend/wallet/app/settings/fee-payer/page.tsx b/frontend/wallet/app/settings/fee-payer/page.tsx new file mode 100644 index 00000000..937f7233 --- /dev/null +++ b/frontend/wallet/app/settings/fee-payer/page.tsx @@ -0,0 +1,203 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { ChevronLeft, Fuel, Copy, Check, AlertTriangle } from 'lucide-react' +import { useInactivityLock } from '@/hooks/useInactivityLock' +import { + ensureFeePayer, + peekFeePayerKeypair, + getFeePayerMode, + getFeePayerDiagnostics, + isFeePayerPrfDowngrade, + formatFeePayerDiagnostics, + type FeePayerMode, + type FeePayerDiagnostics, +} from '@/lib/feePayer' + +const MODE_LABEL: Record = { + 'prf-raw': 'PRF (raw)', + 'prf-hkdf': 'PRF (HKDF)', + legacy: 'Legacy (credential ID)', +} + +const MODE_DESCRIPTION: Record = { + 'prf-raw': 'Derived directly from a WebAuthn PRF output on this passkey — the same method the mobile app uses, so this address reproduces on a PRF-capable device.', + 'prf-hkdf': 'Derived by running a WebAuthn PRF output through HKDF — what the web wallet used before it matched mobile. Kept only because this wallet was already pinned this way.', + legacy: 'Derived from the (non-secret) passkey credential ID. Used for wallets created before PRF support, or as a fallback when the authenticator does not support PRF.', +} + +const STATUS_LABEL: Record = { + exists: 'found on-chain — chosen', + 'not-found': 'not found on-chain', + 'network-error': 'probe failed (network error)', + 'not-probed': 'not probed', +} + +export default function FeePayerSettingsPage() { + const router = useRouter() + useInactivityLock() + + const [address, setAddress] = useState(null) + const [mode, setMode] = useState(null) + const [diagnostics, setDiagnostics] = useState(null) + const [copied, setCopied] = useState<'address' | 'diagnostics' | null>(null) + + const refresh = useCallback(() => { + setAddress(peekFeePayerKeypair()?.publicKey() ?? null) + setMode(getFeePayerMode()) + setDiagnostics(getFeePayerDiagnostics()) + }, []) + + useEffect(() => { + // Idempotent and memoised — if the fee-payer is already established this + // session (the common case, set up at dashboard mount), this resolves + // immediately with no prompt and no re-derivation. + ensureFeePayer().finally(refresh) + }, [refresh]) + + const downgraded = isFeePayerPrfDowngrade(diagnostics) + + async function copy(text: string, which: 'address' | 'diagnostics') { + await navigator.clipboard.writeText(text) + setCopied(which) + setTimeout(() => setCopied(null), 2000) + } + + return ( +
+
+ {/* Header */} +
+ +

+ Fee Payer +

+
+ +

+ Your wallet contract holds your funds, but a separate account — the fee payer — signs + and pays the network fee for every transaction. This page shows which one is active and + how it was derived from your passkey. +

+ + {/* Downgrade warning */} + {downgraded && ( +
+ +
+

+ PRF was requested but is unavailable on this device +

+

+ This wallet fell back to the legacy fee payer because this authenticator did not + produce a WebAuthn PRF result{diagnostics?.prfError ? ` (${diagnostics.prfError})` : ''}. + The same passkey will derive a different fee payer on a PRF-capable device, + which can look like a different wallet there. Copy the diagnostics below if you need + to report this. +

+
+
+ )} + + {/* Active fee payer */} +
+ +

+ ACTIVE FEE PAYER +

+
+ +
+ {address ? ( + <> +
+

+ {address} +

+ +
+

+ {mode ? MODE_LABEL[mode] : 'Unknown'} +

+

+ {mode ? MODE_DESCRIPTION[mode] : 'The derivation mode for this wallet has not been established yet.'} +

+ + ) : ( +

Establishing fee payer…

+ )} +
+ + {/* Diagnostics */} +
+

+ DERIVATION LOG +

+ {diagnostics && ( + + )} +
+ +
+ {!diagnostics && ( +

+ No diagnostic log yet for this session. +

+ )} + + {diagnostics && ( + <> +

+ {diagnostics.probed + ? 'Every candidate below was derived and checked against Horizon for an existing account.' + : 'This wallet’s mode was already pinned, so no on-chain probe ran this session.'} +

+
+ {diagnostics.candidates.map((c) => ( +
+
+

+ {MODE_LABEL[c.mode]} +

+

+ {c.publicKey} +

+
+

+ {STATUS_LABEL[c.status] ?? c.status} +

+
+ ))} +
+ + )} +
+
+
+ ) +} diff --git a/frontend/wallet/app/settings/page.tsx b/frontend/wallet/app/settings/page.tsx index acbcb6c5..45aad791 100644 --- a/frontend/wallet/app/settings/page.tsx +++ b/frontend/wallet/app/settings/page.tsx @@ -18,6 +18,7 @@ import { getEncryptedMnemonic, } from '@/lib/recovery' import { walletLocal, walletSession } from '@/lib/walletStorage' +import { isFeePayerPrfDowngrade, getFeePayerDiagnostics } from '@/lib/feePayer' type Section = 'overview' | 'add-signer' | 'guardian' | 'recovery-backup' @@ -148,6 +149,7 @@ export default function SettingsPage() { const [section, setSection] = useState
('overview') const [status, setStatus] = useState(null) const [loading, setLoading] = useState(false) + const [prfDowngraded, setPrfDowngraded] = useState(false) const [signers, setSigners] = useState([]) const [localPublicKey, setLocalPublicKey] = useState(null) @@ -263,6 +265,9 @@ export default function SettingsPage() { const addr = walletSession.getItem('invisible_wallet_address') if (!addr) { router.replace('/lock'); return } setAddress(addr) + // Check fee-payer downgrade state so the card can show a warning dot + // without importing the full diagnostics into every render. + setPrfDowngraded(isFeePayerPrfDowngrade(getFeePayerDiagnostics())) }, [router]) const fetchSigners = useCallback(async () => { @@ -401,6 +406,42 @@ export default function SettingsPage() {
+ {/* Fee Payer card */} + + {/* Guardian card */}