this repo has no description
1import { useState, useEffect, useCallback } from 'react';
2import type { OAuthSession } from '@atproto/oauth-client-browser';
3import { getOAuthClient, login } from '../lib/oauth';
4import { getBalance, getUtxos, submitTransactionWithSession, formatToshis, parseToshis, MICRO, resolveHandle } from '../lib/api';
5import type { Balance, UTXO, TransactionResult } from '../lib/api';
6import { Stack, Cluster, Divider } from './ui/Layout';
7import {
8 Terminal,
9 Section,
10 LabelValue,
11 DataTable,
12 DataRow,
13 ActionBar,
14 InputField,
15 Message,
16} from './ui/Terminal';
17
18type View = 'main' | 'send' | 'burn' | 'history';
19
20interface WalletState {
21 loading: boolean;
22 error: string | null;
23 session: OAuthSession | null;
24 balance: Balance | null;
25 utxos: UTXO[];
26}
27
28export default function Wallet() {
29 const [state, setState] = useState<WalletState>({
30 loading: true,
31 error: null,
32 session: null,
33 balance: null,
34 utxos: [],
35 });
36 const [view, setView] = useState<View>('main');
37 const [handle, setHandle] = useState('');
38 const [connecting, setConnecting] = useState(false);
39
40 useEffect(() => {
41 async function init() {
42 try {
43 const client = getOAuthClient();
44 const result = await client.init();
45
46 if (result?.session) {
47 setState(s => ({ ...s, session: result.session, loading: false }));
48 } else {
49 setState(s => ({ ...s, loading: false }));
50 }
51 } catch (err) {
52 setState(s => ({
53 ...s,
54 loading: false,
55 error: err instanceof Error ? err.message : 'Failed to initialize',
56 }));
57 }
58 }
59 init();
60 }, []);
61
62 useEffect(() => {
63 async function fetchData() {
64 if (!state.session) return;
65
66 try {
67 const did = state.session.did;
68 const [balanceRes, utxosRes] = await Promise.all([
69 getBalance(did),
70 getUtxos(did, 100),
71 ]);
72 setState(s => ({
73 ...s,
74 balance: balanceRes,
75 utxos: utxosRes.utxos,
76 }));
77 } catch (err) {
78 setState(s => ({
79 ...s,
80 error: err instanceof Error ? err.message : 'Failed to fetch balance',
81 }));
82 }
83 }
84 fetchData();
85 }, [state.session]);
86
87 const handleConnect = async (e: React.FormEvent) => {
88 e.preventDefault();
89 if (!handle.trim()) return;
90
91 setConnecting(true);
92 try {
93 await login(handle.trim());
94 } catch (err) {
95 setState(s => ({
96 ...s,
97 error: err instanceof Error ? err.message : 'Failed to connect',
98 }));
99 setConnecting(false);
100 }
101 };
102
103 const handleDisconnect = async () => {
104 try {
105 if (state.session) {
106 await state.session.signOut();
107 }
108 } catch (e) {
109 console.error('Sign out error:', e);
110 }
111 setState(s => ({ ...s, session: null, balance: null, utxos: [] }));
112 window.location.reload();
113 };
114
115 const refreshData = useCallback(async () => {
116 if (!state.session) return;
117
118 try {
119 const did = state.session.did;
120 const [balanceRes, utxosRes] = await Promise.all([
121 getBalance(did),
122 getUtxos(did, 100),
123 ]);
124 setState(s => ({
125 ...s,
126 balance: balanceRes,
127 utxos: utxosRes.utxos,
128 error: null,
129 }));
130 } catch (err) {
131 setState(s => ({
132 ...s,
133 error: err instanceof Error ? err.message : 'Failed to refresh',
134 }));
135 }
136 }, [state.session]);
137
138 if (state.loading) {
139 return (
140 <Terminal subtitle="WALLET" showBack>
141 <Section>
142 <LabelValue label="STATUS" value="LOADING..." status="warning" />
143 </Section>
144 </Terminal>
145 );
146 }
147
148 if (!state.session) {
149 return (
150 <Terminal subtitle="WALLET" showBack>
151 <Section title="CONNECT WITH BLUESKY">
152 <p className="text-muted">Enter your handle to connect your wallet.</p>
153 </Section>
154
155 <Divider />
156
157 <Section>
158 <form onSubmit={handleConnect}>
159 <Stack gap="md">
160 <InputField
161 label="HANDLE"
162 value={handle}
163 onChange={setHandle}
164 placeholder="alice.bsky.social"
165 disabled={connecting}
166 width="260px"
167 />
168 <Cluster gap="sm">
169 <button type="submit" disabled={connecting || !handle.trim()}>
170 {connecting ? '[CONNECTING...]' : '[CONNECT]'}
171 </button>
172 <a href="/" className="btn secondary">[BACK]</a>
173 </Cluster>
174 </Stack>
175 </form>
176 </Section>
177 </Terminal>
178 );
179 }
180
181 return (
182 <Terminal title="@toshi WALLET">
183 <WalletInfo
184 session={state.session}
185 balance={state.balance}
186 error={state.error}
187 />
188
189 <Divider />
190
191 {view === 'main' && (
192 <MainView
193 utxos={state.utxos}
194 onSend={() => setView('send')}
195 onBurn={() => setView('burn')}
196 onHistory={() => setView('history')}
197 onRefresh={refreshData}
198 />
199 )}
200 {view === 'send' && (
201 <SendView
202 session={state.session}
203 utxos={state.utxos}
204 onBack={() => { setView('main'); refreshData(); }}
205 />
206 )}
207 {view === 'burn' && (
208 <BurnView
209 session={state.session}
210 utxos={state.utxos}
211 onBack={() => { setView('main'); refreshData(); }}
212 />
213 )}
214 {view === 'history' && (
215 <HistoryView
216 utxos={state.utxos}
217 onBack={() => setView('main')}
218 />
219 )}
220
221 <Divider />
222
223 <ActionBar>
224 <button onClick={handleDisconnect} className="secondary">[DISCONNECT]</button>
225 <a href="/explorer" className="btn">[EXPLORER]</a>
226 <a href="/whitepaper" className="btn secondary">[WHITEPAPER]</a>
227 </ActionBar>
228 </Terminal>
229 );
230}
231
232function WalletInfo({ session, balance, error }: {
233 session: OAuthSession;
234 balance: Balance | null;
235 error: string | null;
236}) {
237 return (
238 <Section title="ACCOUNT">
239 <Stack gap="xs">
240 <LabelValue label="DID" value={session.did} />
241 <LabelValue
242 label="BALANCE"
243 value={`${balance ? formatToshis(balance.balance) : '—'} @toshis`}
244 />
245 <LabelValue
246 label="UTXOS"
247 value={balance ? balance.utxoCount : '—'}
248 />
249 </Stack>
250 {error && (
251 <Message type="error">ERROR: {error}</Message>
252 )}
253 </Section>
254 );
255}
256
257function MainView({ utxos, onSend, onBurn, onHistory, onRefresh }: {
258 utxos: UTXO[];
259 onSend: () => void;
260 onBurn: () => void;
261 onHistory: () => void;
262 onRefresh: () => void;
263}) {
264 return (
265 <>
266 <Section title="COINS">
267 <DataTable
268 headers={['#', 'TX', 'AMOUNT']}
269 widths={['2rem', '1fr', '6rem']}
270 emptyMessage="(no coins)"
271 >
272 {utxos.slice(0, 10).map((utxo, i) => (
273 <DataRow
274 key={`${utxo.txId}:${utxo.index}`}
275 cells={[
276 String(i + 1),
277 `${utxo.txId}`,
278 `${formatToshis(utxo.amount)}`,
279 ]}
280 widths={['2rem', '1fr', '6rem']}
281 href={`/tx/${utxo.txId}`}
282 />
283 ))}
284 </DataTable>
285 {utxos.length > 10 && (
286 <p className="text-muted">... and {utxos.length - 10} more</p>
287 )}
288 </Section>
289
290 <Section title="ACTIONS">
291 <Cluster gap="sm">
292 <button onClick={onSend}>[SEND]</button>
293 <button onClick={onBurn} className="secondary">[BURN]</button>
294 <button onClick={onHistory} className="secondary">[HISTORY]</button>
295 <button onClick={onRefresh} className="secondary">[REFRESH]</button>
296 </Cluster>
297 </Section>
298 </>
299 );
300}
301
302function SendView({ session, utxos, onBack }: {
303 session: OAuthSession;
304 utxos: UTXO[];
305 onBack: () => void;
306}) {
307 const [recipients, setRecipients] = useState([{ address: '', amount: '' }]);
308 const [sending, setSending] = useState(false);
309 const [result, setResult] = useState<TransactionResult | null>(null);
310 const [error, setError] = useState<string | null>(null);
311
312 const totalBalanceMicro = utxos.reduce((sum, u) => sum + u.amount, 0);
313 const totalBalanceToshis = totalBalanceMicro / MICRO;
314
315 const totalSendToshis = recipients.reduce((sum, r) => sum + (parseFloat(r.amount) || 0), 0);
316 const totalSendMicro = Math.round(totalSendToshis * MICRO);
317
318 const addRecipient = () => {
319 setRecipients([...recipients, { address: '', amount: '' }]);
320 };
321
322 const removeRecipient = (index: number) => {
323 if (recipients.length > 1) {
324 setRecipients(recipients.filter((_, i) => i !== index));
325 }
326 };
327
328 const updateRecipient = (index: number, field: 'address' | 'amount', value: string) => {
329 const updated = [...recipients];
330 updated[index][field] = value;
331 setRecipients(updated);
332 };
333
334 const handleSend = async (e: React.FormEvent) => {
335 e.preventDefault();
336
337 const validRecipients = recipients.filter(r => r.address && r.amount);
338 if (validRecipients.length === 0) {
339 setError('Enter at least one recipient');
340 return;
341 }
342
343 for (const r of validRecipients) {
344 const amt = parseFloat(r.amount);
345 if (isNaN(amt) || amt <= 0) {
346 setError('Invalid amount');
347 return;
348 }
349 }
350
351 if (totalSendMicro > totalBalanceMicro) {
352 setError('Insufficient balance');
353 return;
354 }
355
356 setSending(true);
357 setError(null);
358
359 try {
360 const { inputs, change } = selectUtxos(utxos, totalSendMicro);
361
362 // Resolve handles to DIDs
363 const outputs: Array<{ owner: string; amount: number }> = [];
364 for (const r of validRecipients) {
365 let owner: string;
366 if (r.address.startsWith('did:')) {
367 // Already a DID, use as-is
368 owner = r.address;
369 } else {
370 // Resolve handle to DID
371 try {
372 owner = await resolveHandle(r.address);
373 } catch (resolveErr) {
374 throw new Error(`Failed to resolve "${r.address}": ${resolveErr instanceof Error ? resolveErr.message : 'Unknown error'}`);
375 }
376 }
377 outputs.push({
378 owner,
379 amount: Math.round(parseFloat(r.amount) * MICRO),
380 });
381 }
382
383 if (change > 0) {
384 outputs.push({ owner: session.did, amount: change });
385 }
386
387 const txInputs = inputs.map(utxo => ({
388 txId: utxo.txId,
389 index: utxo.index,
390 }));
391
392 const txResult = await submitTransactionWithSession(session, txInputs, outputs);
393 setResult(txResult);
394 } catch (err) {
395 setError(err instanceof Error ? err.message : 'Transaction failed');
396 } finally {
397 setSending(false);
398 }
399 };
400
401 if (result) {
402 return (
403 <Section title="SEND @TOSHIS">
404 <Message type="success">SENT!</Message>
405 <Stack gap="sm">
406 <LabelValue label="TXID" value={result.txId} />
407 </Stack>
408 <ActionBar>
409 <a href={`/tx/${result.txId}`} className="btn">[VIEW TX]</a>
410 <button onClick={onBack}>[DONE]</button>
411 </ActionBar>
412 </Section>
413 );
414 }
415
416 const isSingleRecipient = recipients.length === 1;
417
418 return (
419 <Section title="SEND @TOSHIS">
420 <form onSubmit={handleSend}>
421 <Stack gap="md">
422 {recipients.map((r, i) => (
423 <Stack key={i} gap="sm">
424 {!isSingleRecipient && (
425 <Cluster gap="sm" align="baseline">
426 <span className="text-muted">Recipient {i + 1}</span>
427 <button
428 type="button"
429 onClick={() => removeRecipient(i)}
430 className="secondary"
431 disabled={sending}
432 >
433 [REMOVE]
434 </button>
435 </Cluster>
436 )}
437 <InputField
438 label="TO"
439 value={r.address}
440 onChange={(v) => updateRecipient(i, 'address', v)}
441 placeholder="@handle or did:plc:..."
442 disabled={sending}
443 width="280px"
444 />
445 <Cluster gap="sm" align="baseline">
446 <InputField
447 label="AMOUNT"
448 value={r.amount}
449 onChange={(v) => updateRecipient(i, 'amount', v)}
450 placeholder="0"
451 type="text"
452 disabled={sending}
453 width="120px"
454 />
455 <span className="text-muted">@toshis</span>
456 {isSingleRecipient && (
457 <button
458 type="button"
459 onClick={() => updateRecipient(0, 'amount', String(totalBalanceToshis))}
460 className="secondary"
461 disabled={sending}
462 >
463 [MAX]
464 </button>
465 )}
466 </Cluster>
467 </Stack>
468 ))}
469
470 <button
471 type="button"
472 onClick={addRecipient}
473 className="secondary"
474 disabled={sending}
475 >
476 [+ ADD RECIPIENT]
477 </button>
478
479 <Stack gap="xs">
480 <LabelValue
481 label="SENDING"
482 value={`${formatToshis(totalSendMicro)} @toshis`}
483 />
484 <LabelValue
485 label="AVAILABLE"
486 value={`${formatToshis(totalBalanceMicro)} @toshis`}
487 />
488 </Stack>
489
490 {error && <Message type="error">{error}</Message>}
491
492 <Cluster gap="sm">
493 <button type="submit" disabled={sending || totalSendMicro === 0}>
494 {sending ? '[SENDING...]' : '[SEND]'}
495 </button>
496 <button type="button" onClick={onBack} className="secondary" disabled={sending}>
497 [CANCEL]
498 </button>
499 </Cluster>
500 </Stack>
501 </form>
502 </Section>
503 );
504}
505
506function BurnView({ session, utxos, onBack }: {
507 session: OAuthSession;
508 utxos: UTXO[];
509 onBack: () => void;
510}) {
511 const [amount, setAmount] = useState(''); // in toshis
512 const [burning, setBurning] = useState(false);
513 const [result, setResult] = useState<TransactionResult | null>(null);
514 const [error, setError] = useState<string | null>(null);
515
516 const totalBalanceMicro = utxos.reduce((sum, u) => sum + u.amount, 0);
517 const amountToshis = parseFloat(amount) || 0;
518 const amountMicro = Math.round(amountToshis * MICRO);
519 const remainingMicro = Math.max(0, totalBalanceMicro - amountMicro);
520
521 const handleBurn = async (e: React.FormEvent) => {
522 e.preventDefault();
523
524 if (isNaN(amountToshis) || amountToshis <= 0) {
525 setError('Invalid amount');
526 return;
527 }
528
529 if (amountMicro > totalBalanceMicro) {
530 setError('Insufficient balance');
531 return;
532 }
533
534 setBurning(true);
535 setError(null);
536
537 try {
538 const { inputs, change } = selectUtxos(utxos, amountMicro);
539
540 const outputs: Array<{ owner: string; amount: number }> = [];
541 if (change > 0) {
542 outputs.push({ owner: session.did, amount: change });
543 }
544
545 if (outputs.length === 0) {
546 setError('Cannot burn entire balance - keep at least 0.000001');
547 setBurning(false);
548 return;
549 }
550
551 const txInputs = inputs.map(utxo => ({
552 txId: utxo.txId,
553 index: utxo.index,
554 }));
555
556 const txResult = await submitTransactionWithSession(session, txInputs, outputs);
557 setResult(txResult);
558 } catch (err) {
559 setError(err instanceof Error ? err.message : 'Burn failed');
560 } finally {
561 setBurning(false);
562 }
563 };
564
565 if (result) {
566 return (
567 <Section title="BURN @TOSHIS">
568 <Message type="success">TOKENS BURNED!</Message>
569 <Stack gap="sm">
570 <LabelValue label="TXID" value={result.txId} />
571 <LabelValue label="BURNED" value={`${amount} @toshis`} />
572 </Stack>
573 <ActionBar>
574 <button onClick={onBack}>[BACK]</button>
575 </ActionBar>
576 </Section>
577 );
578 }
579
580 return (
581 <Section title="BURN @TOSHIS">
582 <p className="text-muted">Permanently destroy tokens. This cannot be undone.</p>
583 <form onSubmit={handleBurn}>
584 <Stack gap="md">
585 <Cluster gap="sm" align="baseline">
586 <InputField
587 label="AMOUNT TO BURN"
588 value={amount}
589 onChange={setAmount}
590 placeholder="0"
591 type="text"
592 disabled={burning}
593 width="120px"
594 />
595 <span className="text-muted">@toshis</span>
596 </Cluster>
597 <Stack gap="xs">
598 <LabelValue
599 label="AVAILABLE"
600 value={`${formatToshis(totalBalanceMicro)} @toshis`}
601 />
602 <LabelValue
603 label="REMAINING AFTER BURN"
604 value={`${formatToshis(remainingMicro)} @toshis`}
605 />
606 </Stack>
607 {error && <Message type="error">ERROR: {error}</Message>}
608 <Cluster gap="sm">
609 <button type="submit" disabled={burning || !amount}>
610 {burning ? '[BURNING...]' : '[BURN]'}
611 </button>
612 <button type="button" onClick={onBack} className="secondary" disabled={burning}>
613 [CANCEL]
614 </button>
615 </Cluster>
616 </Stack>
617 </form>
618 </Section>
619 );
620}
621
622function HistoryView({ utxos, onBack }: {
623 utxos: UTXO[];
624 onBack: () => void;
625}) {
626 const txIds = [...new Set(utxos.map(u => u.txId))];
627
628 return (
629 <Section title="TRANSACTION HISTORY">
630 <p className="text-muted">Recent transactions involving your UTXOs:</p>
631 <DataTable
632 headers={['TXID', '']}
633 widths={['1fr', '4rem']}
634 emptyMessage="(no transactions)"
635 >
636 {txIds.slice(0, 20).map(txId => (
637 <DataRow
638 key={txId}
639 cells={[txId, '[VIEW]']}
640 widths={['1fr', '4rem']}
641 href={`/tx/${txId}`}
642 />
643 ))}
644 </DataTable>
645 {txIds.length > 20 && (
646 <p className="text-muted">... and {txIds.length - 20} more</p>
647 )}
648 <ActionBar>
649 <button onClick={onBack}>[BACK]</button>
650 </ActionBar>
651 </Section>
652 );
653}
654
655function selectUtxos(utxos: UTXO[], targetAmount: number): { inputs: UTXO[]; change: number } {
656 const sorted = [...utxos].sort((a, b) => b.amount - a.amount);
657
658 const inputs: UTXO[] = [];
659 let total = 0;
660
661 for (const utxo of sorted) {
662 inputs.push(utxo);
663 total += utxo.amount;
664 if (total >= targetAmount) break;
665 }
666
667 if (total < targetAmount) {
668 throw new Error('Insufficient balance');
669 }
670
671 return {
672 inputs,
673 change: total - targetAmount,
674 };
675}