import { useState, useEffect, useCallback } from 'react'; import type { OAuthSession } from '@atproto/oauth-client-browser'; import { getOAuthClient, login } from '../lib/oauth'; import { getBalance, getUtxos, submitTransactionWithSession, formatToshis, parseToshis, MICRO, resolveHandle } from '../lib/api'; import type { Balance, UTXO, TransactionResult } from '../lib/api'; import { Stack, Cluster, Divider } from './ui/Layout'; import { Terminal, Section, LabelValue, DataTable, DataRow, ActionBar, InputField, Message, } from './ui/Terminal'; type View = 'main' | 'send' | 'burn' | 'history'; interface WalletState { loading: boolean; error: string | null; session: OAuthSession | null; balance: Balance | null; utxos: UTXO[]; } export default function Wallet() { const [state, setState] = useState({ loading: true, error: null, session: null, balance: null, utxos: [], }); const [view, setView] = useState('main'); const [handle, setHandle] = useState(''); const [connecting, setConnecting] = useState(false); useEffect(() => { async function init() { try { const client = getOAuthClient(); const result = await client.init(); if (result?.session) { setState(s => ({ ...s, session: result.session, loading: false })); } else { setState(s => ({ ...s, loading: false })); } } catch (err) { setState(s => ({ ...s, loading: false, error: err instanceof Error ? err.message : 'Failed to initialize', })); } } init(); }, []); useEffect(() => { async function fetchData() { if (!state.session) return; try { const did = state.session.did; const [balanceRes, utxosRes] = await Promise.all([ getBalance(did), getUtxos(did, 100), ]); setState(s => ({ ...s, balance: balanceRes, utxos: utxosRes.utxos, })); } catch (err) { setState(s => ({ ...s, error: err instanceof Error ? err.message : 'Failed to fetch balance', })); } } fetchData(); }, [state.session]); const handleConnect = async (e: React.FormEvent) => { e.preventDefault(); if (!handle.trim()) return; setConnecting(true); try { await login(handle.trim()); } catch (err) { setState(s => ({ ...s, error: err instanceof Error ? err.message : 'Failed to connect', })); setConnecting(false); } }; const handleDisconnect = async () => { try { if (state.session) { await state.session.signOut(); } } catch (e) { console.error('Sign out error:', e); } setState(s => ({ ...s, session: null, balance: null, utxos: [] })); window.location.reload(); }; const refreshData = useCallback(async () => { if (!state.session) return; try { const did = state.session.did; const [balanceRes, utxosRes] = await Promise.all([ getBalance(did), getUtxos(did, 100), ]); setState(s => ({ ...s, balance: balanceRes, utxos: utxosRes.utxos, error: null, })); } catch (err) { setState(s => ({ ...s, error: err instanceof Error ? err.message : 'Failed to refresh', })); } }, [state.session]); if (state.loading) { return (
); } if (!state.session) { return (

Enter your handle to connect your wallet.

[BACK]
); } return ( {view === 'main' && ( setView('send')} onBurn={() => setView('burn')} onHistory={() => setView('history')} onRefresh={refreshData} /> )} {view === 'send' && ( { setView('main'); refreshData(); }} /> )} {view === 'burn' && ( { setView('main'); refreshData(); }} /> )} {view === 'history' && ( setView('main')} /> )} [EXPLORER] [WHITEPAPER] ); } function WalletInfo({ session, balance, error }: { session: OAuthSession; balance: Balance | null; error: string | null; }) { return (
{error && ( ERROR: {error} )}
); } function MainView({ utxos, onSend, onBurn, onHistory, onRefresh }: { utxos: UTXO[]; onSend: () => void; onBurn: () => void; onHistory: () => void; onRefresh: () => void; }) { return ( <>
{utxos.slice(0, 10).map((utxo, i) => ( ))} {utxos.length > 10 && (

... and {utxos.length - 10} more

)}
); } function SendView({ session, utxos, onBack }: { session: OAuthSession; utxos: UTXO[]; onBack: () => void; }) { const [recipients, setRecipients] = useState([{ address: '', amount: '' }]); const [sending, setSending] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const totalBalanceMicro = utxos.reduce((sum, u) => sum + u.amount, 0); const totalBalanceToshis = totalBalanceMicro / MICRO; const totalSendToshis = recipients.reduce((sum, r) => sum + (parseFloat(r.amount) || 0), 0); const totalSendMicro = Math.round(totalSendToshis * MICRO); const addRecipient = () => { setRecipients([...recipients, { address: '', amount: '' }]); }; const removeRecipient = (index: number) => { if (recipients.length > 1) { setRecipients(recipients.filter((_, i) => i !== index)); } }; const updateRecipient = (index: number, field: 'address' | 'amount', value: string) => { const updated = [...recipients]; updated[index][field] = value; setRecipients(updated); }; const handleSend = async (e: React.FormEvent) => { e.preventDefault(); const validRecipients = recipients.filter(r => r.address && r.amount); if (validRecipients.length === 0) { setError('Enter at least one recipient'); return; } for (const r of validRecipients) { const amt = parseFloat(r.amount); if (isNaN(amt) || amt <= 0) { setError('Invalid amount'); return; } } if (totalSendMicro > totalBalanceMicro) { setError('Insufficient balance'); return; } setSending(true); setError(null); try { const { inputs, change } = selectUtxos(utxos, totalSendMicro); // Resolve handles to DIDs const outputs: Array<{ owner: string; amount: number }> = []; for (const r of validRecipients) { let owner: string; if (r.address.startsWith('did:')) { // Already a DID, use as-is owner = r.address; } else { // Resolve handle to DID try { owner = await resolveHandle(r.address); } catch (resolveErr) { throw new Error(`Failed to resolve "${r.address}": ${resolveErr instanceof Error ? resolveErr.message : 'Unknown error'}`); } } outputs.push({ owner, amount: Math.round(parseFloat(r.amount) * MICRO), }); } if (change > 0) { outputs.push({ owner: session.did, amount: change }); } const txInputs = inputs.map(utxo => ({ txId: utxo.txId, index: utxo.index, })); const txResult = await submitTransactionWithSession(session, txInputs, outputs); setResult(txResult); } catch (err) { setError(err instanceof Error ? err.message : 'Transaction failed'); } finally { setSending(false); } }; if (result) { return (
SENT! [VIEW TX]
); } const isSingleRecipient = recipients.length === 1; return (
{recipients.map((r, i) => ( {!isSingleRecipient && ( Recipient {i + 1} )} updateRecipient(i, 'address', v)} placeholder="@handle or did:plc:..." disabled={sending} width="280px" /> updateRecipient(i, 'amount', v)} placeholder="0" type="text" disabled={sending} width="120px" /> @toshis {isSingleRecipient && ( )} ))} {error && {error}}
); } function BurnView({ session, utxos, onBack }: { session: OAuthSession; utxos: UTXO[]; onBack: () => void; }) { const [amount, setAmount] = useState(''); // in toshis const [burning, setBurning] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const totalBalanceMicro = utxos.reduce((sum, u) => sum + u.amount, 0); const amountToshis = parseFloat(amount) || 0; const amountMicro = Math.round(amountToshis * MICRO); const remainingMicro = Math.max(0, totalBalanceMicro - amountMicro); const handleBurn = async (e: React.FormEvent) => { e.preventDefault(); if (isNaN(amountToshis) || amountToshis <= 0) { setError('Invalid amount'); return; } if (amountMicro > totalBalanceMicro) { setError('Insufficient balance'); return; } setBurning(true); setError(null); try { const { inputs, change } = selectUtxos(utxos, amountMicro); const outputs: Array<{ owner: string; amount: number }> = []; if (change > 0) { outputs.push({ owner: session.did, amount: change }); } if (outputs.length === 0) { setError('Cannot burn entire balance - keep at least 0.000001'); setBurning(false); return; } const txInputs = inputs.map(utxo => ({ txId: utxo.txId, index: utxo.index, })); const txResult = await submitTransactionWithSession(session, txInputs, outputs); setResult(txResult); } catch (err) { setError(err instanceof Error ? err.message : 'Burn failed'); } finally { setBurning(false); } }; if (result) { return (
TOKENS BURNED!
); } return (

Permanently destroy tokens. This cannot be undone.

@toshis {error && ERROR: {error}}
); } function HistoryView({ utxos, onBack }: { utxos: UTXO[]; onBack: () => void; }) { const txIds = [...new Set(utxos.map(u => u.txId))]; return (

Recent transactions involving your UTXOs:

{txIds.slice(0, 20).map(txId => ( ))} {txIds.length > 20 && (

... and {txIds.length - 20} more

)}
); } function selectUtxos(utxos: UTXO[], targetAmount: number): { inputs: UTXO[]; change: number } { const sorted = [...utxos].sort((a, b) => b.amount - a.amount); const inputs: UTXO[] = []; let total = 0; for (const utxo of sorted) { inputs.push(utxo); total += utxo.amount; if (total >= targetAmount) break; } if (total < targetAmount) { throw new Error('Insufficient balance'); } return { inputs, change: total - targetAmount, }; }