this repo has no description
at main 675 lines 19 kB view raw
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}