Skip to content
Open
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
6 changes: 0 additions & 6 deletions .storybook/decorators/withStealthKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

const noop = () => {};

const baseValue: StealthKeysValue = {

Check failure on line 9 in .storybook/decorators/withStealthKeys.tsx

View workflow job for this annotation

GitHub Actions / build

Type '{ evmKeys: null; evmMetaAddress: null; stellarKeys: null; stellarMetaAddress: null; solanaKeys: null; solanaMetaAddress: null; ckbKeys: null; ckbMetaAddress: null; setEvmKeys: () => void; setEvmMetaAddress: () => void; ... 9 more ...; clearCkb: () => void; }' is missing the following properties from type 'StealthKeysContextValue': isRecoveryMode, isReadOnly, setIsRecoveryMode, setIsReadOnly, and 3 more.
evmKeys: null,
evmMetaAddress: null,
stellarKeys: null,
Expand All @@ -15,12 +15,6 @@
solanaMetaAddress: null,
ckbKeys: null,
ckbMetaAddress: null,
isRecoveryMode: false,
isReadOnly: false,
setIsRecoveryMode: noop,
setIsReadOnly: noop,
restoreFromRecoveryKit: noop,
exitRecoveryMode: noop,
setEvmKeys: noop,
setEvmMetaAddress: noop,
setStellarKeys: noop,
Expand Down
8 changes: 0 additions & 8 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,6 @@
"background_color": "#0e0e0e",
"lang": "en",
"categories": ["finance", "utilities"],
"share_target": {
"action": "/send",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"text": "text"
}
},
"icons": [
{
"src": "/icons/icon-72x72.png",
Expand Down
152 changes: 1 addition & 151 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Header } from '@/components/Header';
import { AutoSign } from '@/components/AutoSign';
import { TelemetryBanner } from '@/components/TelemetryBanner';
Expand All @@ -15,160 +14,12 @@ import { useNotificationSW } from '@/hooks/useNotificationSW';
import Schedule from '@/pages/Schedule';
import StellarSplit from '@/pages/StellarSplit';
import Names from '@/pages/Names';
import NamesAuctions from '@/pages/NamesAuctions';
import Activity from '@/pages/Activity';
import Portfolio from '@/pages/Portfolio';
import Debug from '@/pages/Debug';
import { useChain } from '@/context/ChainContext';
import { useStealthKeys } from '@/context/StealthKeysContext';
import { KeyVault } from '@/vault/KeyVault';
import {
APP_IDLE_TIMEOUT_MS,
IdleLock,
authenticateWithPasskey,
isPasskeySupported,
} from '@/lib/idleLock';
import { parseStellarQrPayload } from '@/utils/qr';

function SessionLock({ onUnlock }: { onUnlock: () => void }) {
const [passphrase, setPassphrase] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const vaultRef = useRef<KeyVault | null>(null);

const unlockWithPasskey = async () => {
setBusy(true);
setError('');
try {
await authenticateWithPasskey();
onUnlock();
} catch {
setError('No Wraith passkey was found. Unlock with your vault passphrase instead.');
} finally {
setBusy(false);
}
};

const unlockWithPassphrase = async (event: FormEvent) => {
event.preventDefault();
if (!passphrase) return;

setBusy(true);
setError('');
try {
vaultRef.current ??= new KeyVault({
idleTimeoutMs: 0,
lockOnBlur: false,
lockOnVisibilityChange: false,
});
await vaultRef.current.unlock(passphrase);
await vaultRef.current.lock();
setPassphrase('');
onUnlock();
} catch {
setError('That passphrase could not unlock the Wraith vault.');
} finally {
setBusy(false);
}
};

return (
<main className="flex min-h-screen items-center justify-center bg-surface px-4">
<section className="w-full max-w-sm border border-outline-variant bg-surface-container p-6">
<img src="/logo.png" alt="" className="mb-5 h-8 w-8" />
<h1 className="font-heading text-lg font-bold uppercase tracking-widest text-on-surface">
Wraith locked
</h1>
<p className="mt-2 font-body text-sm leading-relaxed text-on-surface-variant">
Your session was locked after five minutes of inactivity.
</p>

{isPasskeySupported() && (
<button
type="button"
onClick={unlockWithPasskey}
disabled={busy}
className="mt-6 h-11 w-full bg-primary font-heading text-[11px] font-semibold uppercase tracking-widest text-surface disabled:opacity-50"
>
Unlock with passkey
</button>
)}

<form onSubmit={unlockWithPassphrase} className="mt-4">
<label
htmlFor="session-passphrase"
className="font-heading text-[10px] uppercase tracking-widest text-outline"
>
Vault passphrase
</label>
<input
id="session-passphrase"
type="password"
autoComplete="current-password"
value={passphrase}
onChange={(event) => setPassphrase(event.target.value)}
className="mt-2 h-11 w-full border border-outline-variant bg-surface px-3 font-body text-sm text-on-surface outline-none focus:border-primary"
/>
<button
type="submit"
disabled={busy || !passphrase}
className="mt-3 h-11 w-full border border-primary font-heading text-[11px] font-semibold uppercase tracking-widest text-primary disabled:opacity-50"
>
{busy ? 'Unlocking...' : 'Unlock with passphrase'}
</button>
</form>

{error && (
<p role="alert" className="mt-4 font-body text-xs text-error">
{error}
</p>
)}
</section>
</main>
);
}

export function App() {
useNotificationSW();
const location = useLocation();
const navigate = useNavigate();
const { setChain } = useChain();
const { clearEvm, clearStellar, clearSolana, clearCkb } = useStealthKeys();
const [sessionLocked, setSessionLocked] = useState(false);

const relockSession = useCallback(() => {
clearEvm();
clearStellar();
clearSolana();
clearCkb();
setSessionLocked(true);
}, [clearCkb, clearEvm, clearSolana, clearStellar]);

useEffect(() => {
if (sessionLocked) return;
const idleLock = new IdleLock({ timeoutMs: APP_IDLE_TIMEOUT_MS, onIdle: relockSession });
idleLock.start();
return () => idleLock.stop();
}, [relockSession, sessionLocked]);

useEffect(() => {
if (location.pathname !== '/send') return;
const sharedText = new URLSearchParams(location.search).get('text');
if (!sharedText) return;

try {
const payload = parseStellarQrPayload(sharedText);
const params = new URLSearchParams({ to: payload.metaAddress });
if (payload.amount) params.set('amount', payload.amount);
if (payload.memo) params.set('memo', payload.memo);
setChain('stellar');
navigate(`/send?${params.toString()}`, { replace: true });
} catch {
// Leave unsupported shared text untouched so the user can correct it manually.
}
}, [location.pathname, location.search, navigate, setChain]);

if (sessionLocked) return <SessionLock onUnlock={() => setSessionLocked(false)} />;

return (
<div className="flex min-h-screen flex-col">
Expand All @@ -187,7 +38,6 @@ export function App() {
<Route path="/stellar/split" element={<StellarSplit />} />
<Route path="/pay" element={<Send />} />
<Route path="/names" element={<Names />} />
<Route path="/names/auctions" element={<NamesAuctions />} />
<Route path="/activity" element={<Activity />} />
<Route path="/history" element={<Activity />} />
<Route path="/portfolio" element={<Portfolio />} />
Expand Down
66 changes: 48 additions & 18 deletions src/components/AutoSign.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ import type { HexString as CkbHexString } from '@wraith-protocol/sdk/chains/ckb'
import { useStealthKeys } from '@/context/StealthKeysContext';
import { useStellarWallet } from '@/context/StellarWalletContext';
import { useChain } from '@/context/ChainContext';
import { useProfilesStore } from '@/store/profilesStore';
import { profileSigningMessage } from '@/lib/profileSigningMessage';

function HorizenAutoSign() {
const { isConnected, address, connector } = useAccount();
const { data: connectorClient } = useConnectorClient();
const { signMessageAsync } = useSignMessage();
const { evmKeys, setEvmKeys, setEvmMetaAddress, clearEvm } = useStealthKeys();
const activeProfileId = useProfilesStore((s) => s.activeProfileId);
const prompted = useRef<string | null>(null);
const [ready, setReady] = useState(false);
const isLoading = useRef(false);
Expand All @@ -49,14 +52,16 @@ function HorizenAutoSign() {
if (!ready || !address) return;
if (evmKeys) return;
if (isLoading.current) return;
if (prompted.current === address) return;
const promptKey = `${address}:${activeProfileId}`;
if (prompted.current === promptKey) return;

prompted.current = address;
prompted.current = promptKey;
isLoading.current = true;

(async () => {
try {
const signature = await signMessageAsync({ message: STEALTH_SIGNING_MESSAGE });
const message = profileSigningMessage(STEALTH_SIGNING_MESSAGE, activeProfileId);
const signature = await signMessageAsync({ message });
const keys = deriveStealthKeys(signature as HexString);
const meta = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey);
setEvmKeys(keys);
Expand All @@ -67,7 +72,7 @@ function HorizenAutoSign() {
isLoading.current = false;
}
})();
}, [ready, address, evmKeys, signMessageAsync, setEvmKeys, setEvmMetaAddress]);
}, [ready, address, evmKeys, activeProfileId, signMessageAsync, setEvmKeys, setEvmMetaAddress]);

useEffect(() => {
if (!isConnected) {
Expand All @@ -83,6 +88,7 @@ function HorizenAutoSign() {
function StellarAutoSign() {
const { isConnected, address, signMessage } = useStellarWallet();
const { stellarKeys, setStellarKeys, setStellarMetaAddress, clearStellar } = useStealthKeys();
const activeProfileId = useProfilesStore((s) => s.activeProfileId);
const prompted = useRef<string | null>(null);
const [ready, setReady] = useState(false);
const isLoading = useRef(false);
Expand All @@ -99,14 +105,16 @@ function StellarAutoSign() {
if (!ready || !address) return;
if (stellarKeys) return;
if (isLoading.current) return;
if (prompted.current === address) return;
const promptKey = `${address}:${activeProfileId}`;
if (prompted.current === promptKey) return;

prompted.current = address;
prompted.current = promptKey;
isLoading.current = true;

(async () => {
try {
const signature = await signMessage(STELLAR_SIGNING_MESSAGE);
const message = profileSigningMessage(STELLAR_SIGNING_MESSAGE, activeProfileId);
const signature = await signMessage(message);
const keys = deriveStellarKeys(signature);
const meta = encodeStellarMeta(keys.spendingPubKey, keys.viewingPubKey);
setStellarKeys(keys);
Expand All @@ -117,7 +125,15 @@ function StellarAutoSign() {
isLoading.current = false;
}
})();
}, [ready, address, stellarKeys, signMessage, setStellarKeys, setStellarMetaAddress]);
}, [
ready,
address,
stellarKeys,
activeProfileId,
signMessage,
setStellarKeys,
setStellarMetaAddress,
]);

useEffect(() => {
if (!isConnected) {
Expand All @@ -133,6 +149,7 @@ function StellarAutoSign() {
function SolanaAutoSign() {
const { connected, publicKey, signMessage } = useWallet();
const { solanaKeys, setSolanaKeys, setSolanaMetaAddress, clearSolana } = useStealthKeys();
const activeProfileId = useProfilesStore((s) => s.activeProfileId);
const prompted = useRef<string | null>(null);
const [ready, setReady] = useState(false);
const isLoading = useRef(false);
Expand All @@ -150,14 +167,16 @@ function SolanaAutoSign() {
if (solanaKeys) return;
if (isLoading.current) return;
const addr = publicKey.toBase58();
if (prompted.current === addr) return;
const promptKey = `${addr}:${activeProfileId}`;
if (prompted.current === promptKey) return;

prompted.current = addr;
prompted.current = promptKey;
isLoading.current = true;

(async () => {
try {
const msgBytes = new TextEncoder().encode(SOLANA_SIGNING_MESSAGE);
const message = profileSigningMessage(SOLANA_SIGNING_MESSAGE, activeProfileId);
const msgBytes = new TextEncoder().encode(message);
const signature = await signMessage(msgBytes);
const keys = deriveSolanaKeys(signature);
const meta = encodeSolanaMeta(keys.spendingPubKey, keys.viewingPubKey);
Expand All @@ -169,7 +188,15 @@ function SolanaAutoSign() {
isLoading.current = false;
}
})();
}, [ready, publicKey, solanaKeys, signMessage, setSolanaKeys, setSolanaMetaAddress]);
}, [
ready,
publicKey,
solanaKeys,
activeProfileId,
signMessage,
setSolanaKeys,
setSolanaMetaAddress,
]);

useEffect(() => {
if (!connected) {
Expand All @@ -186,7 +213,8 @@ function CkbAutoSign() {
const { wallet } = ccc.useCcc();
const signer = ccc.useSigner();
const { ckbKeys, setCkbKeys, setCkbMetaAddress, clearCkb } = useStealthKeys();
const prompted = useRef(false);
const activeProfileId = useProfilesStore((s) => s.activeProfileId);
const prompted = useRef<string | null>(null);
const [ready, setReady] = useState(false);
const isLoading = useRef(false);

Expand All @@ -202,14 +230,16 @@ function CkbAutoSign() {
if (!ready || !signer) return;
if (ckbKeys) return;
if (isLoading.current) return;
if (prompted.current) return;
const promptKey = activeProfileId;
if (prompted.current === promptKey) return;

prompted.current = true;
prompted.current = promptKey;
isLoading.current = true;

(async () => {
try {
const sig = await (signer as any).signMessageRaw(CKB_SIGNING_MESSAGE);
const message = profileSigningMessage(CKB_SIGNING_MESSAGE, activeProfileId);
const sig = await (signer as any).signMessageRaw(message);
const sigStr = typeof sig === 'string' ? sig : `0x${Buffer.from(sig).toString('hex')}`;
const sigHex = sigStr.startsWith('0x') ? sigStr : `0x${sigStr}`;
const derived = deriveCkbKeys(sigHex as CkbHexString);
Expand All @@ -222,11 +252,11 @@ function CkbAutoSign() {
isLoading.current = false;
}
})();
}, [ready, signer, ckbKeys, setCkbKeys, setCkbMetaAddress]);
}, [ready, signer, ckbKeys, activeProfileId, setCkbKeys, setCkbMetaAddress]);

useEffect(() => {
if (!wallet) {
prompted.current = false;
prompted.current = null;
setReady(false);
clearCkb();
}
Expand Down
Loading
Loading