Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions frontend/wallet/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -135,6 +135,9 @@ function DashboardPageContent() {
const [wraithOutCursor, setWraithOutCursor] = useState<string | null>(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.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -755,6 +762,42 @@ function DashboardPageContent() {
</div>
)}

{/* ── PRF downgrade warning banner (issue #629) ── */}
{!loading && showPrfDowngrade && !prfDowngradeDismissed && (
<div style={{
marginBottom: '1.5rem',
padding: '1rem 1.25rem',
background: 'rgba(220,38,38,0.06)',
border: '1px solid rgba(220,38,38,0.3)',
borderRadius: '12px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--off-white)', fontWeight: 500, marginBottom: '0.375rem' }}>
Fee payer: PRF unavailable on this device
</p>
<button
id="dashboard-prf-downgrade-dismiss"
onClick={() => setPrfDowngradeDismissed(true)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-muted)', fontSize: '1rem', lineHeight: 1, padding: '0 0 0 0.5rem' }}
title="Dismiss"
>
×
</button>
</div>
<p style={{ fontSize: '0.8125rem', color: 'rgba(246,247,248,0.55)', marginBottom: '0.875rem', lineHeight: 1.5 }}>
This wallet requested a WebAuthn PRF result but the authenticator didn&apos;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.
</p>
<button
id="dashboard-prf-downgrade-details"
className="btn-gold"
onClick={() => router.push('/settings/fee-payer')}
style={{ fontSize: '0.875rem', padding: '0.625rem 1.25rem', color: 'var(--color-muted)' }}
>
View diagnostics
</button>
</div>
)}

{/* ── Sweep prompt: contract SAC balance detected ── */}
{!loading && contractXlm > 0 && !sweepDismissed && (
<div style={{
Expand Down
33 changes: 33 additions & 0 deletions frontend/wallet/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,39 @@ body {
border-top-color: var(--off-white);
}

/* ── Biometric pulse ring (landing / create screen) ─────────────────── */
@keyframes pulse-ring {
0% { transform: scale(0.92); opacity: 0.7; }
70% { transform: scale(1.18); opacity: 0; }
100% { transform: scale(0.92); opacity: 0; }
}
@keyframes pulse-ring-2 {
0% { transform: scale(0.92); opacity: 0.4; }
70% { transform: scale(1.36); opacity: 0; }
100% { transform: scale(0.92); opacity: 0; }
}
.biometric-pulse {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.biometric-pulse::before,
.biometric-pulse::after {
content: '';
position: absolute;
inset: -12px;
border-radius: 50%;
border: 1.5px solid rgba(253, 218, 36, 0.55);
pointer-events: none;
}
.biometric-pulse::before {
animation: pulse-ring 2.4s var(--ease) infinite;
}
.biometric-pulse::after {
animation: pulse-ring-2 2.4s var(--ease) 0.8s infinite;
}

/* ── Wallet shell layout ─────────────────────────────────────────────── */
.wallet-shell {
min-height: 100dvh;
Expand Down
180 changes: 101 additions & 79 deletions frontend/wallet/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,97 +133,119 @@ export default function OnboardingPage() {
<>
<main>
{showTutorial && <OnboardingTutorial onComplete={handleTutorialComplete} />}
<div className="wallet-shell" style={{ justifyContent: 'center', alignItems: 'center', padding: '2rem 1.25rem', minHeight: '100dvh' }}>
<div style={{ maxWidth: 400, width: '100%' }}>

{/* Logo + wordmark */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', marginBottom: '3rem' }}>
<div style={{ position: 'relative' }} className="biometric-pulse">
<VeilMark size={64} />
</div>
<div style={{ textAlign: 'center' }}>
<h1 style={{ fontFamily: 'Anton, Impact, sans-serif', fontSize: '2.5rem', letterSpacing: '0.08em', color: 'var(--gold)' }}>
VEIL
</h1>
<p style={{ fontSize: '0.9375rem', color: 'rgba(246,247,248,0.5)', marginTop: '0.25rem' }}>
Your passkey is your wallet
</p>
</div>
</div>
<div
className="wallet-shell"
style={{ justifyContent: 'center', alignItems: 'center', padding: '2rem 1.25rem', minHeight: '100dvh' }}
>
<div style={{ maxWidth: 400, width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>

{/* ── Hero ── */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2rem', marginBottom: '3rem', textAlign: 'center' }}>

{/* Biometric pulse ring */}
<div className="biometric-pulse" style={{ width: 96, height: 96 }}>
<VeilMark size={64} />
</div>

{/* Main card */}



{step === 'landing' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button className="btn-gold" onClick={handleCreate}>
Create wallet
</button>
<button className="btn-ghost" onClick={() => router.push('/recover')}>
Recover existing wallet
</button>
{error && (
<p style={{ fontSize: '0.8125rem', color: 'var(--teal)', textAlign: 'center', marginTop: '0.5rem' }}>
{error}
{/* Headline + lede */}
<div>
<h1
style={{
fontFamily: 'Lora, Georgia, serif',
fontWeight: 600,
fontStyle: 'italic',
fontSize: 'clamp(1.75rem, 6vw, 2.25rem)',
lineHeight: 1.25,
color: 'var(--off-white)',
marginBottom: '1rem',
}}
>
Your passkey is{' '}
<span className="hl" style={{ color: 'var(--gold)', fontStyle: 'italic' }}>
your wallet.
</span>
</h1>
<p style={{ fontSize: '0.9375rem', color: 'rgba(246,247,248,0.5)', lineHeight: 1.6 }}>
No seed phrase. No password. Just your biometric — securing a smart contract wallet on Stellar.
</p>
)}
</div>
</div>
)}

{(step === 'registering' || step === 'deploying') && (
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '1rem' }}>
<div className="spinner spinner-light" />
{/* ── CTAs ── */}
{step === 'landing' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', width: '100%' }}>
<button id="onboarding-create" className="btn-gold" onClick={handleCreate}>
Create wallet
</button>
<button id="onboarding-recover" className="btn-ghost" onClick={() => router.push('/recover')}>
Recover existing wallet
</button>
{error && (
<p style={{ fontSize: '0.8125rem', color: 'var(--teal)', textAlign: 'center', marginTop: '0.5rem' }}>
{error}
</p>
)}
</div>
<p style={{ fontFamily: 'Inter', fontWeight: 500, color: 'var(--off-white)' }}>
{step === 'registering' ? 'Waiting for biometric...' : 'Deploying wallet on-chain...'}
</p>
<p style={{ fontSize: '0.8125rem', color: 'rgba(246,247,248,0.4)', marginTop: '0.5rem' }}>
{step === 'registering'
? 'Approve the passkey prompt on your device'
: `Broadcasting to ${network.displayName}`}
</p>
</div>
)}

{step === 'done' && address && (
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ textAlign: 'center' }}>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" style={{ margin: '0 auto 0.75rem' }}>
<circle cx="20" cy="20" r="19" stroke="var(--teal)" strokeWidth="1.5" />
<path d="M13 20.5l5 5 9-9" stroke="var(--teal)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<p style={{ fontFamily: 'Lora, Georgia, serif', fontWeight: 600, fontStyle: 'italic', fontSize: '1.25rem', color: 'var(--off-white)' }}>
Wallet created
)}

{/* ── In-progress states ── */}
{(step === 'registering' || step === 'deploying') && (
<div className="card" style={{ textAlign: 'center', width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '1rem' }}>
<div className="spinner spinner-light" />
</div>
<p style={{ fontFamily: 'Inter', fontWeight: 500, color: 'var(--off-white)' }}>
{step === 'registering' ? 'Waiting for biometric...' : 'Deploying wallet on-chain...'}
</p>
<p style={{ fontSize: '0.8125rem', color: 'rgba(246,247,248,0.4)', marginTop: '0.5rem' }}>
{step === 'registering'
? 'Approve the passkey prompt on your device'
: `Broadcasting to ${network.displayName}`}
</p>
</div>
)}

{/* ── Success ── */}
{step === 'done' && address && (
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" style={{ margin: '0 auto 0.75rem' }}>
<circle cx="20" cy="20" r="19" stroke="var(--teal)" strokeWidth="1.5" />
<path d="M13 20.5l5 5 9-9" stroke="var(--teal)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<p style={{ fontFamily: 'Lora, Georgia, serif', fontWeight: 600, fontStyle: 'italic', fontSize: '1.25rem', color: 'var(--off-white)' }}>
Wallet created
</p>
</div>

<div>
<p style={{ fontSize: '0.75rem', color: 'rgba(246,247,248,0.4)', marginBottom: '0.5rem', fontFamily: 'Inter' }}>
YOUR WALLET ADDRESS
</p>
<div className="address-chip" style={{ width: '100%', justifyContent: 'center', borderRadius: 12, padding: '0.75rem 1rem' }}>
{address.slice(0, 8)}...{address.slice(-8)}
<div>
<p style={{ fontSize: '0.75rem', color: 'rgba(246,247,248,0.4)', marginBottom: '0.5rem', fontFamily: 'Anton, Impact, sans-serif', letterSpacing: '0.06em' }}>
YOUR WALLET ADDRESS
</p>
<div className="address-chip" style={{ width: '100%', justifyContent: 'center', borderRadius: 12, padding: '0.75rem 1rem' }}>
{address.slice(0, 8)}...{address.slice(-8)}
</div>
</div>

<button className="btn-gold" onClick={handleContinue}>
Open wallet
</button>
</div>
)}

{/* ── WebAuthn footnote ── */}
<p
id="webauthn-footnote"
style={{ textAlign: 'center', fontSize: '0.75rem', color: 'rgba(246,247,248,0.3)', marginTop: '2.5rem' }}
>
Secured by{' '}
<span style={{ color: 'rgba(246,247,248,0.6)' }}>WebAuthn</span>
{' '}on{' '}
<span style={{ color: 'rgba(246,247,248,0.6)' }}>Stellar</span>
</p>

<button className="btn-gold" onClick={handleContinue}>
Open wallet
</button>
</div>
)}

{/* Footer */}
<p
id= "tutorial-title"
style={{ textAlign: 'center', fontSize: '0.75rem', color: '#7c7c7c', marginTop: '2rem' }}>
No seed phrase. No private key. Powered by{' '}
<span style={{ color: 'rgba(246,247,248,0.88)' }}>Stellar Soroban</span>
</p>

</div>
</div>
</div>
</main>
</>
)
Expand Down
Loading
Loading