hd-wallet-ui 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js CHANGED
@@ -20,7 +20,7 @@ import { Buffer } from 'buffer';
20
20
  import { createV3 } from 'vcard-cryptoperson';
21
21
 
22
22
  // SpaceDataStandards EME (Encrypted Message Envelope)
23
- import { EME, EMET } from '@sds/lib/js/ts/EME/EME.js';
23
+ import { EME, EMET } from '@sds/lib/js/EME/EME.js';
24
24
  import * as flatbuffers from 'flatbuffers';
25
25
 
26
26
  // Make Buffer available globally for various crypto libraries
@@ -229,6 +229,18 @@ const state = {
229
229
  encryptionIV: null,
230
230
  // vCard photo (base64 data URI)
231
231
  vcardPhoto: null,
232
+ // Active accounts discovered by scanning or manually added
233
+ activeAccounts: [],
234
+ // Wallet groups (Phantom-style: each wallet = same account index across chains)
235
+ wallets: [{ id: 0, name: 'Wallet 1', accountIndex: 0 }],
236
+ activeWalletId: 0,
237
+ walletManageTab: 'active',
238
+ walletFiatTotals: {},
239
+ walletFiatCurrency: 'USD',
240
+ balanceCache: {},
241
+ balanceCacheLoaded: false,
242
+ balanceRateLimitUntil: {},
243
+ scanInProgress: false,
232
244
  // PKI Demo state
233
245
  pki: {
234
246
  alice: null,
@@ -454,6 +466,1552 @@ function updatePathDisplay() {
454
466
  if (encryptionPathEl) encryptionPathEl.textContent = encryptionPath;
455
467
  }
456
468
 
469
+ // =============================================================================
470
+ // Active Accounts: Derivation, Persistence, Scanning, Rendering
471
+ // =============================================================================
472
+
473
+ /**
474
+ * Derive an address for a given BIP44 path.
475
+ * SOL (501) uses ed25519; BTC (0) and ETH (60) use secp256k1.
476
+ * @returns {{ address: string, publicKey: Uint8Array, path: string }}
477
+ */
478
+ function deriveAddressForPath(coinType, account, index) {
479
+ if (!state.hdRoot) throw new Error('HD wallet not initialized');
480
+ const path = buildSigningPath(coinType, account, index);
481
+ const derived = state.hdRoot.derivePath(path);
482
+
483
+ if (coinType === 501) {
484
+ // Solana: ed25519
485
+ const privKey = derived.privateKey();
486
+ const pubKey = ed25519.getPublicKey(privKey);
487
+ return { address: generateSolAddress(pubKey), publicKey: pubKey, path };
488
+ }
489
+
490
+ // BTC/ETH: secp256k1
491
+ const pubKey = derived.publicKey();
492
+ return { address: generateAddressForCoin(pubKey, coinType), publicKey: pubKey, path };
493
+ }
494
+
495
+ const ACTIVE_ACCOUNTS_KEY = 'hd-wallet-active-accounts';
496
+ const WALLETS_KEY = 'hd-wallet-wallets';
497
+ const DEFAULT_WALLET_COUNT = 10;
498
+ const WALLET_OVERLAY_VIEWS = ['wallet-wallets-view', 'wallet-export-view', 'wallet-advanced-view', 'wallet-send-view'];
499
+ const BALANCE_CACHE_KEY = 'hd-wallet-scan-balance-cache-v1';
500
+ const BALANCE_CACHE_TTL_MS = 2 * 60 * 1000;
501
+ const BALANCE_CACHE_STALE_MS = 30 * 60 * 1000;
502
+ const SCAN_REQUEST_DELAY_MS = 700;
503
+ const SCAN_RETRY_BASE_DELAY_MS = 1200;
504
+ const SCAN_MAX_RETRIES = 2;
505
+ const BALANCE_RATE_LIMIT_COOLDOWN_MS = 2 * 60 * 1000;
506
+ let _scanLastRequestAt = 0;
507
+ let _balanceCacheDirty = false;
508
+
509
+ function getDefaultWalletState() {
510
+ return [{ id: 0, name: 'Wallet 1', accountIndex: 0, inactive: false }];
511
+ }
512
+
513
+ function getDefaultWalletName(accountIndex) {
514
+ return `Wallet ${accountIndex + 1}`;
515
+ }
516
+
517
+ function normalizeWalletName(rawName, accountIndex) {
518
+ const fallback = getDefaultWalletName(accountIndex);
519
+ const trimmed = (rawName || '').toString().trim();
520
+ if (!trimmed) return fallback;
521
+ if (/^wallet(?:\s+\d+)?$/i.test(trimmed)) return fallback;
522
+ return trimmed;
523
+ }
524
+
525
+ function ensureWalletNamesNormalized() {
526
+ let changed = false;
527
+ state.wallets.forEach((wallet) => {
528
+ const normalized = normalizeWalletName(wallet.name, wallet.accountIndex);
529
+ if (normalized !== wallet.name) {
530
+ wallet.name = normalized;
531
+ changed = true;
532
+ }
533
+ });
534
+ if (changed) saveWallets();
535
+ return changed;
536
+ }
537
+
538
+ function getWalletDerivationEntries(wallet) {
539
+ return [
540
+ { coinType: 0, name: 'BTC', account: wallet.accountIndex, index: 0 },
541
+ { coinType: 60, name: 'ETH', account: 0, index: wallet.accountIndex },
542
+ { coinType: 501, name: 'SOL', account: wallet.accountIndex, index: 0 },
543
+ ];
544
+ }
545
+
546
+ function getWalletIdForPath(coinType, account, index) {
547
+ const accountIndex = coinType === 60 ? index : account;
548
+ const wallet = state.wallets.find(w => w.accountIndex === accountIndex);
549
+ return wallet ? wallet.id : 0;
550
+ }
551
+
552
+ function isWalletInactive(wallet) {
553
+ return Boolean(wallet?.inactive);
554
+ }
555
+
556
+ function getActiveWallets() {
557
+ return state.wallets.filter(wallet => !isWalletInactive(wallet));
558
+ }
559
+
560
+ function getInactiveWallets() {
561
+ return state.wallets.filter(wallet => isWalletInactive(wallet));
562
+ }
563
+
564
+ function normalizeWallets(wallets) {
565
+ const normalized = [];
566
+ const source = Array.isArray(wallets) ? wallets : [];
567
+ const usedIds = new Set();
568
+ const usedAccountIndexes = new Set();
569
+
570
+ for (const wallet of source) {
571
+ const id = Number.parseInt(wallet?.id, 10);
572
+ const accountIndex = Number.parseInt(wallet?.accountIndex, 10);
573
+ if (Number.isNaN(id) || Number.isNaN(accountIndex)) continue;
574
+ if (usedIds.has(id) || usedAccountIndexes.has(accountIndex)) continue;
575
+
576
+ const name = normalizeWalletName(wallet?.name, accountIndex);
577
+ const inactive = Boolean(wallet?.inactive);
578
+ normalized.push({ id, name, accountIndex, inactive });
579
+ usedIds.add(id);
580
+ usedAccountIndexes.add(accountIndex);
581
+ }
582
+
583
+ let nextId = normalized.reduce((max, wallet) => Math.max(max, wallet.id), -1) + 1;
584
+ for (let accountIndex = 0; accountIndex < DEFAULT_WALLET_COUNT; accountIndex++) {
585
+ if (usedAccountIndexes.has(accountIndex)) continue;
586
+ while (usedIds.has(nextId)) nextId++;
587
+ normalized.push({
588
+ id: nextId,
589
+ name: getDefaultWalletName(accountIndex),
590
+ accountIndex,
591
+ inactive: false,
592
+ });
593
+ usedIds.add(nextId);
594
+ }
595
+
596
+ if (normalized.length === 0) return getDefaultWalletState();
597
+ normalized.sort((a, b) => a.accountIndex - b.accountIndex || a.id - b.id);
598
+ return normalized;
599
+ }
600
+
601
+ function normalizeActiveAccounts(accounts) {
602
+ const source = Array.isArray(accounts) ? accounts : [];
603
+ return source.map((acct) => {
604
+ const existingWalletId = Number.parseInt(acct.walletId, 10);
605
+ const walletExists = state.wallets.some(w => w.id === existingWalletId);
606
+ const walletId = walletExists
607
+ ? existingWalletId
608
+ : getWalletIdForPath(Number.parseInt(acct.coinType, 10), Number.parseInt(acct.account, 10), Number.parseInt(acct.index, 10));
609
+ return { ...acct, walletId };
610
+ });
611
+ }
612
+
613
+ function ensureWalletAccounts() {
614
+ if (!state.hdRoot) return false;
615
+
616
+ const existing = new Set(
617
+ state.activeAccounts.map(a => `${a.walletId ?? getWalletIdForPath(a.coinType, a.account, a.index)}:${a.coinType}:${a.account}:${a.index}`)
618
+ );
619
+ let added = false;
620
+
621
+ for (const wallet of state.wallets) {
622
+ for (const entry of getWalletDerivationEntries(wallet)) {
623
+ const key = `${wallet.id}:${entry.coinType}:${entry.account}:${entry.index}`;
624
+ if (existing.has(key)) continue;
625
+ try {
626
+ const { address, path } = deriveAddressForPath(entry.coinType, entry.account, entry.index);
627
+ state.activeAccounts.push({
628
+ coinType: entry.coinType,
629
+ name: entry.name,
630
+ account: entry.account,
631
+ index: entry.index,
632
+ address,
633
+ path,
634
+ balance: '--',
635
+ active: false,
636
+ walletId: wallet.id,
637
+ });
638
+ existing.add(key);
639
+ added = true;
640
+ } catch (e) {
641
+ console.warn('Failed to derive wallet account:', entry, e);
642
+ }
643
+ }
644
+ }
645
+
646
+ if (added) saveActiveAccounts();
647
+ return added;
648
+ }
649
+
650
+ function getWalletById(walletId) {
651
+ return state.wallets.find(w => w.id === walletId);
652
+ }
653
+
654
+ function getCurrentWallet() {
655
+ const activeWallets = getActiveWallets();
656
+ if (activeWallets.length === 0) return null;
657
+ const current = activeWallets.find(wallet => wallet.id === state.activeWalletId);
658
+ return current || activeWallets[0] || null;
659
+ }
660
+
661
+ function getAccountWalletId(acct) {
662
+ if (!acct) return 0;
663
+ if (acct.walletId !== undefined && getWalletById(acct.walletId)) return acct.walletId;
664
+ return getWalletIdForPath(acct.coinType, acct.account, acct.index);
665
+ }
666
+
667
+ function isSigningAccountForWallet(acct, wallet) {
668
+ if (!acct || !wallet) return false;
669
+ const coinType = Number.parseInt(acct.coinType, 10);
670
+ const account = Number.parseInt(acct.account, 10);
671
+ const index = Number.parseInt(acct.index, 10);
672
+ if (Number.isNaN(coinType) || Number.isNaN(account) || Number.isNaN(index)) return false;
673
+
674
+ if (coinType === 60) return account === 0 && index === wallet.accountIndex;
675
+ if (coinType === 0 || coinType === 501) return account === wallet.accountIndex && index === 0;
676
+ return false;
677
+ }
678
+
679
+ function isSigningAccount(acct) {
680
+ const wallet = getWalletById(getAccountWalletId(acct));
681
+ return isSigningAccountForWallet(acct, wallet);
682
+ }
683
+
684
+ function updateCustomPathWalletLabel() {
685
+ const label = $('custom-path-wallet-label');
686
+ const wallet = getCurrentWallet();
687
+ if (!label) return;
688
+ label.textContent = wallet ? `${wallet.name} (account ${wallet.accountIndex})` : `${getDefaultWalletName(0)} (account 0)`;
689
+ }
690
+
691
+ function updateCustomPathDefault() {
692
+ const chainSelect = $('custom-path-chain');
693
+ const input = $('custom-path-input');
694
+ const wallet = getCurrentWallet();
695
+ if (!chainSelect || !input || !wallet) return;
696
+
697
+ const coinType = Number.parseInt(chainSelect.value, 10);
698
+ if (Number.isNaN(coinType)) return;
699
+ const account = coinType === 60 ? 0 : wallet.accountIndex;
700
+ const index = coinType === 60 ? wallet.accountIndex : 0;
701
+ input.value = buildSigningPath(coinType, account, index);
702
+ input.dataset.autogenerated = 'true';
703
+ }
704
+
705
+ function renderWalletSelector() {
706
+ const select = $('wallet-active-select');
707
+ if (!select) return;
708
+ ensureWalletNamesNormalized();
709
+
710
+ const currentWallet = getCurrentWallet();
711
+ if (!currentWallet) {
712
+ select.innerHTML = '';
713
+ return;
714
+ }
715
+ state.activeWalletId = currentWallet.id;
716
+
717
+ select.innerHTML = '';
718
+ const displayCurrency = state.walletFiatCurrency || getSelectedCurrency();
719
+ const activeWallets = getActiveWallets();
720
+ activeWallets.forEach((wallet) => {
721
+ const option = document.createElement('option');
722
+ option.value = String(wallet.id);
723
+ const walletValue = state.walletFiatTotals[wallet.id] ?? 0;
724
+ option.textContent = `${wallet.name} (${formatCurrencyValue(walletValue, displayCurrency)})`;
725
+ select.appendChild(option);
726
+ });
727
+ select.value = String(state.activeWalletId);
728
+ updateCustomPathWalletLabel();
729
+ }
730
+
731
+ function sleep(ms) {
732
+ return new Promise(resolve => setTimeout(resolve, ms));
733
+ }
734
+
735
+ function loadBalanceCache() {
736
+ try {
737
+ const raw = localStorage.getItem(BALANCE_CACHE_KEY);
738
+ if (!raw) return {};
739
+ const parsed = JSON.parse(raw);
740
+ return parsed && typeof parsed === 'object' ? parsed : {};
741
+ } catch {
742
+ return {};
743
+ }
744
+ }
745
+
746
+ function saveBalanceCache() {
747
+ if (!_balanceCacheDirty) return;
748
+ try {
749
+ localStorage.setItem(BALANCE_CACHE_KEY, JSON.stringify(state.balanceCache));
750
+ _balanceCacheDirty = false;
751
+ } catch (e) {
752
+ console.warn('Failed to save balance cache:', e);
753
+ }
754
+ }
755
+
756
+ function pruneBalanceCache() {
757
+ const now = Date.now();
758
+ let changed = false;
759
+ Object.entries(state.balanceCache).forEach(([key, entry]) => {
760
+ if (!entry || typeof entry.ts !== 'number' || now - entry.ts > BALANCE_CACHE_STALE_MS) {
761
+ delete state.balanceCache[key];
762
+ changed = true;
763
+ }
764
+ });
765
+ if (changed) _balanceCacheDirty = true;
766
+ }
767
+
768
+ function getBalanceCacheKey(coinType, address) {
769
+ return `${coinType}:${address}`;
770
+ }
771
+
772
+ function isNumericBalance(balance) {
773
+ const n = Number.parseFloat(balance);
774
+ return Number.isFinite(n) && n >= 0;
775
+ }
776
+
777
+ function getCachedBalance(coinType, address, { allowStale = false } = {}) {
778
+ const key = getBalanceCacheKey(coinType, address);
779
+ const entry = state.balanceCache[key];
780
+ if (!entry || typeof entry.balance !== 'string' || typeof entry.ts !== 'number') return null;
781
+
782
+ const age = Date.now() - entry.ts;
783
+ if (age <= BALANCE_CACHE_TTL_MS) return { ...entry, stale: false };
784
+ if (allowStale && age <= BALANCE_CACHE_STALE_MS) return { ...entry, stale: true };
785
+ return null;
786
+ }
787
+
788
+ function setCachedBalance(coinType, address, balance) {
789
+ if (!isNumericBalance(balance)) return;
790
+ state.balanceCache[getBalanceCacheKey(coinType, address)] = {
791
+ balance: String(balance),
792
+ ts: Date.now(),
793
+ };
794
+ _balanceCacheDirty = true;
795
+ }
796
+
797
+ function hydrateAccountsFromBalanceCache() {
798
+ let changed = false;
799
+ for (const acct of state.activeAccounts) {
800
+ if (!acct?.address) continue;
801
+ const cached = getCachedBalance(acct.coinType, acct.address, { allowStale: true });
802
+ if (!cached || !isNumericBalance(cached.balance)) continue;
803
+
804
+ const prev = Number.parseFloat(acct.balance);
805
+ const next = Number.parseFloat(cached.balance);
806
+ if (!Number.isFinite(prev) || Math.abs(prev - next) > 1e-12) {
807
+ acct.balance = cached.balance;
808
+ changed = true;
809
+ }
810
+ if (next > 0 && !acct.active) {
811
+ acct.active = true;
812
+ changed = true;
813
+ }
814
+ }
815
+ if (changed) saveActiveAccounts();
816
+ return changed;
817
+ }
818
+
819
+ function isRateLimitError(message) {
820
+ const text = (message || '').toLowerCase();
821
+ return text.includes('429')
822
+ || text.includes('rate')
823
+ || text.includes('limit')
824
+ || text.includes('too many')
825
+ || text.includes('throttl')
826
+ || text.includes('quota');
827
+ }
828
+
829
+ async function waitForScanThrottle() {
830
+ const now = Date.now();
831
+ const elapsed = now - _scanLastRequestAt;
832
+ if (elapsed < SCAN_REQUEST_DELAY_MS) {
833
+ await sleep(SCAN_REQUEST_DELAY_MS - elapsed);
834
+ }
835
+ _scanLastRequestAt = Date.now();
836
+ }
837
+
838
+ function findExistingAccountForTarget(target) {
839
+ return state.activeAccounts.find(a =>
840
+ getAccountWalletId(a) === target.walletId
841
+ && a.coinType === target.coinType
842
+ && a.account === target.account
843
+ && a.index === target.index
844
+ );
845
+ }
846
+
847
+ async function fetchBalanceForScanTarget(target, address) {
848
+ const cooldownUntil = state.balanceRateLimitUntil[target.coinType] || 0;
849
+ if (Date.now() < cooldownUntil) {
850
+ const stale = getCachedBalance(target.coinType, address, { allowStale: true });
851
+ if (stale) {
852
+ return {
853
+ ok: true,
854
+ balance: stale.balance,
855
+ source: 'cache',
856
+ stale: true,
857
+ error: 'Rate-limited; using cached balance',
858
+ };
859
+ }
860
+ return {
861
+ ok: false,
862
+ balance: '--',
863
+ source: 'none',
864
+ stale: false,
865
+ error: 'Rate-limited; retrying later',
866
+ };
867
+ }
868
+
869
+ const fresh = getCachedBalance(target.coinType, address);
870
+ if (fresh) {
871
+ return {
872
+ ok: true,
873
+ balance: fresh.balance,
874
+ source: 'cache',
875
+ stale: false,
876
+ };
877
+ }
878
+
879
+ let lastError = '';
880
+ for (let attempt = 0; attempt <= SCAN_MAX_RETRIES; attempt++) {
881
+ await waitForScanThrottle();
882
+ try {
883
+ const result = await target.fetchBalance(address);
884
+ const balance = result?.balance;
885
+ const error = result?.error;
886
+ if (!error && isNumericBalance(balance)) {
887
+ state.balanceRateLimitUntil[target.coinType] = 0;
888
+ setCachedBalance(target.coinType, address, balance);
889
+ return {
890
+ ok: true,
891
+ balance: String(balance),
892
+ source: 'network',
893
+ stale: false,
894
+ };
895
+ }
896
+
897
+ lastError = error || 'Unknown balance fetch error';
898
+ if (isRateLimitError(lastError)) {
899
+ state.balanceRateLimitUntil[target.coinType] = Date.now() + BALANCE_RATE_LIMIT_COOLDOWN_MS;
900
+ }
901
+ } catch (e) {
902
+ lastError = e?.message || 'Unknown balance fetch exception';
903
+ if (isRateLimitError(lastError)) {
904
+ state.balanceRateLimitUntil[target.coinType] = Date.now() + BALANCE_RATE_LIMIT_COOLDOWN_MS;
905
+ }
906
+ }
907
+
908
+ const retryable = isRateLimitError(lastError) || lastError.length > 0;
909
+ if (attempt < SCAN_MAX_RETRIES && retryable) {
910
+ const delay = SCAN_RETRY_BASE_DELAY_MS * (attempt + 1);
911
+ await sleep(delay);
912
+ continue;
913
+ }
914
+ }
915
+
916
+ const stale = getCachedBalance(target.coinType, address, { allowStale: true });
917
+ if (stale) {
918
+ return {
919
+ ok: true,
920
+ balance: stale.balance,
921
+ source: 'cache',
922
+ stale: true,
923
+ error: lastError,
924
+ };
925
+ }
926
+
927
+ return {
928
+ ok: false,
929
+ balance: '--',
930
+ source: 'none',
931
+ stale: false,
932
+ error: lastError || 'Balance unavailable',
933
+ };
934
+ }
935
+
936
+ function saveActiveAccounts() {
937
+ try {
938
+ const serializable = state.activeAccounts.map(a => ({
939
+ coinType: a.coinType,
940
+ name: a.name,
941
+ account: a.account,
942
+ index: a.index,
943
+ address: a.address,
944
+ balance: a.balance,
945
+ active: a.active,
946
+ path: a.path,
947
+ walletId: a.walletId ?? 0,
948
+ }));
949
+ localStorage.setItem(ACTIVE_ACCOUNTS_KEY, JSON.stringify(serializable));
950
+ } catch (e) {
951
+ console.warn('Failed to save active accounts:', e);
952
+ }
953
+ }
954
+
955
+ function loadActiveAccounts() {
956
+ try {
957
+ const saved = localStorage.getItem(ACTIVE_ACCOUNTS_KEY);
958
+ return saved ? JSON.parse(saved) : [];
959
+ } catch {
960
+ return [];
961
+ }
962
+ }
963
+
964
+ function saveWallets() {
965
+ try {
966
+ localStorage.setItem(WALLETS_KEY, JSON.stringify(state.wallets));
967
+ } catch (e) {
968
+ console.warn('Failed to save wallets:', e);
969
+ }
970
+ }
971
+
972
+ function loadWallets() {
973
+ try {
974
+ const saved = localStorage.getItem(WALLETS_KEY);
975
+ return normalizeWallets(saved ? JSON.parse(saved) : getDefaultWalletState());
976
+ } catch {
977
+ return normalizeWallets(getDefaultWalletState());
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Create a new wallet (Phantom-style: all chains at same account index N).
983
+ * BTC: m/44'/0'/N'/0/0, ETH: m/44'/60'/0'/0/N, SOL: m/44'/501'/N'/0
984
+ */
985
+ function createNewWallet(walletName) {
986
+ if (!state.hdRoot) return;
987
+
988
+ const maxIdx = state.wallets.reduce((m, w) => Math.max(m, w.accountIndex), -1);
989
+ const nextIdx = maxIdx + 1;
990
+ const nextId = state.wallets.reduce((m, w) => Math.max(m, w.id), -1) + 1;
991
+ const name = normalizeWalletName(walletName, nextIdx);
992
+
993
+ const wallet = { id: nextId, name, accountIndex: nextIdx, inactive: false };
994
+ state.wallets.push(wallet);
995
+ saveWallets();
996
+
997
+ // Derive addresses for each chain at the new account index
998
+ const chainDerivations = [
999
+ { coinType: 0, name: 'BTC', account: nextIdx, index: 0 }, // BTC: m/44'/0'/N'/0/0
1000
+ { coinType: 60, name: 'ETH', account: 0, index: nextIdx }, // ETH: m/44'/60'/0'/0/N
1001
+ { coinType: 501, name: 'SOL', account: nextIdx, index: 0 }, // SOL: m/44'/501'/N'/0
1002
+ ];
1003
+
1004
+ for (const cd of chainDerivations) {
1005
+ try {
1006
+ const { address, path } = deriveAddressForPath(cd.coinType, cd.account, cd.index);
1007
+ state.activeAccounts.push({
1008
+ coinType: cd.coinType,
1009
+ name: cd.name,
1010
+ account: cd.account,
1011
+ index: cd.index,
1012
+ address,
1013
+ path,
1014
+ balance: '--',
1015
+ active: false,
1016
+ walletId: nextId,
1017
+ });
1018
+ } catch (e) {
1019
+ console.warn('Failed to derive for new wallet:', cd.name, e);
1020
+ }
1021
+ }
1022
+
1023
+ saveActiveAccounts();
1024
+ state.activeWalletId = nextId;
1025
+ renderAccountsList();
1026
+ renderWalletList();
1027
+ renderWalletSelector();
1028
+ updateCustomPathDefault();
1029
+ }
1030
+
1031
+ function renameWallet(walletId, newName) {
1032
+ const wallet = state.wallets.find(w => w.id === walletId);
1033
+ if (!wallet) return;
1034
+ wallet.name = normalizeWalletName(newName, wallet.accountIndex);
1035
+ saveWallets();
1036
+ renderAccountsList();
1037
+ renderWalletSelector();
1038
+ updateCustomPathWalletLabel();
1039
+ }
1040
+
1041
+ function setWalletInactive(walletId, inactive) {
1042
+ const wallet = getWalletById(walletId);
1043
+ if (!wallet) return;
1044
+
1045
+ if (!inactive && !isWalletInactive(wallet)) return;
1046
+ if (inactive && isWalletInactive(wallet)) return;
1047
+
1048
+ if (inactive && getActiveWallets().length <= 1) {
1049
+ alert('At least one active wallet is required.');
1050
+ return;
1051
+ }
1052
+
1053
+ wallet.inactive = inactive;
1054
+ if (inactive && state.activeWalletId === walletId) {
1055
+ const fallback = getActiveWallets()[0];
1056
+ state.activeWalletId = fallback ? fallback.id : 0;
1057
+ }
1058
+
1059
+ saveWallets();
1060
+ renderWalletList();
1061
+ renderWalletSelector();
1062
+ renderAccountsList();
1063
+ updateCustomPathDefault();
1064
+ updateWalletBondTotal();
1065
+ }
1066
+
1067
+ function setWalletManageTab(tabName) {
1068
+ state.walletManageTab = tabName === 'inactive' ? 'inactive' : 'active';
1069
+
1070
+ const activeBtn = $('wallet-manage-tab-active');
1071
+ const inactiveBtn = $('wallet-manage-tab-inactive');
1072
+ if (activeBtn) activeBtn.classList.toggle('active', state.walletManageTab === 'active');
1073
+ if (inactiveBtn) inactiveBtn.classList.toggle('active', state.walletManageTab === 'inactive');
1074
+
1075
+ renderWalletList();
1076
+ }
1077
+
1078
+ function renderWalletList() {
1079
+ const listEl = $('wallet-list');
1080
+ if (!listEl) return;
1081
+ listEl.innerHTML = '';
1082
+ ensureWalletNamesNormalized();
1083
+
1084
+ const activeWalletCount = getActiveWallets().length;
1085
+ const walletsToRender = state.walletManageTab === 'inactive'
1086
+ ? getInactiveWallets()
1087
+ : getActiveWallets();
1088
+
1089
+ if (walletsToRender.length === 0) {
1090
+ const empty = document.createElement('div');
1091
+ empty.className = 'wallet-manage-empty';
1092
+ empty.textContent = state.walletManageTab === 'inactive'
1093
+ ? 'No inactive wallets.'
1094
+ : 'No active wallets.';
1095
+ listEl.appendChild(empty);
1096
+ return;
1097
+ }
1098
+
1099
+ for (const w of walletsToRender) {
1100
+ const count = state.activeAccounts.filter(a => getAccountWalletId(a) === w.id && isSigningAccountForWallet(a, w)).length;
1101
+ const actionLabel = isWalletInactive(w) ? 'Active' : 'Inactive';
1102
+ const disableAction = !isWalletInactive(w) && activeWalletCount <= 1;
1103
+ const derivationSummary = getWalletDerivationEntries(w)
1104
+ .map(entry => buildSigningPath(entry.coinType, entry.account, entry.index))
1105
+ .join(' • ');
1106
+ const derivationTitle = derivationSummary.replace(/"/g, '&quot;');
1107
+ const row = document.createElement('div');
1108
+ row.className = 'wallet-name-row';
1109
+ row.innerHTML =
1110
+ '<div class="wallet-name-cell">' +
1111
+ '<input class="wallet-name-input glass-input compact" value="' + (w.name || '').replace(/"/g, '&quot;') + '" data-wallet-id="' + w.id + '">' +
1112
+ '<div class="wallet-derivation-path" title="' + derivationTitle + '">' + derivationSummary + '</div>' +
1113
+ '</div>' +
1114
+ '<span class="wallet-account-count">' + count + ' account' + (count !== 1 ? 's' : '') + '</span>' +
1115
+ '<button class="wallet-status-btn glass-btn small' + (disableAction ? ' disabled' : '') + '" data-wallet-id="' + w.id + '" data-target-inactive="' + (!isWalletInactive(w)) + '" ' + (disableAction ? 'disabled' : '') + '>' + actionLabel + '</button>';
1116
+ listEl.appendChild(row);
1117
+ }
1118
+
1119
+ listEl.querySelectorAll('.wallet-name-input').forEach(input => {
1120
+ input.addEventListener('change', (e) => {
1121
+ const id = Number.parseInt(e.target.dataset.walletId, 10);
1122
+ const wallet = getWalletById(id);
1123
+ renameWallet(id, e.target.value.trim() || getDefaultWalletName(wallet ? wallet.accountIndex : id));
1124
+ });
1125
+ });
1126
+
1127
+ listEl.querySelectorAll('.wallet-status-btn').forEach(btn => {
1128
+ btn.addEventListener('click', (e) => {
1129
+ const id = Number.parseInt(e.currentTarget.dataset.walletId, 10);
1130
+ if (Number.isNaN(id)) return;
1131
+ const targetInactive = e.currentTarget.dataset.targetInactive === 'true';
1132
+ setWalletInactive(id, targetInactive);
1133
+ });
1134
+ });
1135
+ }
1136
+
1137
+ // --- Wallet Overlay Navigation ---
1138
+
1139
+ function hideWalletOverlays() {
1140
+ WALLET_OVERLAY_VIEWS.forEach((viewId) => {
1141
+ const view = $(viewId);
1142
+ if (view) view.style.display = 'none';
1143
+ });
1144
+ }
1145
+
1146
+ function showWalletMainView() {
1147
+ const main = $('wallet-main-view');
1148
+ hideWalletOverlays();
1149
+ if (main) main.style.display = 'block';
1150
+ }
1151
+
1152
+ function showWalletView(viewId) {
1153
+ const main = $('wallet-main-view');
1154
+ hideWalletOverlays();
1155
+ if (main) main.style.display = 'none';
1156
+ const view = $(viewId);
1157
+ if (view) view.style.display = viewId === 'wallet-wallets-view' ? 'flex' : 'block';
1158
+ }
1159
+
1160
+ function showWalletsView() {
1161
+ showWalletView('wallet-wallets-view');
1162
+ setWalletManageTab(state.walletManageTab);
1163
+ }
1164
+
1165
+ function showExportView() {
1166
+ showWalletView('wallet-export-view');
1167
+ }
1168
+
1169
+ function showAdvancedView() {
1170
+ showWalletView('wallet-advanced-view');
1171
+ updateCustomPathWalletLabel();
1172
+ updateCustomPathDefault();
1173
+ }
1174
+
1175
+ function showSendView(preselectedIdx) {
1176
+ showWalletView('wallet-send-view');
1177
+ populateSendForm(preselectedIdx);
1178
+ }
1179
+
1180
+ function hideSendView() {
1181
+ showWalletMainView();
1182
+ // Reset form
1183
+ const compose = $('send-compose-step');
1184
+ const review = $('send-review-step');
1185
+ if (compose) compose.style.display = 'block';
1186
+ if (review) review.style.display = 'none';
1187
+ }
1188
+
1189
+ function addCustomPathAccount() {
1190
+ if (!state.hdRoot) return;
1191
+ const chainSelect = $('custom-path-chain');
1192
+ const pathInput = $('custom-path-input');
1193
+ if (!chainSelect || !pathInput) return;
1194
+
1195
+ const coinType = Number.parseInt(chainSelect.value, 10);
1196
+ const pathStr = pathInput.value.trim();
1197
+ if (!pathStr || Number.isNaN(coinType)) return;
1198
+
1199
+ // Parse account/index from path (m/44'/coin'/account'/0/index)
1200
+ const parts = pathStr.replace(/'/g, '').split('/');
1201
+ const account = Number.parseInt(parts[3], 10) || 0;
1202
+ const index = Number.parseInt(parts[5], 10) || 0;
1203
+ const wallet = getCurrentWallet();
1204
+ const walletId = wallet ? wallet.id : 0;
1205
+ if (!wallet) return;
1206
+
1207
+ const proposed = { coinType, account, index, walletId };
1208
+ if (!isSigningAccountForWallet(proposed, wallet)) {
1209
+ alert('Only signing-path accounts are supported for wallet availability.');
1210
+ updateCustomPathDefault();
1211
+ return;
1212
+ }
1213
+
1214
+ const chainName = CHAIN_CONFIG.find(c => c.coinType === coinType)?.name || 'BTC';
1215
+ const exists = state.activeAccounts.some(
1216
+ a => getAccountWalletId(a) === walletId
1217
+ && a.coinType === coinType
1218
+ && a.account === account
1219
+ && a.index === index
1220
+ );
1221
+ if (exists) return;
1222
+
1223
+ try {
1224
+ const { address, path } = deriveAddressForPath(coinType, account, index);
1225
+ state.activeAccounts.push({
1226
+ coinType,
1227
+ name: chainName,
1228
+ account,
1229
+ index,
1230
+ address,
1231
+ path,
1232
+ balance: '--',
1233
+ active: false,
1234
+ walletId,
1235
+ });
1236
+ saveActiveAccounts();
1237
+ renderAccountsList();
1238
+ updateWalletBondTotal();
1239
+ updateCustomPathDefault();
1240
+ } catch (e) {
1241
+ console.warn('Failed to add custom path:', e);
1242
+ }
1243
+ }
1244
+
1245
+ /**
1246
+ * Merge newly scanned accounts with existing ones.
1247
+ * Preserves user's active/inactive choices; updates balances.
1248
+ */
1249
+ function mergeAccounts(existing, scanned) {
1250
+ const key = (a) => `${getAccountWalletId(a)}:${a.coinType}:${a.account}:${a.index}`;
1251
+ const map = new Map();
1252
+
1253
+ // Existing accounts keep their active state
1254
+ for (const a of existing) {
1255
+ map.set(key(a), { ...a, walletId: getAccountWalletId(a) });
1256
+ }
1257
+
1258
+ // Scanned accounts update balances, add new entries
1259
+ for (const a of scanned) {
1260
+ const k = key(a);
1261
+ if (map.has(k)) {
1262
+ const prev = map.get(k);
1263
+ prev.balance = a.balance;
1264
+ prev.address = a.address;
1265
+ prev.path = a.path;
1266
+ prev.name = a.name;
1267
+ prev.walletId = getAccountWalletId(a);
1268
+ } else {
1269
+ map.set(k, { ...a, walletId: getAccountWalletId(a) });
1270
+ }
1271
+ }
1272
+
1273
+ return Array.from(map.values());
1274
+ }
1275
+
1276
+ const CHAIN_CONFIG = [
1277
+ { coinType: 0, name: 'BTC', fetchBalance: fetchBtcBalance },
1278
+ { coinType: 60, name: 'ETH', fetchBalance: fetchEthBalance },
1279
+ { coinType: 501, name: 'SOL', fetchBalance: fetchSolBalance },
1280
+ ];
1281
+
1282
+ async function scanActiveAccounts() {
1283
+ if (!state.hdRoot) return;
1284
+ if (state.scanInProgress) return;
1285
+ state.scanInProgress = true;
1286
+
1287
+ if (!state.balanceCacheLoaded) {
1288
+ state.balanceCache = loadBalanceCache();
1289
+ state.balanceCacheLoaded = true;
1290
+ }
1291
+ pruneBalanceCache();
1292
+ const ensuredAccounts = ensureWalletAccounts();
1293
+ const hydratedFromCache = hydrateAccountsFromBalanceCache();
1294
+ if (ensuredAccounts || hydratedFromCache) {
1295
+ renderAccountsList();
1296
+ }
1297
+ updateWalletBondTotal();
1298
+
1299
+ const statusEl = $('wallet-scan-status');
1300
+ const labelEl = $('wallet-scan-label');
1301
+ const scanBtn = $('wallet-scan-btn');
1302
+ if (statusEl) statusEl.style.display = 'flex';
1303
+ if (scanBtn) scanBtn.disabled = true;
1304
+
1305
+ try {
1306
+ const found = [];
1307
+ const chainByCoinType = new Map(CHAIN_CONFIG.map(chain => [chain.coinType, chain]));
1308
+ const targets = [];
1309
+ const seen = new Set();
1310
+ const addTarget = (coinType, account, index, walletId, name) => {
1311
+ const chain = chainByCoinType.get(coinType);
1312
+ if (!chain) return;
1313
+ const key = `${walletId}:${coinType}:${account}:${index}`;
1314
+ if (seen.has(key)) return;
1315
+ seen.add(key);
1316
+ targets.push({
1317
+ coinType,
1318
+ account,
1319
+ index,
1320
+ walletId,
1321
+ name: name || chain.name,
1322
+ fetchBalance: chain.fetchBalance,
1323
+ });
1324
+ };
1325
+
1326
+ getActiveWallets().forEach((wallet) => {
1327
+ getWalletDerivationEntries(wallet).forEach((entry) => {
1328
+ addTarget(entry.coinType, entry.account, entry.index, wallet.id, entry.name);
1329
+ });
1330
+ });
1331
+
1332
+ for (const target of targets) {
1333
+ if (labelEl) {
1334
+ labelEl.textContent = `Scanning ${target.name} m/44'/${target.coinType}'/${target.account}'/0/${target.index}...`;
1335
+ }
1336
+
1337
+ let derived;
1338
+ try {
1339
+ derived = deriveAddressForPath(target.coinType, target.account, target.index);
1340
+ } catch (deriveErr) {
1341
+ console.warn(`Derivation failed ${target.name} ${target.account}/${target.index}:`, deriveErr);
1342
+ continue;
1343
+ }
1344
+
1345
+ const existing = findExistingAccountForTarget(target);
1346
+ const result = await fetchBalanceForScanTarget(target, derived.address);
1347
+ if (!result.ok) {
1348
+ console.warn(`Balance fetch failed ${target.name} ${target.account}/${target.index}:`, result.error);
1349
+ }
1350
+
1351
+ // Never clobber a known balance with "--" when the network call fails/rate-limits.
1352
+ const resolvedBalance = result.ok
1353
+ ? result.balance
1354
+ : (existing?.balance && existing.balance !== '--' ? existing.balance : '--');
1355
+ const balNum = Number.parseFloat(resolvedBalance);
1356
+
1357
+ found.push({
1358
+ coinType: target.coinType,
1359
+ name: target.name,
1360
+ account: target.account,
1361
+ index: target.index,
1362
+ address: derived.address,
1363
+ path: derived.path,
1364
+ balance: resolvedBalance,
1365
+ active: Number.isFinite(balNum) ? balNum > 0 : (existing?.active || false),
1366
+ walletId: target.walletId,
1367
+ });
1368
+
1369
+ // Surface funded accounts quickly instead of waiting for full scan completion.
1370
+ if (Number.isFinite(balNum) && balNum > 0) {
1371
+ state.activeAccounts = mergeAccounts(state.activeAccounts, [found[found.length - 1]]).filter(isSigningAccount);
1372
+ saveActiveAccounts();
1373
+ renderAccountsList();
1374
+ updateWalletBondTotal();
1375
+ }
1376
+ }
1377
+
1378
+ state.activeAccounts = mergeAccounts(state.activeAccounts, found).filter(isSigningAccount);
1379
+ saveActiveAccounts();
1380
+ saveBalanceCache();
1381
+ renderAccountsList();
1382
+ updateWalletBondTotal();
1383
+ } finally {
1384
+ if (statusEl) statusEl.style.display = 'none';
1385
+ if (scanBtn) scanBtn.disabled = false;
1386
+ state.scanInProgress = false;
1387
+ }
1388
+ }
1389
+
1390
+ const CHAIN_ICONS = {
1391
+ BTC: { color: '#F7931A', symbol: '\u20BF' },
1392
+ ETH: { color: '#627EEA', symbol: '\u039E' },
1393
+ SOL: { color: '#9945FF', symbol: 'S' },
1394
+ };
1395
+
1396
+ const CHAIN_FULL_NAMES = {
1397
+ BTC: 'Bitcoin',
1398
+ ETH: 'Ethereum',
1399
+ SOL: 'Solana',
1400
+ };
1401
+
1402
+ function getVisibleWalletEntries() {
1403
+ const wallet = getCurrentWallet();
1404
+ if (!wallet) return [];
1405
+ const chainOrder = { BTC: 0, ETH: 1, SOL: 2 };
1406
+ const entries = state.activeAccounts
1407
+ .map((acct, idx) => ({ acct, idx, walletId: getAccountWalletId(acct) }))
1408
+ .filter(entry => entry.walletId === wallet.id && isSigningAccountForWallet(entry.acct, wallet));
1409
+
1410
+ entries.sort((a, b) => {
1411
+ const chainDelta = (chainOrder[a.acct.name] ?? 99) - (chainOrder[b.acct.name] ?? 99);
1412
+ if (chainDelta !== 0) return chainDelta;
1413
+ const accountDelta = (a.acct.account ?? 0) - (b.acct.account ?? 0);
1414
+ if (accountDelta !== 0) return accountDelta;
1415
+ return (a.acct.index ?? 0) - (b.acct.index ?? 0);
1416
+ });
1417
+ return entries;
1418
+ }
1419
+
1420
+ function getWalletAccountForChain(chainName) {
1421
+ const matches = getVisibleWalletEntries()
1422
+ .map(entry => entry.acct)
1423
+ .filter(acct => acct.name === chainName);
1424
+ if (matches.length === 0) return null;
1425
+
1426
+ const funded = matches.filter(acct => (Number.parseFloat(acct.balance) || 0) > 0);
1427
+ const activeFunded = funded.find(acct => acct.active);
1428
+ if (activeFunded) return activeFunded;
1429
+ if (funded.length > 0) return funded[0];
1430
+
1431
+ const active = matches.find(acct => acct.active);
1432
+ if (active) return active;
1433
+ return matches[0];
1434
+ }
1435
+
1436
+ function updateWalletActionMenus() {
1437
+ ['BTC', 'ETH', 'SOL'].forEach((chain) => {
1438
+ const available = Boolean(getWalletAccountForChain(chain));
1439
+ $qa(`.ph-action-menu-item[data-chain="${chain}"]`).forEach((btn) => {
1440
+ btn.disabled = !available;
1441
+ btn.title = available ? '' : `No ${chain} account in this wallet`;
1442
+ });
1443
+ });
1444
+ }
1445
+
1446
+ function closeWalletActionMenus() {
1447
+ $('wallet-send-menu')?.classList.remove('visible');
1448
+ $('wallet-receive-menu')?.classList.remove('visible');
1449
+ }
1450
+
1451
+ function renderAccountsList() {
1452
+ const listEl = $('wallet-accounts-list');
1453
+ const emptyEl = $('wallet-accounts-empty');
1454
+ if (!listEl) return;
1455
+
1456
+ // Clear all dynamic content (wallet headers + token rows)
1457
+ listEl.querySelectorAll('.ph-token-row, .ph-wallet-header').forEach(r => r.remove());
1458
+ const entries = getVisibleWalletEntries();
1459
+ if (entries.length === 0) {
1460
+ if (emptyEl) emptyEl.style.display = 'flex';
1461
+ const emptySub = emptyEl?.querySelector('.ph-token-empty-sub');
1462
+ if (emptySub) {
1463
+ const wallet = getCurrentWallet();
1464
+ emptySub.textContent = wallet
1465
+ ? `No accounts yet for ${wallet.name}. Tap Scan or add one from Advanced.`
1466
+ : 'No accounts yet.';
1467
+ }
1468
+ updateWalletActionMenus();
1469
+ return;
1470
+ }
1471
+
1472
+ if (emptyEl) emptyEl.style.display = 'none';
1473
+
1474
+ const pricesPromise = fetchCryptoPrices(getSelectedCurrency()).catch(() => null);
1475
+
1476
+ for (const { acct, idx } of entries) {
1477
+ const row = document.createElement('div');
1478
+ row.className = 'ph-token-row' + (acct.active ? '' : ' ph-token-inactive');
1479
+ row.dataset.idx = idx;
1480
+
1481
+ const bal = parseFloat(acct.balance);
1482
+ const balDisplay = isNaN(bal) ? acct.balance : (bal > 0 ? bal.toFixed(bal < 0.001 ? 8 : 4) : '0');
1483
+ const icon = CHAIN_ICONS[acct.name] || { color: '#888', symbol: '?' };
1484
+ const fullName = CHAIN_FULL_NAMES[acct.name] || acct.name;
1485
+ const pathLabel = acct.path || "m/44'/" + acct.coinType + "'/" + acct.account + "'/0/" + acct.index;
1486
+
1487
+ row.innerHTML =
1488
+ '<div class="ph-token-icon" style="background:' + icon.color + '">' + icon.symbol + '</div>' +
1489
+ '<div class="ph-token-info">' +
1490
+ '<div class="ph-token-name">' + fullName + '</div>' +
1491
+ '<div class="ph-token-path">' + pathLabel + '</div>' +
1492
+ '</div>' +
1493
+ '<div class="ph-token-amounts">' +
1494
+ '<div class="ph-token-balance">' + balDisplay + ' ' + acct.name + '</div>' +
1495
+ '<div class="ph-token-fiat" id="ph-fiat-' + idx + '"></div>' +
1496
+ '</div>';
1497
+
1498
+ row.addEventListener('click', () => {
1499
+ showReceiveModal(acct);
1500
+ });
1501
+
1502
+ listEl.appendChild(row);
1503
+ }
1504
+ updateWalletActionMenus();
1505
+
1506
+ pricesPromise.then(prices => {
1507
+ if (!prices) return;
1508
+ const currency = getSelectedCurrency();
1509
+ entries.forEach(({ acct, idx }) => {
1510
+ const bal = parseFloat(acct.balance) || 0;
1511
+ const priceKey = acct.name.toUpperCase();
1512
+ const fiatVal = bal * (prices[priceKey] || 0);
1513
+ const el = $('ph-fiat-' + idx);
1514
+ if (el) el.textContent = fiatVal > 0 ? formatCurrencyValue(fiatVal, currency) : '';
1515
+ });
1516
+ });
1517
+ }
1518
+
1519
+ function toggleAccountActive(idx) {
1520
+ if (idx < 0 || idx >= state.activeAccounts.length) return;
1521
+ state.activeAccounts[idx].active = !state.activeAccounts[idx].active;
1522
+ saveActiveAccounts();
1523
+ renderAccountsList();
1524
+ }
1525
+
1526
+ async function handleAccountAction(action, idx) {
1527
+ const acct = state.activeAccounts[idx];
1528
+ if (!acct) return;
1529
+
1530
+ switch (action) {
1531
+ case 'receive':
1532
+ showReceiveModal(acct);
1533
+ break;
1534
+ case 'copy':
1535
+ try {
1536
+ await navigator.clipboard.writeText(acct.address);
1537
+ } catch {}
1538
+ break;
1539
+ case 'toggle':
1540
+ toggleAccountActive(idx);
1541
+ break;
1542
+ case 'remove':
1543
+ state.activeAccounts.splice(idx, 1);
1544
+ saveActiveAccounts();
1545
+ renderAccountsList();
1546
+ break;
1547
+ }
1548
+ }
1549
+
1550
+ async function showReceiveModal(acct) {
1551
+ // Create a simple receive overlay
1552
+ let overlay = $('wallet-receive-overlay');
1553
+ if (!overlay) {
1554
+ overlay = document.createElement('div');
1555
+ overlay.id = 'wallet-receive-overlay';
1556
+ overlay.className = 'wallet-receive-overlay';
1557
+ overlay.innerHTML = `
1558
+ <div class="wallet-receive-card">
1559
+ <h4 id="wallet-receive-title" class="section-label"></h4>
1560
+ <canvas id="wallet-receive-qr"></canvas>
1561
+ <code id="wallet-receive-address" class="wallet-receive-address"></code>
1562
+ <div class="wallet-receive-actions">
1563
+ <button id="wallet-receive-copy" class="glass-btn small">Copy</button>
1564
+ <button id="wallet-receive-close" class="glass-btn small">Close</button>
1565
+ </div>
1566
+ </div>
1567
+ `;
1568
+ $('wallet-tab-content')?.appendChild(overlay);
1569
+ }
1570
+
1571
+ const titleEl = overlay.querySelector('#wallet-receive-title');
1572
+ const addrEl = overlay.querySelector('#wallet-receive-address');
1573
+ if (titleEl) titleEl.textContent = `Receive ${acct.name}`;
1574
+ if (addrEl) addrEl.textContent = acct.address;
1575
+
1576
+ try {
1577
+ const qrCanvas = overlay.querySelector('#wallet-receive-qr');
1578
+ if (qrCanvas) {
1579
+ await QRCode.toCanvas(qrCanvas, acct.address, {
1580
+ width: 180,
1581
+ margin: 2,
1582
+ color: { dark: '#1e293b', light: '#ffffff' },
1583
+ });
1584
+ }
1585
+ } catch (e) {
1586
+ console.warn('QR generation failed:', e);
1587
+ }
1588
+
1589
+ overlay.style.display = 'flex';
1590
+
1591
+ overlay.querySelector('#wallet-receive-copy')?.addEventListener('click', () => {
1592
+ navigator.clipboard.writeText(acct.address).catch(() => {});
1593
+ }, { once: true });
1594
+
1595
+ overlay.querySelector('#wallet-receive-close')?.addEventListener('click', () => {
1596
+ overlay.style.display = 'none';
1597
+ }, { once: true });
1598
+ }
1599
+
1600
+ // =============================================================================
1601
+ // Send Flow
1602
+ // =============================================================================
1603
+
1604
+ function populateSendForm(preselectedIdx) {
1605
+ const select = $('send-from-account');
1606
+ if (!select) return;
1607
+ select.innerHTML = '';
1608
+
1609
+ const walletEntries = getVisibleWalletEntries();
1610
+ const walletAccounts = walletEntries.map(entry => entry.acct);
1611
+ const activeAccts = walletAccounts.filter(a => a.active || parseFloat(a.balance) > 0);
1612
+ const accts = activeAccts.length > 0 ? [...activeAccts] : [...walletAccounts];
1613
+ const preselectedAcct = typeof preselectedIdx === 'number' ? state.activeAccounts[preselectedIdx] : null;
1614
+ if (preselectedAcct && walletAccounts.includes(preselectedAcct) && !accts.includes(preselectedAcct)) {
1615
+ accts.unshift(preselectedAcct);
1616
+ }
1617
+
1618
+ accts.forEach((acct) => {
1619
+ const opt = document.createElement('option');
1620
+ const origIdx = state.activeAccounts.indexOf(acct);
1621
+ opt.value = origIdx;
1622
+ const bal = parseFloat(acct.balance);
1623
+ const balStr = isNaN(bal) ? '' : (' — ' + bal.toFixed(bal < 0.001 ? 8 : 4) + ' ' + acct.name);
1624
+ opt.textContent = acct.name + ' ' + truncateAddress(acct.address) + balStr;
1625
+ select.appendChild(opt);
1626
+ });
1627
+
1628
+ if (typeof preselectedIdx === 'number') {
1629
+ select.value = String(preselectedIdx);
1630
+ }
1631
+
1632
+ if (select.options.length > 0) {
1633
+ updateSendFromSelection();
1634
+ } else {
1635
+ const balEl = $('send-available-balance');
1636
+ const labelEl = $('send-currency-label');
1637
+ if (balEl) balEl.textContent = '--';
1638
+ if (labelEl) labelEl.textContent = '--';
1639
+ }
1640
+
1641
+ // Reset review step
1642
+ const compose = $('send-compose-step');
1643
+ const review = $('send-review-step');
1644
+ if (compose) compose.style.display = 'block';
1645
+ if (review) review.style.display = 'none';
1646
+ const statusEl = $('send-status');
1647
+ if (statusEl) statusEl.style.display = 'none';
1648
+
1649
+ // Clear inputs
1650
+ const toAddr = $('send-to-address');
1651
+ const amount = $('send-amount');
1652
+ if (toAddr) toAddr.value = '';
1653
+ if (amount) amount.value = '';
1654
+ const fiatEst = $('send-fiat-estimate');
1655
+ if (fiatEst) fiatEst.textContent = '';
1656
+ const reviewBtn = $('send-review-btn');
1657
+ if (reviewBtn) reviewBtn.disabled = true;
1658
+ }
1659
+
1660
+ function updateSendFromSelection() {
1661
+ const select = $('send-from-account');
1662
+ if (!select) return;
1663
+ const idx = parseInt(select.value);
1664
+ const acct = state.activeAccounts[idx];
1665
+ if (!acct) return;
1666
+
1667
+ const balEl = $('send-available-balance');
1668
+ const labelEl = $('send-currency-label');
1669
+ if (balEl) {
1670
+ const bal = parseFloat(acct.balance);
1671
+ balEl.textContent = (isNaN(bal) ? acct.balance : bal.toFixed(bal < 0.001 ? 8 : 4)) + ' ' + acct.name;
1672
+ }
1673
+ if (labelEl) labelEl.textContent = acct.name;
1674
+ }
1675
+
1676
+ function validateSendForm() {
1677
+ const select = $('send-from-account');
1678
+ const toAddr = $('send-to-address');
1679
+ const amount = $('send-amount');
1680
+ const reviewBtn = $('send-review-btn');
1681
+ if (!select || !toAddr || !amount || !reviewBtn) return;
1682
+
1683
+ const idx = parseInt(select.value);
1684
+ const acct = state.activeAccounts[idx];
1685
+ const addr = toAddr.value.trim();
1686
+ const amt = parseFloat(amount.value);
1687
+
1688
+ reviewBtn.disabled = !(acct && addr.length > 10 && amt > 0);
1689
+ }
1690
+
1691
+ function showSendReview() {
1692
+ const select = $('send-from-account');
1693
+ const toAddr = $('send-to-address');
1694
+ const amount = $('send-amount');
1695
+ if (!select || !toAddr || !amount) return;
1696
+
1697
+ const idx = parseInt(select.value);
1698
+ const acct = state.activeAccounts[idx];
1699
+ if (!acct) return;
1700
+
1701
+ const amt = parseFloat(amount.value);
1702
+ const fee = acct.name === 'BTC' ? 0.0001 : (acct.name === 'ETH' ? 0.002 : 0.000005);
1703
+
1704
+ const reviewTo = $('send-review-to');
1705
+ const reviewAmt = $('send-review-amount');
1706
+ const reviewFee = $('send-review-fee');
1707
+ const reviewTotal = $('send-review-total');
1708
+
1709
+ if (reviewTo) reviewTo.textContent = toAddr.value.trim();
1710
+ if (reviewAmt) reviewAmt.textContent = amt.toFixed(amt < 0.001 ? 8 : 4) + ' ' + acct.name;
1711
+ if (reviewFee) reviewFee.textContent = '~' + fee + ' ' + acct.name;
1712
+ if (reviewTotal) reviewTotal.textContent = (amt + fee).toFixed(8) + ' ' + acct.name;
1713
+
1714
+ const compose = $('send-compose-step');
1715
+ const review = $('send-review-step');
1716
+ if (compose) compose.style.display = 'none';
1717
+ if (review) review.style.display = 'block';
1718
+ }
1719
+
1720
+ async function executeSend() {
1721
+ const select = $('send-from-account');
1722
+ const toAddr = $('send-to-address');
1723
+ const amount = $('send-amount');
1724
+ const statusEl = $('send-status');
1725
+ const confirmBtn = $('send-confirm-btn');
1726
+
1727
+ if (!select || !toAddr || !amount) return;
1728
+
1729
+ const idx = parseInt(select.value);
1730
+ const acct = state.activeAccounts[idx];
1731
+ if (!acct) return;
1732
+
1733
+ const to = toAddr.value.trim();
1734
+ const amt = parseFloat(amount.value);
1735
+
1736
+ if (statusEl) {
1737
+ statusEl.style.display = 'block';
1738
+ statusEl.className = 'send-status send-status-pending';
1739
+ statusEl.textContent = 'Broadcasting transaction...';
1740
+ }
1741
+ if (confirmBtn) confirmBtn.disabled = true;
1742
+
1743
+ try {
1744
+ let txHash;
1745
+
1746
+ if (acct.coinType === 0) {
1747
+ txHash = await sendBtcTransaction(acct, to, amt);
1748
+ } else if (acct.coinType === 60) {
1749
+ txHash = await sendEthTransaction(acct, to, amt);
1750
+ } else if (acct.coinType === 501) {
1751
+ txHash = await sendSolTransaction(acct, to, amt);
1752
+ } else {
1753
+ throw new Error('Unsupported chain: ' + acct.name);
1754
+ }
1755
+
1756
+ if (statusEl) {
1757
+ statusEl.className = 'send-status send-status-success';
1758
+ statusEl.innerHTML = 'Transaction sent! Hash: <code class="truncate">' + (txHash || 'pending') + '</code>';
1759
+ }
1760
+
1761
+ // Refresh balances after a short delay
1762
+ setTimeout(() => {
1763
+ scanActiveAccounts();
1764
+ }, 5000);
1765
+ } catch (e) {
1766
+ console.error('Send failed:', e);
1767
+ if (statusEl) {
1768
+ statusEl.className = 'send-status send-status-error';
1769
+ statusEl.textContent = 'Failed: ' + (e.message || 'Unknown error');
1770
+ }
1771
+ } finally {
1772
+ if (confirmBtn) confirmBtn.disabled = false;
1773
+ }
1774
+ }
1775
+
1776
+ // --- Per-chain transaction construction ---
1777
+
1778
+ async function sendBtcTransaction(acct, toAddress, amountBtc) {
1779
+ const module = state.hdWalletModule;
1780
+ if (!module?.bitcoin?.tx) throw new Error('Bitcoin tx builder not available');
1781
+
1782
+ // Fetch UTXOs
1783
+ const utxoResp = await fetch(apiUrl('https://blockchain.info/unspent?active=' + acct.address));
1784
+ if (!utxoResp.ok) throw new Error('Failed to fetch UTXOs (address may have no unspent outputs)');
1785
+ const utxoData = await utxoResp.json();
1786
+ const utxos = utxoData.unspent_outputs || [];
1787
+ if (utxos.length === 0) throw new Error('No UTXOs available');
1788
+
1789
+ const amountSats = BigInt(Math.round(amountBtc * 1e8));
1790
+ const feeSats = BigInt(10000); // ~0.0001 BTC flat fee estimate
1791
+ const totalNeeded = amountSats + feeSats;
1792
+
1793
+ // Select UTXOs (simple greedy)
1794
+ let inputSum = BigInt(0);
1795
+ const selectedUtxos = [];
1796
+ for (const utxo of utxos) {
1797
+ selectedUtxos.push(utxo);
1798
+ inputSum += BigInt(utxo.value);
1799
+ if (inputSum >= totalNeeded) break;
1800
+ }
1801
+ if (inputSum < totalNeeded) throw new Error('Insufficient funds');
1802
+
1803
+ // Build transaction
1804
+ const tx = module.bitcoin.tx.create();
1805
+ for (const utxo of selectedUtxos) {
1806
+ tx.addInput(utxo.tx_hash_big_endian, utxo.tx_output_n);
1807
+ }
1808
+ tx.addOutput(toAddress, amountSats);
1809
+
1810
+ // Change output
1811
+ const change = inputSum - amountSats - feeSats;
1812
+ if (change > BigInt(546)) { // dust threshold
1813
+ tx.addOutput(acct.address, change);
1814
+ }
1815
+
1816
+ // Sign each input
1817
+ const path = acct.path || buildSigningPath(acct.coinType, acct.account, acct.index);
1818
+ const derived = state.hdRoot.derivePath(path);
1819
+ const privKey = derived.privateKey();
1820
+ for (let i = 0; i < selectedUtxos.length; i++) {
1821
+ tx.sign(i, privKey);
1822
+ }
1823
+
1824
+ const rawTx = tx.serialize();
1825
+ const hexTx = Array.from(rawTx).map(b => b.toString(16).padStart(2, '0')).join('');
1826
+
1827
+ // Broadcast
1828
+ const broadcastResp = await fetch(apiUrl('https://blockchain.info/pushtx'), {
1829
+ method: 'POST',
1830
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1831
+ body: 'tx=' + hexTx,
1832
+ });
1833
+ if (!broadcastResp.ok) {
1834
+ const errText = await broadcastResp.text();
1835
+ throw new Error('Broadcast failed: ' + errText);
1836
+ }
1837
+
1838
+ return tx.getTxid();
1839
+ }
1840
+
1841
+ async function sendEthTransaction(acct, toAddress, amountEth) {
1842
+ const module = state.hdWalletModule;
1843
+ if (!module?.ethereum?.tx) throw new Error('Ethereum tx builder not available');
1844
+
1845
+ const ETH_RPC = 'https://cloudflare-eth.com';
1846
+ const rpc = async (method, params) => {
1847
+ const resp = await fetch(ETH_RPC, {
1848
+ method: 'POST',
1849
+ headers: { 'Content-Type': 'application/json' },
1850
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
1851
+ });
1852
+ const data = await resp.json();
1853
+ if (data.error) throw new Error(data.error.message);
1854
+ return data.result;
1855
+ };
1856
+
1857
+ // Get nonce, gas price, estimate gas
1858
+ const [nonceHex, baseFeeBlock] = await Promise.all([
1859
+ rpc('eth_getTransactionCount', [acct.address, 'latest']),
1860
+ rpc('eth_getBlockByNumber', ['latest', false]),
1861
+ ]);
1862
+
1863
+ const nonce = parseInt(nonceHex, 16);
1864
+ const baseFee = BigInt(baseFeeBlock.baseFeePerGas || '0x0');
1865
+ const maxPriorityFee = BigInt(2000000000); // 2 gwei
1866
+ const maxFee = baseFee * BigInt(2) + maxPriorityFee;
1867
+ const gasLimit = BigInt(21000);
1868
+
1869
+ // Convert ETH to wei
1870
+ const weiStr = BigInt(Math.round(amountEth * 1e18));
1871
+
1872
+ const tx = module.ethereum.tx.createEIP1559({
1873
+ nonce,
1874
+ maxFeePerGas: maxFee,
1875
+ maxPriorityFeePerGas: maxPriorityFee,
1876
+ gasLimit,
1877
+ to: toAddress,
1878
+ value: weiStr,
1879
+ chainId: 1,
1880
+ });
1881
+
1882
+ // Sign
1883
+ const path = acct.path || buildSigningPath(acct.coinType, acct.account, acct.index);
1884
+ const derived = state.hdRoot.derivePath(path);
1885
+ const privKey = derived.privateKey();
1886
+ tx.sign(privKey);
1887
+
1888
+ const rawTx = tx.serialize();
1889
+ const hexTx = '0x' + Array.from(rawTx).map(b => b.toString(16).padStart(2, '0')).join('');
1890
+
1891
+ // Broadcast
1892
+ const txHash = await rpc('eth_sendRawTransaction', [hexTx]);
1893
+ return txHash;
1894
+ }
1895
+
1896
+ async function sendSolTransaction(acct, toAddress, amountSol) {
1897
+ // Solana transfer via RPC (no WASM builder — manual SystemProgram.transfer)
1898
+ const SOL_ENDPOINTS = [
1899
+ 'https://api.mainnet-beta.solana.com',
1900
+ 'https://solana-mainnet.g.alchemy.com/v2/demo',
1901
+ 'https://rpc.ankr.com/solana',
1902
+ ];
1903
+
1904
+ const rpc = async (method, params) => {
1905
+ for (const endpoint of SOL_ENDPOINTS) {
1906
+ try {
1907
+ const resp = await fetch(endpoint, {
1908
+ method: 'POST',
1909
+ headers: { 'Content-Type': 'application/json' },
1910
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
1911
+ });
1912
+ const data = await resp.json();
1913
+ if (data.error) throw new Error(data.error.message);
1914
+ return data.result;
1915
+ } catch (e) {
1916
+ continue;
1917
+ }
1918
+ }
1919
+ throw new Error('All Solana RPC endpoints failed');
1920
+ };
1921
+
1922
+ // For Solana, we need @solana/web3.js which may not be available
1923
+ // Try dynamic import, otherwise fail gracefully
1924
+ throw new Error('Solana send requires @solana/web3.js (not yet integrated). Use a Solana wallet to send SOL.');
1925
+ }
1926
+
1927
+ async function updateSendFiatEstimate() {
1928
+ const select = $('send-from-account');
1929
+ const amount = $('send-amount');
1930
+ const fiatEst = $('send-fiat-estimate');
1931
+ if (!select || !amount || !fiatEst) return;
1932
+
1933
+ const idx = parseInt(select.value);
1934
+ const acct = state.activeAccounts[idx];
1935
+ if (!acct) return;
1936
+
1937
+ const amt = parseFloat(amount.value) || 0;
1938
+ if (amt <= 0) { fiatEst.textContent = ''; return; }
1939
+
1940
+ try {
1941
+ const currency = getSelectedCurrency();
1942
+ const prices = await fetchCryptoPrices(currency);
1943
+ const price = prices[acct.name.toUpperCase()] || 0;
1944
+ const fiat = amt * price;
1945
+ fiatEst.textContent = fiat > 0 ? '~ ' + formatCurrencyValue(fiat, currency) : '';
1946
+ } catch {
1947
+ fiatEst.textContent = '';
1948
+ }
1949
+ }
1950
+
1951
+ async function updateWalletBondTotal() {
1952
+ const valueEl = $('wallet-bond-value');
1953
+
1954
+ try {
1955
+ const currency = getSelectedCurrency();
1956
+ const prices = await fetchCryptoPrices(currency);
1957
+
1958
+ let total = 0;
1959
+ const walletTotals = {};
1960
+ let hasPositiveBalance = false;
1961
+ let missingPriceForFundedAccount = false;
1962
+ for (const acct of state.activeAccounts.filter(isSigningAccount)) {
1963
+ const bal = Number.parseFloat(acct.balance);
1964
+ if (!Number.isFinite(bal) || bal <= 0) continue;
1965
+ hasPositiveBalance = true;
1966
+
1967
+ const priceKey = acct.name.toUpperCase();
1968
+ const price = Number.parseFloat(prices[priceKey]);
1969
+ if (!Number.isFinite(price) || price <= 0) {
1970
+ missingPriceForFundedAccount = true;
1971
+ continue;
1972
+ }
1973
+
1974
+ const fiatValue = bal * price;
1975
+ total += fiatValue;
1976
+ const walletId = getAccountWalletId(acct);
1977
+ walletTotals[walletId] = (walletTotals[walletId] || 0) + fiatValue;
1978
+ }
1979
+
1980
+ if (hasPositiveBalance && total <= 0 && missingPriceForFundedAccount) {
1981
+ throw new Error('Funded accounts found but fiat pricing is unavailable');
1982
+ }
1983
+
1984
+ state.walletFiatTotals = walletTotals;
1985
+ state.walletFiatCurrency = currency;
1986
+
1987
+ const formatted = formatCurrencyValue(total, currency);
1988
+ if (valueEl) valueEl.textContent = formatted;
1989
+ renderWalletSelector();
1990
+
1991
+ // Also update the header bond total
1992
+ const accountTotalEl = $('account-total-value');
1993
+ if (accountTotalEl) {
1994
+ accountTotalEl.textContent = 'Bond: ' + formatted;
1995
+ }
1996
+ } catch (e) {
1997
+ console.warn('Bond total calculation failed:', e);
1998
+ // Keep last known totals if pricing endpoint is temporarily unavailable.
1999
+ const cachedTotals = state.walletFiatTotals || {};
2000
+ const cachedTotal = Object.values(cachedTotals).reduce((sum, v) => sum + (Number.isFinite(v) ? v : 0), 0);
2001
+ if (cachedTotal > 0) {
2002
+ const displayCurrency = state.walletFiatCurrency || getSelectedCurrency();
2003
+ const formatted = formatCurrencyValue(cachedTotal, displayCurrency);
2004
+ if (valueEl) valueEl.textContent = formatted;
2005
+ const accountTotalEl = $('account-total-value');
2006
+ if (accountTotalEl) accountTotalEl.textContent = 'Bond: ' + formatted;
2007
+ } else if (valueEl && !valueEl.textContent) {
2008
+ valueEl.textContent = '$0.00';
2009
+ }
2010
+ renderWalletSelector();
2011
+ }
2012
+ }
2013
+
2014
+
457
2015
  async function deriveAndDisplayAddress() {
458
2016
  console.log('deriveAndDisplayAddress called, hdRoot:', !!state.hdRoot);
459
2017
 
@@ -851,9 +2409,10 @@ function login(keys) {
851
2409
  if (xpubEl) {
852
2410
  setTruncatedValue(xpubEl, state.hdRoot.toXpub() || 'N/A');
853
2411
  }
854
- const keysXpubEl = $('keys-xpub');
855
- if (keysXpubEl) {
856
- setTruncatedValue(keysXpubEl, state.hdRoot.toXpub() || 'N/A');
2412
+ // Populate wallet tab xpub display
2413
+ const walletTabXpubEl = $('wallet-tab-xpub');
2414
+ if (walletTabXpubEl) {
2415
+ setTruncatedValue(walletTabXpubEl, state.hdRoot.toXpub() || 'N/A');
857
2416
  }
858
2417
  populateAccountAddressDropdown();
859
2418
  if (xprvEl) {
@@ -866,6 +2425,22 @@ function login(keys) {
866
2425
  } else if (seedEl) {
867
2426
  seedEl.textContent = 'Not available (derived from password)';
868
2427
  }
2428
+
2429
+ // Load persisted wallets and active accounts
2430
+ state.wallets = loadWallets();
2431
+ state.activeAccounts = normalizeActiveAccounts(loadActiveAccounts());
2432
+ const currentWallet = getCurrentWallet() || getActiveWallets()[0] || state.wallets[0];
2433
+ state.activeWalletId = currentWallet ? currentWallet.id : 0;
2434
+ ensureWalletAccounts();
2435
+ state.activeAccounts = state.activeAccounts.filter(isSigningAccount);
2436
+ saveActiveAccounts();
2437
+ saveWallets();
2438
+ renderAccountsList();
2439
+ renderWalletSelector();
2440
+ updateCustomPathDefault();
2441
+
2442
+ // Auto-scan for funded accounts in the background
2443
+ scanActiveAccounts().catch(e => console.warn('Auto-scan failed:', e));
869
2444
  }
870
2445
 
871
2446
  // Derive PKI keys from HD wallet if available
@@ -908,12 +2483,8 @@ function login(keys) {
908
2483
  // Update wallet addresses and balances
909
2484
  updateAdversarialSecurity();
910
2485
 
911
- // Populate vCard keys display
912
- populateVCardKeysDisplay();
913
-
914
2486
  // Open Account modal so user can see the wallet they just loaded
915
2487
  $('keys-modal')?.classList.add('active');
916
- deriveAndDisplayAddress();
917
2488
 
918
2489
  // Resolve names and update title
919
2490
  clearNameCache();
@@ -1000,7 +2571,7 @@ async function exportWallet(format) {
1000
2571
  break;
1001
2572
 
1002
2573
  case 'xpub':
1003
- if (!state.hdRoot?.publicExtendedKey) {
2574
+ if (!state.hdRoot?.toXpub) {
1004
2575
  alert('Extended public key not available.');
1005
2576
  return;
1006
2577
  }
@@ -1010,7 +2581,7 @@ async function exportWallet(format) {
1010
2581
  break;
1011
2582
 
1012
2583
  case 'xprv':
1013
- if (!state.hdRoot?.privateExtendedKey) {
2584
+ if (!state.hdRoot?.toXprv) {
1014
2585
  alert('Extended private key not available.');
1015
2586
  return;
1016
2587
  }
@@ -1060,104 +2631,26 @@ function downloadData(data, filename, mimeType) {
1060
2631
  // Wallet Address Population & Balance Fetching
1061
2632
  // =============================================================================
1062
2633
 
1063
- // Account address dropdown populated once after login, updated when balances arrive
1064
- let _accountAddressData = {}; // { xpub: { addr, value }, btc: { addr, value }, ... }
1065
-
2634
+ // Account headershow xpub only
1066
2635
  function populateAccountAddressDropdown() {
1067
- const sel = $('account-address-select');
1068
- if (!sel) return;
2636
+ const addrEl = $('account-address-display');
2637
+ if (!addrEl) return;
1069
2638
 
1070
2639
  const xpubStr = state.hdRoot ? state.hdRoot.toXpub() : '';
1071
- const addrs = state.addresses || {};
1072
-
1073
- const networks = [
1074
- { key: 'xpub', label: 'xpub', addr: xpubStr },
1075
- { key: 'btc', label: 'Bitcoin', addr: addrs.btc || '' },
1076
- { key: 'eth', label: 'Ethereum', addr: addrs.eth || '' },
1077
- { key: 'sol', label: 'Solana', addr: addrs.sol || '' },
1078
- // { key: 'xrp', label: 'Ripple', addr: addrs.xrp || '' },
1079
- ];
1080
-
1081
- // // Add SUI/Monad/ADA if we can derive them
1082
- // if (state.hdRoot) {
1083
- // try {
1084
- // const suiPath = buildSigningPath(784, 0, 0);
1085
- // const suiDerived = state.hdRoot.derivePath(suiPath);
1086
- // const suiPubKey = ed25519.getPublicKey(suiDerived.privateKey());
1087
- // networks.push({ key: 'sui', label: 'SUI', addr: deriveSuiAddress(suiPubKey, 'ed25519') });
1088
- // } catch (_) {}
1089
- // networks.push({ key: 'monad', label: 'Monad', addr: addrs.eth || '' });
1090
- // try {
1091
- // const adaPath = buildSigningPath(1815, 0, 0);
1092
- // const adaDerived = state.hdRoot.derivePath(adaPath);
1093
- // const adaPubKey = ed25519.getPublicKey(adaDerived.privateKey());
1094
- // networks.push({ key: 'ada', label: 'Cardano', addr: deriveCardanoAddress(adaPubKey) });
1095
- // } catch (_) {}
1096
- // }
1097
-
1098
- _accountAddressData = {};
1099
- sel.innerHTML = '';
1100
- for (const n of networks) {
1101
- if (!n.addr) continue;
1102
- _accountAddressData[n.key] = { addr: n.addr, value: '' };
1103
- const opt = document.createElement('option');
1104
- opt.value = n.key;
1105
- opt.textContent = n.label;
1106
- sel.appendChild(opt);
1107
- }
1108
-
1109
- sel.removeEventListener('change', updateAccountAddressDisplay);
1110
- sel.addEventListener('change', updateAccountAddressDisplay);
2640
+ addrEl.textContent = `${xpubStr.slice(0,10)}...${xpubStr.slice(-10)}`;
2641
+ addrEl.title = xpubStr;
1111
2642
 
1112
2643
  const copyBtn = $('account-address-copy');
1113
2644
  if (copyBtn) {
1114
2645
  copyBtn.onclick = () => {
1115
- const key = sel.value;
1116
- const data = _accountAddressData[key];
1117
- if (data?.addr) {
1118
- navigator.clipboard.writeText(data.addr).then(() => {
2646
+ if (xpubStr) {
2647
+ navigator.clipboard.writeText(xpubStr).then(() => {
1119
2648
  copyBtn.title = 'Copied!';
1120
- setTimeout(() => { copyBtn.title = 'Copy address'; }, 1500);
2649
+ setTimeout(() => { copyBtn.title = 'Copy xpub'; }, 1500);
1121
2650
  });
1122
2651
  }
1123
2652
  };
1124
2653
  }
1125
-
1126
- updateAccountAddressDisplay();
1127
- }
1128
-
1129
- function updateAccountAddressDisplay() {
1130
- const sel = $('account-address-select');
1131
- const addrEl = $('account-address-display');
1132
- const valEl = $('account-address-value');
1133
- if (!sel || !addrEl) return;
1134
-
1135
- const key = sel.value;
1136
- const data = _accountAddressData[key];
1137
- if (!data) return;
1138
-
1139
- const addr = data.addr;
1140
- addrEl.textContent = addr;
1141
- addrEl.title = addr;
1142
- if (valEl) valEl.textContent = data.value || (key !== 'xpub' ? '$0.00' : '');
1143
- }
1144
-
1145
- function updateAccountAddressValues(bondBalances, prices, currency) {
1146
- const symbol = CURRENCY_SYMBOLS[currency] || currency;
1147
- const keyToSymbol = { btc: 'BTC', eth: 'ETH', sol: 'SOL' };
1148
-
1149
- for (const [key, data] of Object.entries(_accountAddressData)) {
1150
- if (key === 'xpub') {
1151
- data.value = '';
1152
- continue;
1153
- }
1154
- const sym = keyToSymbol[key];
1155
- const bal = parseFloat(bondBalances[key]) || 0;
1156
- const price = (prices && sym) ? (prices[sym] || 0) : 0;
1157
- const converted = bal * price;
1158
- data.value = converted > 0 ? symbol + converted.toFixed(2) : bal > 0 ? bal.toFixed(6) + ' ' + (sym || '') : '';
1159
- }
1160
- updateAccountAddressDisplay();
1161
2654
  }
1162
2655
 
1163
2656
  function populateWalletAddresses() {
@@ -1258,8 +2751,33 @@ const CURRENCY_SYMBOLS = {
1258
2751
 
1259
2752
  const CURRENCY_OPTIONS = Object.keys(CURRENCY_SYMBOLS);
1260
2753
 
2754
+ const PRICE_CACHE_KEY = 'hd-wallet-price-cache-v1';
2755
+ const PRICE_CACHE_TTL_MS = 60 * 1000;
2756
+ const PRICE_CACHE_STALE_MS = 30 * 60 * 1000;
1261
2757
  let priceCache = { data: null, currency: null, timestamp: 0 };
1262
2758
 
2759
+ function loadStoredPriceCache() {
2760
+ try {
2761
+ const raw = localStorage.getItem(PRICE_CACHE_KEY);
2762
+ if (!raw) return null;
2763
+ const parsed = JSON.parse(raw);
2764
+ if (!parsed || typeof parsed !== 'object') return null;
2765
+ if (typeof parsed.currency !== 'string' || typeof parsed.timestamp !== 'number') return null;
2766
+ if (!parsed.data || typeof parsed.data !== 'object') return null;
2767
+ return parsed;
2768
+ } catch {
2769
+ return null;
2770
+ }
2771
+ }
2772
+
2773
+ function saveStoredPriceCache(entry) {
2774
+ try {
2775
+ localStorage.setItem(PRICE_CACHE_KEY, JSON.stringify(entry));
2776
+ } catch (e) {
2777
+ console.warn('Failed to save price cache:', e);
2778
+ }
2779
+ }
2780
+
1263
2781
  function getSelectedCurrency() {
1264
2782
  return localStorage.getItem('bond-currency') || 'USD';
1265
2783
  }
@@ -1270,12 +2788,23 @@ function setSelectedCurrency(currency) {
1270
2788
 
1271
2789
  async function fetchCryptoPrices(currency) {
1272
2790
  const now = Date.now();
1273
- if (priceCache.data && priceCache.currency === currency && now - priceCache.timestamp < 60000) {
2791
+ if (priceCache.data && priceCache.currency === currency && now - priceCache.timestamp < PRICE_CACHE_TTL_MS) {
1274
2792
  return priceCache.data;
1275
2793
  }
1276
2794
 
2795
+ const stored = loadStoredPriceCache();
2796
+ if (stored && stored.currency === currency && now - stored.timestamp < PRICE_CACHE_TTL_MS) {
2797
+ priceCache = { data: stored.data, currency: stored.currency, timestamp: stored.timestamp };
2798
+ return stored.data;
2799
+ }
2800
+
1277
2801
  const cryptos = ['BTC', 'ETH', 'SOL'];
1278
2802
  const prices = {};
2803
+ const setPrice = (symbol, rawValue) => {
2804
+ const value = Number.parseFloat(rawValue);
2805
+ if (!Number.isFinite(value) || value <= 0) return;
2806
+ prices[symbol] = value;
2807
+ };
1279
2808
 
1280
2809
  if (currency === 'BTC') {
1281
2810
  // For BTC denomination, fetch each crypto's price in BTC
@@ -1285,31 +2814,70 @@ async function fetchCryptoPrices(currency) {
1285
2814
  others.map(async (crypto) => {
1286
2815
  const url = apiUrl(`https://api.coinbase.com/v2/exchange-rates?currency=${crypto}`);
1287
2816
  const res = await fetch(url);
2817
+ if (!res.ok) throw new Error(`Coinbase HTTP ${res.status}`);
1288
2818
  const json = await res.json();
1289
- return { crypto, rate: parseFloat(json.data?.rates?.BTC) || 0 };
2819
+ return { crypto, rate: json.data?.rates?.BTC };
1290
2820
  })
1291
2821
  );
1292
2822
  results.forEach(r => {
1293
- if (r.status === 'fulfilled') prices[r.value.crypto] = r.value.rate;
2823
+ if (r.status === 'fulfilled') setPrice(r.value.crypto, r.value.rate);
1294
2824
  });
1295
- // prices.MONAD = 0; // Testnet token, no market price
1296
2825
  } else {
1297
- // Fetch exchange rates with USD as base, then convert
2826
+ // Fetch fiat spot prices for each supported chain coin.
1298
2827
  const results = await Promise.allSettled(
1299
2828
  cryptos.map(async (crypto) => {
1300
2829
  const url = apiUrl(`https://api.coinbase.com/v2/prices/${crypto}-${currency}/spot`);
1301
2830
  const res = await fetch(url);
2831
+ if (!res.ok) throw new Error(`Coinbase HTTP ${res.status}`);
1302
2832
  const json = await res.json();
1303
- return { crypto, price: parseFloat(json.data?.amount) || 0 };
2833
+ return { crypto, price: json.data?.amount };
1304
2834
  })
1305
2835
  );
1306
2836
  results.forEach(r => {
1307
- if (r.status === 'fulfilled') prices[r.value.crypto] = r.value.price;
2837
+ if (r.status === 'fulfilled') setPrice(r.value.crypto, r.value.price);
1308
2838
  });
1309
- // prices.MONAD = 0;
2839
+ }
2840
+
2841
+ // Secondary provider fallback when Coinbase is unavailable/rate-limited.
2842
+ if (Object.keys(prices).length < cryptos.length) {
2843
+ const missing = cryptos.filter(symbol => !Number.isFinite(prices[symbol]) || prices[symbol] <= 0);
2844
+ if (missing.length > 0) {
2845
+ try {
2846
+ const vs = currency.toLowerCase();
2847
+ const geckoUrl = apiUrl(`https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=${vs}`);
2848
+ const geckoRes = await fetch(geckoUrl);
2849
+ if (geckoRes.ok) {
2850
+ const gecko = await geckoRes.json();
2851
+ const idBySymbol = { BTC: 'bitcoin', ETH: 'ethereum', SOL: 'solana' };
2852
+ missing.forEach((symbol) => {
2853
+ const id = idBySymbol[symbol];
2854
+ if (!id) return;
2855
+ setPrice(symbol, gecko?.[id]?.[vs]);
2856
+ });
2857
+ }
2858
+ } catch (e) {
2859
+ console.warn('CoinGecko price fallback failed:', e);
2860
+ }
2861
+ }
2862
+ }
2863
+
2864
+ const hasAnyPrice = cryptos.some(symbol => Number.isFinite(prices[symbol]) && prices[symbol] > 0);
2865
+ if (!hasAnyPrice) {
2866
+ const staleSources = [priceCache, stored].filter(entry =>
2867
+ entry?.data
2868
+ && entry.currency === currency
2869
+ && now - entry.timestamp < PRICE_CACHE_STALE_MS
2870
+ );
2871
+ if (staleSources.length > 0) {
2872
+ const stale = staleSources[0];
2873
+ priceCache = { data: stale.data, currency: stale.currency, timestamp: stale.timestamp };
2874
+ return stale.data;
2875
+ }
2876
+ throw new Error(`No ${currency} prices available`);
1310
2877
  }
1311
2878
 
1312
2879
  priceCache = { data: prices, currency, timestamp: now };
2880
+ saveStoredPriceCache(priceCache);
1313
2881
  return prices;
1314
2882
  }
1315
2883
 
@@ -1495,142 +3063,11 @@ function initCurrencySelector() {
1495
3063
  // =============================================================================
1496
3064
 
1497
3065
  async function updateAdversarialSecurity() {
1498
- const loginRequired = $('adversarial-login-required');
1499
- const balancesSection = $('adversarial-balances');
1500
-
1501
3066
  const hasWallet = state.wallet && (state.wallet.secp256k1 || state.wallet.ed25519);
3067
+ if (!hasWallet) return;
1502
3068
 
1503
- if (!hasWallet) {
1504
- if (loginRequired) loginRequired.style.display = 'block';
1505
- if (balancesSection) balancesSection.style.display = 'none';
1506
- const trustNote = $('trust-note');
1507
- if (trustNote) trustNote.textContent = 'Login to derive addresses and check balances.';
1508
- return;
1509
- }
1510
-
1511
- if (loginRequired) loginRequired.style.display = 'none';
1512
- if (balancesSection) balancesSection.style.display = 'block';
1513
-
1514
- populateWalletAddresses();
1515
-
1516
- const btcAddress = state.addresses?.btc;
1517
- const ethAddress = state.addresses?.eth;
1518
- const solAddress = state.addresses?.sol;
1519
-
1520
- // let suiAddress = null;
1521
- // let adaAddress = null;
1522
- // if (state.hdRoot) {
1523
- // try {
1524
- // const suiPath = buildSigningPath(784, 0, 0);
1525
- // const suiDerived = state.hdRoot.derivePath(suiPath);
1526
- // const suiPubKey = ed25519.getPublicKey(suiDerived.privateKey());
1527
- // suiAddress = deriveSuiAddress(suiPubKey, 'ed25519');
1528
- // } catch (e) { console.error('SUI derivation error:', e); }
1529
-
1530
- // try {
1531
- // const adaPath = buildSigningPath(1815, 0, 0);
1532
- // const adaDerived = state.hdRoot.derivePath(adaPath);
1533
- // const adaPubKey = ed25519.getPublicKey(adaDerived.privateKey());
1534
- // adaAddress = deriveCardanoAddress(adaPubKey);
1535
- // } catch (e) { console.error('ADA derivation error:', e); }
1536
- // }
1537
-
1538
- // const monadAddress = ethAddress;
1539
- // const xrpAddress = state.addresses?.xrp;
1540
-
1541
- // Set loading state
1542
- const networks = ['btc', 'eth', 'sol'];
1543
- networks.forEach(net => {
1544
- const balEl = $(`wallet-${net}-balance`);
1545
- if (balEl) balEl.textContent = '...';
1546
- });
1547
- const trustNote = $('trust-note');
1548
- if (trustNote) trustNote.textContent = 'Fetching balances from blockchain...';
1549
-
1550
- const fetchResults = await Promise.allSettled([
1551
- btcAddress ? fetchBtcBalance(btcAddress) : Promise.resolve({ balance: '0' }),
1552
- ethAddress ? fetchEthBalance(ethAddress) : Promise.resolve({ balance: '0' }),
1553
- solAddress ? fetchSolBalance(solAddress) : Promise.resolve({ balance: '0' }),
1554
- // suiAddress ? fetchSuiBalance(suiAddress) : Promise.resolve({ balance: '0' }),
1555
- // monadAddress ? fetchMonadBalance(monadAddress) : Promise.resolve({ balance: '0' }),
1556
- // adaAddress ? fetchAdaBalance(adaAddress) : Promise.resolve({ balance: '0' }),
1557
- // xrpAddress ? fetchXrpBalance(xrpAddress) : Promise.resolve({ balance: '0' }),
1558
- ]);
1559
-
1560
- const [btcResult, ethResult, solResult] = fetchResults.map(
1561
- r => r.status === 'fulfilled' ? r.value : { balance: '0' }
1562
- );
1563
-
1564
- const updateBalance = (network, balance, decimals = 4) => {
1565
- const balEl = $(`wallet-${network}-balance`);
1566
- if (balEl) {
1567
- const val = parseFloat(balance) || 0;
1568
- balEl.textContent = val > 0 ? val.toFixed(val < 0.0001 ? 8 : decimals) : '0';
1569
- }
1570
-
1571
- const card = $(`wallet-${network}-card`);
1572
- if (card) {
1573
- const hasBalance = parseFloat(balance) > 0;
1574
- card.classList.toggle('has-balance', hasBalance);
1575
- card.classList.toggle('secure', hasBalance);
1576
- }
1577
- };
1578
-
1579
- updateBalance('btc', btcResult.balance, 8);
1580
- updateBalance('eth', ethResult.balance, 6);
1581
- updateBalance('sol', solResult.balance, 6);
1582
- // updateBalance('sui', suiResult.balance, 4);
1583
- // updateBalance('monad', monadResult.balance, 4);
1584
- // updateBalance('ada', adaResult.balance, 6);
1585
- // updateBalance('xrp', xrpResult.balance, 6);
1586
-
1587
- // Update bond tab per-network balances
1588
- const bondBalances = {
1589
- btc: btcResult.balance, eth: ethResult.balance, sol: solResult.balance,
1590
- // sui: suiResult.balance, monad: monadResult.balance, ada: adaResult.balance,
1591
- // xrp: xrpResult.balance,
1592
- };
1593
- Object.entries(bondBalances).forEach(([net, bal]) => {
1594
- const el = $(`bond-${net}-balance`);
1595
- const card = $(`bond-${net}-card`);
1596
- const val = parseFloat(bal) || 0;
1597
- if (el) el.textContent = val > 0 ? val.toFixed(val < 0.0001 ? 8 : 4) : '0';
1598
- if (card) card.classList.toggle('has-balance', val > 0);
1599
- });
1600
-
1601
- // Convert to selected currency
1602
- const currency = getSelectedCurrency();
1603
- let totalConverted = 0;
1604
- let cryptoPrices = null;
1605
-
1606
- try {
1607
- cryptoPrices = await fetchCryptoPrices(currency);
1608
- const prices = cryptoPrices;
1609
- const balances = {
1610
- BTC: parseFloat(btcResult.balance) || 0,
1611
- ETH: parseFloat(ethResult.balance) || 0,
1612
- SOL: parseFloat(solResult.balance) || 0,
1613
- // SUI: parseFloat(suiResult.balance) || 0,
1614
- // MONAD: parseFloat(monadResult.balance) || 0,
1615
- // ADA: parseFloat(adaResult.balance) || 0,
1616
- // XRP: parseFloat(xrpResult.balance) || 0,
1617
- };
1618
-
1619
- for (const [crypto, bal] of Object.entries(balances)) {
1620
- totalConverted += bal * (prices[crypto] || 0);
1621
- }
1622
- } catch (e) {
1623
- console.warn('Price conversion failed:', e);
1624
- }
1625
-
1626
- // Update account header total value
1627
- const accountTotalEl = $('account-total-value');
1628
- if (accountTotalEl) {
1629
- accountTotalEl.textContent = 'Bond: ' + formatCurrencyValue(totalConverted, currency);
1630
- }
1631
-
1632
- // Update account address dropdown values
1633
- updateAccountAddressValues(bondBalances, cryptoPrices, currency);
3069
+ // Wallet tab bond total is now updated by scanActiveAccounts/updateWalletBondTotal
3070
+ updateWalletBondTotal();
1634
3071
  }
1635
3072
 
1636
3073
  // =============================================================================
@@ -1638,7 +3075,7 @@ async function updateAdversarialSecurity() {
1638
3075
  // =============================================================================
1639
3076
 
1640
3077
  function generateVCard(info, { skipPhoto = false } = {}) {
1641
- const person = {};
3078
+ const person = { KEY: [] };
1642
3079
 
1643
3080
  if (info.firstName || info.lastName) {
1644
3081
  if (info.lastName) person.FAMILY_NAME = info.lastName;
@@ -1648,8 +3085,15 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1648
3085
  if (info.suffix) person.HONORIFIC_SUFFIX = info.suffix;
1649
3086
  }
1650
3087
 
3088
+ const contacts = [];
1651
3089
  if (info.email) {
1652
- person.CONTACT_POINT = [{ EMAIL: info.email }];
3090
+ contacts.push({ EMAIL: info.email, CONTACT_TYPE: 'HOME' });
3091
+ }
3092
+ if (info.phone) {
3093
+ contacts.push({ TELEPHONE: info.phone, CONTACT_TYPE: 'CELL' });
3094
+ }
3095
+ if (contacts.length) {
3096
+ person.CONTACT_POINT = contacts;
1653
3097
  }
1654
3098
 
1655
3099
  if (info.org) {
@@ -1664,34 +3108,57 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1664
3108
  person.IMAGE = state.vcardPhoto;
1665
3109
  }
1666
3110
 
1667
- if (info.includeKeys && state.wallet.x25519) {
3111
+ if (info.includeKeys && state.wallet?.x25519) {
1668
3112
  person.KEY = [
1669
- ...(state.hdRoot?.publicExtendedKey ? [{
1670
- KEY_TYPE: 'xpub',
1671
- PUBLIC_KEY: state.hdRoot.toXpub(),
3113
+ // Always include xPub
3114
+ ...(state.hdRoot?.toXpub ? [{
3115
+ XPUB: state.hdRoot.toXpub(),
3116
+ LABEL: '',
1672
3117
  }] : []),
3118
+ // X25519 encryption key
1673
3119
  {
1674
- KEY_TYPE: 'X25519',
1675
3120
  PUBLIC_KEY: toBase64(state.wallet.x25519.publicKey),
3121
+ LABEL: 'X25519',
1676
3122
  },
1677
- {
1678
- KEY_TYPE: 'Ed25519',
1679
- PUBLIC_KEY: toBase64(state.wallet.ed25519.publicKey),
1680
- },
1681
- {
1682
- KEY_TYPE: 'secp256k1',
1683
- PUBLIC_KEY: toBase64(state.wallet.secp256k1.publicKey),
1684
- CRYPTO_ADDRESS: state.addresses.btc || undefined,
1685
- },
3123
+ // Active accounts from wallet scan
3124
+ ...state.activeAccounts
3125
+ .filter(a => a.active && isSigningAccount(a))
3126
+ .flatMap(a => {
3127
+ const entries = [];
3128
+ const pathLabel = a.path || `m/44'/${a.coinType}'/${a.account}'/0/${a.index}`;
3129
+ try {
3130
+ const { publicKey } = deriveAddressForPath(a.coinType, a.account, a.index);
3131
+ const curve = a.coinType === 501 ? 'Ed25519' : 'secp256k1';
3132
+ entries.push({
3133
+ PUBLIC_KEY: toBase64(publicKey),
3134
+ LABEL: `${curve} ${pathLabel}`,
3135
+ });
3136
+ } catch {}
3137
+ if (a.address) {
3138
+ entries.push({
3139
+ KEY_ADDRESS: a.address,
3140
+ LABEL: pathLabel,
3141
+ });
3142
+ }
3143
+ return entries;
3144
+ }),
1686
3145
  ];
3146
+ } else if (info.xpubOnly && state.hdRoot?.toXpub) {
3147
+ person.KEY = [{ XPUB: state.hdRoot.toXpub(), LABEL: '' }];
1687
3148
  }
1688
3149
 
1689
3150
  const note = info.includeKeys
1690
- ? 'Generated by HD Wallet UI'
3151
+ ? 'Generated by Space Data Network'
1691
3152
  : undefined;
1692
3153
 
1693
3154
  let vcard = createV3(person, note);
1694
3155
 
3156
+ // Add NICKNAME field (not supported by createV3)
3157
+ if (info.nickname) {
3158
+ vcard = vcard.replace('END:VCARD', `NICKNAME:${info.nickname}\nEND:VCARD`);
3159
+ }
3160
+
3161
+
1695
3162
  // Convert PHOTO from data URI format to iOS-compatible inline base64 format
1696
3163
  vcard = vcard.replace(
1697
3164
  /PHOTO;VALUE=URI:data:image\/(\w+);base64,([^\n]+)\n/,
@@ -1709,127 +3176,126 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1709
3176
  }
1710
3177
 
1711
3178
  // =============================================================================
1712
- // vCard Keys Display
3179
+ // vCard Digital Signature (Ed25519)
1713
3180
  // =============================================================================
1714
3181
 
1715
- function populateVCardKeysDisplay() {
1716
- const keysDisplay = $('vcard-keys-display');
1717
- if (!keysDisplay) return;
3182
+ function getSignableBody(vcardText) {
3183
+ const lines = vcardText.split('\n');
3184
+ const sigItems = new Set();
3185
+ for (const line of lines) {
3186
+ const m = line.match(/^item(\d+)\.X-ABLabel:Digital Signature/);
3187
+ if (m) sigItems.add(m[1]);
3188
+ }
3189
+ return lines.filter(line => {
3190
+ if (line.trim() === 'END:VCARD') return false;
3191
+ for (const n of sigItems) {
3192
+ if (line.startsWith(`item${n}.`)) return false;
3193
+ }
3194
+ return true;
3195
+ }).join('\n') + '\n';
3196
+ }
3197
+
3198
+ function signVCard(vcardText) {
3199
+ if (!state.wallet?.ed25519?.privateKey) return vcardText;
3200
+
3201
+ const body = getSignableBody(vcardText);
3202
+ const messageBytes = new TextEncoder().encode(body);
3203
+ const signature = ed25519.sign(messageBytes, state.wallet.ed25519.privateKey);
3204
+ const sigB64 = toBase64(signature);
3205
+
3206
+ // Encode signature + derivation path (coinType=501, account=0, index=0)
3207
+ const sigValue = `${sigB64}:501:0:0`;
3208
+
3209
+ // Find highest itemN and key index
3210
+ let maxItem = 0;
3211
+ let maxKeyIdx = 0;
3212
+ const itemRe = /item(\d+)\./g;
3213
+ const keyIdxRe = /#(\d+)/g;
3214
+ let match;
3215
+ while ((match = itemRe.exec(vcardText)) !== null) {
3216
+ maxItem = Math.max(maxItem, parseInt(match[1], 10));
3217
+ }
3218
+ while ((match = keyIdxRe.exec(vcardText)) !== null) {
3219
+ maxKeyIdx = Math.max(maxKeyIdx, parseInt(match[1], 10));
3220
+ }
3221
+
3222
+ const sigLines =
3223
+ `item${maxItem + 1}.X-ABLabel:Digital Signature #${maxKeyIdx + 1}\n` +
3224
+ `item${maxItem + 1}.X-ABRELATEDNAMES:${sigValue}\n`;
1718
3225
 
1719
- const keys = [];
3226
+ return body + sigLines + 'END:VCARD';
3227
+ }
1720
3228
 
1721
- // Bitcoin signing key
1722
- if (state.addresses.btc) {
1723
- keys.push({
1724
- label: 'Bitcoin Signing',
1725
- curve: 'secp256k1',
1726
- address: state.addresses.btc,
1727
- pubkey: state.wallet.secp256k1 ? toHex(state.wallet.secp256k1.publicKey) : '—',
1728
- path: buildSigningPath(0, 0, 0), // m/44'/0'/0'/0'/0'
1729
- role: 'signing',
1730
- explorer: `https://blockstream.info/address/${state.addresses.btc}`,
1731
- });
3229
+ function verifyVCardSignature(vcardText) {
3230
+ // Parse all itemN label/value pairs
3231
+ const lines = vcardText.split('\n');
3232
+ const items = {};
3233
+ for (const line of lines) {
3234
+ const labelMatch = line.match(/^item(\d+)\.X-ABLabel:(.+)/);
3235
+ if (labelMatch) {
3236
+ items[labelMatch[1]] = items[labelMatch[1]] || {};
3237
+ items[labelMatch[1]].label = labelMatch[2].trim();
3238
+ }
3239
+ const valueMatch = line.match(/^item(\d+)\.X-ABRELATEDNAMES:(.+)/);
3240
+ if (valueMatch) {
3241
+ items[valueMatch[1]] = items[valueMatch[1]] || {};
3242
+ items[valueMatch[1]].value = valueMatch[2].trim();
3243
+ }
1732
3244
  }
1733
3245
 
1734
- // Ethereum signing key
1735
- if (state.addresses.eth) {
1736
- keys.push({
1737
- label: 'Ethereum Signing',
1738
- curve: 'secp256k1',
1739
- address: state.addresses.eth,
1740
- pubkey: state.wallet.secp256k1 ? toHex(state.wallet.secp256k1.publicKey) : '—',
1741
- path: buildSigningPath(60, 0, 0), // m/44'/60'/0'/0'/0'
1742
- role: 'signing',
1743
- explorer: `https://etherscan.io/address/${state.addresses.eth}`,
1744
- });
3246
+ // Find Digital Signature entry
3247
+ let sigValue = null;
3248
+ for (const item of Object.values(items)) {
3249
+ if (item.label?.startsWith('Digital Signature') && item.value) {
3250
+ sigValue = item.value;
3251
+ }
1745
3252
  }
1746
3253
 
1747
- // Solana signing key
1748
- if (state.addresses.sol) {
1749
- keys.push({
1750
- label: 'Solana Signing',
1751
- curve: 'Ed25519',
1752
- address: state.addresses.sol,
1753
- pubkey: state.wallet.ed25519 ? toHex(state.wallet.ed25519.publicKey) : '—',
1754
- path: buildSigningPath(501, 0, 0), // m/44'/501'/0'/0'
1755
- role: 'signing',
1756
- explorer: `https://explorer.solana.com/address/${state.addresses.sol}`,
1757
- });
3254
+ if (!sigValue) {
3255
+ return { verified: false, path: null, publicKey: null, error: 'unsigned' };
1758
3256
  }
1759
3257
 
1760
- // P-256 encryption key
1761
- if (state.wallet.p256) {
1762
- keys.push({
1763
- label: 'Encryption Key',
1764
- curve: 'P-256 (NIST)',
1765
- address: '—',
1766
- pubkey: toHex(state.wallet.p256.publicKey),
1767
- path: buildEncryptionPath(0, 0, 0), // m/44'/0'/0'/1'/0'
1768
- role: 'encryption',
1769
- explorer: null,
1770
- });
3258
+ // Parse signature value: base64sig:coinType:account:index
3259
+ const parts = sigValue.split(':');
3260
+ if (parts.length < 4) {
3261
+ return { verified: false, path: null, publicKey: null, error: 'Malformed signature' };
3262
+ }
3263
+ const sigB64 = parts[0];
3264
+ const coinType = parts[1];
3265
+ const account = parts[2];
3266
+ const index = parts[3];
3267
+ const path = `m/44'/${coinType}'/${account}'/0/${index}`;
3268
+
3269
+ // Find Ed25519 public key — look for "Public Key" entries with 32-byte (44-char base64) values
3270
+ let ed25519PubB64 = null;
3271
+ for (const item of Object.values(items)) {
3272
+ if (item.label?.startsWith('Public Key') && item.value) {
3273
+ try {
3274
+ const decoded = Uint8Array.from(atob(item.value), c => c.charCodeAt(0));
3275
+ if (decoded.length === 32) {
3276
+ ed25519PubB64 = item.value;
3277
+ break;
3278
+ }
3279
+ } catch { /* not base64 */ }
3280
+ }
1771
3281
  }
1772
3282
 
1773
- // Clear and populate
1774
- keysDisplay.innerHTML = '';
3283
+ if (!ed25519PubB64) {
3284
+ return { verified: false, path, publicKey: null, error: 'No Ed25519 public key found' };
3285
+ }
1775
3286
 
1776
- keys.forEach(key => {
1777
- const keyCard = document.createElement('div');
1778
- keyCard.className = 'key-display-card';
1779
- keyCard.innerHTML = `
1780
- <div class="key-display-header">
1781
- <span class="key-display-label">${key.label}</span>
1782
- <span class="key-display-badge ${key.role}">${key.role}</span>
1783
- </div>
1784
- <div class="key-display-row">
1785
- <span class="key-display-field">Curve</span>
1786
- <code class="key-display-value">${key.curve}</code>
1787
- </div>
1788
- <div class="key-display-row">
1789
- <span class="key-display-field">Public Key</span>
1790
- <code class="key-display-value truncate" title="${key.pubkey}">${truncateAddress(key.pubkey, 16)}</code>
1791
- <button class="copy-btn-small" data-copy-text="${key.pubkey}" title="Copy">
1792
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1793
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
1794
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
1795
- </svg>
1796
- </button>
1797
- </div>
1798
- ${key.address !== '—' ? `
1799
- <div class="key-display-row">
1800
- <span class="key-display-field">Address</span>
1801
- <code class="key-display-value truncate" title="${key.address}">${truncateAddress(key.address, 16)}</code>
1802
- ${key.explorer ? `<a href="${key.explorer}" target="_blank" rel="noopener" class="explorer-link-small" title="View on Explorer">
1803
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1804
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
1805
- <polyline points="15 3 21 3 21 9"/>
1806
- <line x1="10" y1="14" x2="21" y2="3"/>
1807
- </svg>
1808
- </a>` : ''}
1809
- </div>
1810
- ` : ''}
1811
- <div class="key-display-row">
1812
- <span class="key-display-field">Derivation Path</span>
1813
- <code class="key-display-value">${key.path}</code>
1814
- </div>
1815
- `;
1816
- keysDisplay.appendChild(keyCard);
1817
- });
3287
+ // Reconstruct signable body and verify
3288
+ const body = getSignableBody(vcardText);
3289
+ const messageBytes = new TextEncoder().encode(body);
3290
+ const sigBytes = Uint8Array.from(atob(sigB64), c => c.charCodeAt(0));
3291
+ const pubKeyBytes = Uint8Array.from(atob(ed25519PubB64), c => c.charCodeAt(0));
1818
3292
 
1819
- // Add copy button event listeners
1820
- keysDisplay.querySelectorAll('.copy-btn-small').forEach(btn => {
1821
- btn.addEventListener('click', async () => {
1822
- const text = btn.getAttribute('data-copy-text');
1823
- try {
1824
- await navigator.clipboard.writeText(text);
1825
- const originalHTML = btn.innerHTML;
1826
- btn.innerHTML = '✓';
1827
- setTimeout(() => { btn.innerHTML = originalHTML; }, 1000);
1828
- } catch (err) {
1829
- console.error('Copy failed:', err);
1830
- }
1831
- });
1832
- });
3293
+ try {
3294
+ const valid = ed25519.verify(sigBytes, messageBytes, pubKeyBytes);
3295
+ return { verified: valid, path, publicKey: ed25519PubB64, error: valid ? null : 'Signature invalid' };
3296
+ } catch (e) {
3297
+ return { verified: false, path, publicKey: ed25519PubB64, error: e.message };
3298
+ }
1833
3299
  }
1834
3300
 
1835
3301
  function parseAndDisplayVCF(vcfText) {
@@ -1913,6 +3379,23 @@ function parseAndDisplayVCF(vcfText) {
1913
3379
  html += '</div>';
1914
3380
  }
1915
3381
 
3382
+ // Verify digital signature
3383
+ const sigStatus = $('vcf-import-sig-status');
3384
+ if (sigStatus) {
3385
+ const result = verifyVCardSignature(vcfText);
3386
+ if (result.error === 'unsigned') {
3387
+ sigStatus.className = 'vcard-sig-badge sig-unsigned';
3388
+ sigStatus.innerHTML = 'No signature';
3389
+ } else if (result.verified) {
3390
+ sigStatus.className = 'vcard-sig-badge sig-verified';
3391
+ sigStatus.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Verified (${result.path})`;
3392
+ } else {
3393
+ sigStatus.className = 'vcard-sig-badge sig-invalid';
3394
+ sigStatus.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> Invalid signature`;
3395
+ }
3396
+ sigStatus.style.display = 'flex';
3397
+ }
3398
+
1916
3399
  fieldsEl.innerHTML = html;
1917
3400
  resultEl.style.display = 'block';
1918
3401
  }
@@ -2404,7 +3887,6 @@ function setupMainAppHandlers() {
2404
3887
  $('nav-logout')?.addEventListener('click', logout);
2405
3888
  $('nav-keys')?.addEventListener('click', async () => {
2406
3889
  $('keys-modal')?.classList.add('active');
2407
- deriveAndDisplayAddress();
2408
3890
  if (state.loggedIn) {
2409
3891
  const names = await resolveNames();
2410
3892
  updateAccountTitle(names);
@@ -2431,11 +3913,55 @@ function setupMainAppHandlers() {
2431
3913
  });
2432
3914
  });
2433
3915
 
3916
+ // Messaging sub-tab switching
3917
+ $qa('.messaging-sub-tab').forEach(tab => {
3918
+ tab.addEventListener('click', () => {
3919
+ $qa('.messaging-sub-tab').forEach(t => t.classList.remove('active'));
3920
+ $qa('.messaging-sub-content').forEach(c => c.classList.remove('active'));
3921
+ tab.classList.add('active');
3922
+ const target = $(tab.dataset.messagingSub);
3923
+ if (target) target.classList.add('active');
3924
+ });
3925
+ });
3926
+
3927
+ // Identity card summary — updates the read-only card display from form fields
3928
+ function updateIdentityCardSummary() {
3929
+ const prefix = $('vcard-prefix')?.value || '';
3930
+ const first = $('vcard-firstname')?.value || '';
3931
+ const middle = $('vcard-middlename')?.value || '';
3932
+ const last = $('vcard-lastname')?.value || '';
3933
+ const suffix = $('vcard-suffix')?.value || '';
3934
+ const nick = $('vcard-nickname')?.value || '';
3935
+ const parts = [prefix, first, middle, last, suffix].filter(Boolean);
3936
+ const nameEl = $('identity-card-name');
3937
+ if (nameEl) {
3938
+ const namePart = parts.length > 0 ? parts.join(' ') : '--';
3939
+ if (nick) {
3940
+ nameEl.innerHTML = `${namePart} <span class="nickname">(${nick})</span>`;
3941
+ } else {
3942
+ nameEl.textContent = namePart;
3943
+ }
3944
+ }
3945
+
3946
+ const titleEl = $('identity-card-title');
3947
+ if (titleEl) titleEl.textContent = $('vcard-title')?.value || '';
3948
+
3949
+ const orgEl = $('identity-card-org');
3950
+ if (orgEl) orgEl.textContent = $('vcard-org')?.value || '';
3951
+
3952
+ const emailEl = $('identity-card-email');
3953
+ if (emailEl) emailEl.textContent = $('vcard-email')?.value || '';
3954
+
3955
+ const phoneEl = $('identity-card-phone');
3956
+ if (phoneEl) phoneEl.textContent = $('vcard-phone')?.value || '';
3957
+ }
3958
+
3959
+
2434
3960
  // vCard identity auto-save
2435
3961
  const VCARD_STORAGE_KEY = 'hd-wallet-vcard-identity';
2436
3962
  const vcardFieldIds = [
2437
3963
  'vcard-prefix', 'vcard-firstname', 'vcard-middlename', 'vcard-lastname',
2438
- 'vcard-suffix', 'vcard-email', 'vcard-phone', 'vcard-org', 'vcard-title',
3964
+ 'vcard-suffix', 'vcard-nickname', 'vcard-email', 'vcard-phone', 'vcard-org', 'vcard-title',
2439
3965
  'vcard-street', 'vcard-city', 'vcard-region', 'vcard-postal', 'vcard-country'
2440
3966
  ];
2441
3967
 
@@ -2466,16 +3992,95 @@ function setupMainAppHandlers() {
2466
3992
  }
2467
3993
 
2468
3994
  restoreVcardIdentity();
3995
+ updateIdentityCardSummary();
2469
3996
 
2470
- let vcardSaveTimer = null;
2471
- function debouncedVcardSave() {
2472
- clearTimeout(vcardSaveTimer);
2473
- vcardSaveTimer = setTimeout(saveVcardIdentity, 500);
3997
+ // Snapshot of field values before editing (for Back/cancel)
3998
+ let _vcardEditSnapshot = {};
3999
+
4000
+ function snapshotVcardFields() {
4001
+ _vcardEditSnapshot = {};
4002
+ for (const id of vcardFieldIds) {
4003
+ const el = $(id);
4004
+ if (el) _vcardEditSnapshot[id] = el.value;
4005
+ }
4006
+ }
4007
+
4008
+ function restoreVcardSnapshot() {
4009
+ for (const id of vcardFieldIds) {
4010
+ const el = $(id);
4011
+ if (el && _vcardEditSnapshot[id] !== undefined) el.value = _vcardEditSnapshot[id];
4012
+ }
2474
4013
  }
2475
4014
 
2476
- for (const id of vcardFieldIds) {
2477
- $(id)?.addEventListener('input', debouncedVcardSave);
4015
+ // Edit button switch to edit view
4016
+ $('identity-edit-btn')?.addEventListener('click', () => {
4017
+ setPhotoActionsVisible(false);
4018
+ stopCamera();
4019
+ snapshotVcardFields();
4020
+ $('vcard-form-view').style.display = 'none';
4021
+ $('vcard-edit-view').style.display = 'flex';
4022
+ });
4023
+
4024
+ // Save button — persist and return to card view
4025
+ $('identity-save-btn')?.addEventListener('click', () => {
4026
+ saveVcardIdentity();
4027
+ updateIdentityCardSummary();
4028
+ setPhotoActionsVisible(false);
4029
+ $('vcard-edit-view').style.display = 'none';
4030
+ $('vcard-form-view').style.display = '';
4031
+ });
4032
+
4033
+ // Back button — discard changes and return to card view
4034
+ $('identity-back-btn')?.addEventListener('click', () => {
4035
+ restoreVcardSnapshot();
4036
+ setPhotoActionsVisible(false);
4037
+ stopCamera();
4038
+ $('vcard-edit-view').style.display = 'none';
4039
+ $('vcard-form-view').style.display = '';
4040
+ });
4041
+
4042
+ const photoActions = $('vcard-photo-actions');
4043
+ const photoEditBtn = $('vcard-photo-edit-btn');
4044
+ function setPhotoActionsVisible(visible) {
4045
+ if (photoActions) photoActions.classList.toggle('visible', visible);
4046
+ if (photoEditBtn) {
4047
+ photoEditBtn.classList.toggle('is-open', visible);
4048
+ photoEditBtn.title = visible ? 'Close Photo Menu' : 'Edit Photo';
4049
+ }
4050
+ }
4051
+ function arePhotoActionsVisible() {
4052
+ return !!photoActions?.classList.contains('visible');
2478
4053
  }
4054
+ setPhotoActionsVisible(false);
4055
+
4056
+ function encodeVcardPhoto(source, sourceWidth, sourceHeight) {
4057
+ if (!source || !sourceWidth || !sourceHeight) return null;
4058
+ const maxDimension = 1024;
4059
+ const quality = 0.9;
4060
+ const scale = Math.min(1, maxDimension / Math.max(sourceWidth, sourceHeight));
4061
+ const outputWidth = Math.max(1, Math.round(sourceWidth * scale));
4062
+ const outputHeight = Math.max(1, Math.round(sourceHeight * scale));
4063
+ const canvas = document.createElement('canvas');
4064
+ canvas.width = outputWidth;
4065
+ canvas.height = outputHeight;
4066
+ const ctx = canvas.getContext('2d');
4067
+ if (!ctx) return null;
4068
+ ctx.imageSmoothingEnabled = true;
4069
+ ctx.imageSmoothingQuality = 'high';
4070
+ ctx.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, outputWidth, outputHeight);
4071
+ return canvas.toDataURL('image/jpeg', quality);
4072
+ }
4073
+
4074
+ photoEditBtn?.addEventListener('click', (e) => {
4075
+ e.preventDefault();
4076
+ e.stopPropagation();
4077
+ if (arePhotoActionsVisible()) {
4078
+ setPhotoActionsVisible(false);
4079
+ stopCamera();
4080
+ return;
4081
+ }
4082
+ setPhotoActionsVisible(true);
4083
+ });
2479
4084
 
2480
4085
  // Photo upload handler
2481
4086
  $('vcard-photo-input')?.addEventListener('change', (e) => {
@@ -2485,19 +4090,12 @@ function setupMainAppHandlers() {
2485
4090
  reader.onload = (ev) => {
2486
4091
  const img = new Image();
2487
4092
  img.onload = () => {
2488
- const canvas = document.createElement('canvas');
2489
- const size = 128;
2490
- canvas.width = size;
2491
- canvas.height = size;
2492
- const ctx = canvas.getContext('2d');
2493
- const min = Math.min(img.width, img.height);
2494
- const sx = (img.width - min) / 2;
2495
- const sy = (img.height - min) / 2;
2496
- ctx.drawImage(img, sx, sy, min, min, 0, 0, size, size);
2497
- const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
4093
+ const dataUrl = encodeVcardPhoto(img, img.width, img.height);
4094
+ if (!dataUrl) return;
2498
4095
  state.vcardPhoto = dataUrl;
2499
4096
  stopCamera();
2500
4097
  showPhotoPreview(dataUrl);
4098
+ setPhotoActionsVisible(false);
2501
4099
  saveVcardIdentity();
2502
4100
  };
2503
4101
  img.src = ev.target.result;
@@ -2526,6 +4124,7 @@ function setupMainAppHandlers() {
2526
4124
  const cameraBtn = $('vcard-camera-btn');
2527
4125
  if (cameraBtn) cameraBtn.style.display = '';
2528
4126
  }
4127
+ setPhotoActionsVisible(false);
2529
4128
  const modal = $('photo-remove-confirm-modal');
2530
4129
  if (modal) modal.classList.remove('active');
2531
4130
  });
@@ -2543,6 +4142,14 @@ function setupMainAppHandlers() {
2543
4142
  if (placeholder) placeholder.style.display = '';
2544
4143
  const video = $('vcard-camera-video');
2545
4144
  if (video) video.style.display = 'none';
4145
+ const removeBtn = $('vcard-photo-remove');
4146
+ if (removeBtn) removeBtn.style.display = 'none';
4147
+ const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
4148
+ if (uploadLabel) uploadLabel.style.display = '';
4149
+ const cameraBtn = $('vcard-camera-btn');
4150
+ if (cameraBtn && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
4151
+ cameraBtn.style.display = '';
4152
+ }
2546
4153
  }
2547
4154
 
2548
4155
  function showPhotoPreview(dataUrl) {
@@ -2559,23 +4166,28 @@ function setupMainAppHandlers() {
2559
4166
  preview.appendChild(img);
2560
4167
  const removeBtn = $('vcard-photo-remove');
2561
4168
  if (removeBtn) removeBtn.style.display = '';
2562
- // Hide upload/camera buttons when photo is present
2563
4169
  const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
2564
- if (uploadLabel) uploadLabel.style.display = 'none';
4170
+ if (uploadLabel) uploadLabel.style.display = '';
2565
4171
  const cameraBtn = $('vcard-camera-btn');
2566
- if (cameraBtn) cameraBtn.style.display = 'none';
4172
+ if (cameraBtn && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
4173
+ cameraBtn.style.display = '';
4174
+ }
2567
4175
  }
2568
4176
 
2569
4177
  // Camera support
2570
4178
  let cameraStream = null;
2571
4179
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
2572
4180
  const cameraBtn = $('vcard-camera-btn');
2573
- if (cameraBtn && !state.vcardPhoto) cameraBtn.style.display = '';
4181
+ if (cameraBtn) cameraBtn.style.display = '';
2574
4182
 
2575
4183
  cameraBtn?.addEventListener('click', async () => {
2576
4184
  try {
2577
4185
  cameraStream = await navigator.mediaDevices.getUserMedia({
2578
- video: { facingMode: 'user', width: { ideal: 512 }, height: { ideal: 512 } }
4186
+ video: {
4187
+ facingMode: 'user',
4188
+ width: { ideal: 1280, max: 1920 },
4189
+ height: { ideal: 720, max: 1080 },
4190
+ }
2579
4191
  });
2580
4192
  const video = $('vcard-camera-video');
2581
4193
  if (video) {
@@ -2590,6 +4202,10 @@ function setupMainAppHandlers() {
2590
4202
  preview.querySelectorAll('img').forEach(el => el.style.display = 'none');
2591
4203
  }
2592
4204
  cameraBtn.style.display = 'none';
4205
+ const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
4206
+ if (uploadLabel) uploadLabel.style.display = 'none';
4207
+ const removeBtn = $('vcard-photo-remove');
4208
+ if (removeBtn) removeBtn.style.display = 'none';
2593
4209
  const captureBtn = $('vcard-camera-capture');
2594
4210
  const cancelBtn = $('vcard-camera-cancel');
2595
4211
  if (captureBtn) captureBtn.style.display = '';
@@ -2603,21 +4219,14 @@ function setupMainAppHandlers() {
2603
4219
  $('vcard-camera-capture')?.addEventListener('click', () => {
2604
4220
  const video = $('vcard-camera-video');
2605
4221
  if (!video) return;
2606
- const canvas = document.createElement('canvas');
2607
- const size = 128;
2608
- canvas.width = size;
2609
- canvas.height = size;
2610
- const ctx = canvas.getContext('2d');
2611
4222
  const vw = video.videoWidth;
2612
4223
  const vh = video.videoHeight;
2613
- const min = Math.min(vw, vh);
2614
- const sx = (vw - min) / 2;
2615
- const sy = (vh - min) / 2;
2616
- ctx.drawImage(video, sx, sy, min, min, 0, 0, size, size);
2617
- const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
4224
+ const dataUrl = encodeVcardPhoto(video, vw, vh);
4225
+ if (!dataUrl) return;
2618
4226
  state.vcardPhoto = dataUrl;
2619
4227
  stopCamera();
2620
4228
  showPhotoPreview(dataUrl);
4229
+ setPhotoActionsVisible(false);
2621
4230
  saveVcardIdentity();
2622
4231
  });
2623
4232
 
@@ -2642,9 +4251,13 @@ function setupMainAppHandlers() {
2642
4251
  video.style.display = 'none';
2643
4252
  }
2644
4253
  const cameraBtn = $('vcard-camera-btn');
4254
+ const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
4255
+ const removeBtn = $('vcard-photo-remove');
2645
4256
  const captureBtn = $('vcard-camera-capture');
2646
4257
  const cancelBtn = $('vcard-camera-cancel');
2647
- if (cameraBtn) cameraBtn.style.display = state.vcardPhoto ? 'none' : '';
4258
+ if (uploadLabel) uploadLabel.style.display = '';
4259
+ if (removeBtn) removeBtn.style.display = state.vcardPhoto ? '' : 'none';
4260
+ if (cameraBtn && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) cameraBtn.style.display = '';
2648
4261
  if (captureBtn) captureBtn.style.display = 'none';
2649
4262
  if (cancelBtn) cancelBtn.style.display = 'none';
2650
4263
  }
@@ -2694,28 +4307,13 @@ function setupMainAppHandlers() {
2694
4307
  });
2695
4308
  });
2696
4309
 
2697
- // Export wallet dropdown
2698
- const exportBtn = $('export-wallet-btn');
2699
- const exportMenu = $('export-menu');
2700
- if (exportBtn && exportMenu) {
2701
- exportBtn.addEventListener('click', () => {
2702
- exportMenu.classList.toggle('active');
2703
- });
2704
-
2705
- _root.addEventListener('click', (e) => {
2706
- if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) {
2707
- exportMenu.classList.remove('active');
2708
- }
2709
- });
2710
-
2711
- $qa('.export-option').forEach(option => {
2712
- option.addEventListener('click', async () => {
2713
- const format = option.dataset.format;
2714
- await exportWallet(format);
2715
- exportMenu.classList.remove('active');
2716
- });
4310
+ // Export wallet options
4311
+ $qa('.export-option').forEach(option => {
4312
+ option.addEventListener('click', async () => {
4313
+ const format = option.dataset.format;
4314
+ await exportWallet(format);
2717
4315
  });
2718
- }
4316
+ });
2719
4317
 
2720
4318
  // Mobile menu toggle
2721
4319
  const mobileMenuBtn = $('nav-menu-btn');
@@ -2769,21 +4367,145 @@ function setupMainAppHandlers() {
2769
4367
  });
2770
4368
  });
2771
4369
 
2772
- // HD wallet controls
2773
- $('hd-coin')?.addEventListener('change', () => {
2774
- updatePathDisplay();
2775
- deriveAndDisplayAddress();
2776
- updateEncryptionTab();
4370
+ // Wallet tab controls
4371
+ $('wallet-active-select')?.addEventListener('change', (e) => {
4372
+ const walletId = Number.parseInt(e.target.value, 10);
4373
+ if (Number.isNaN(walletId)) return;
4374
+ state.activeWalletId = walletId;
4375
+ closeWalletActionMenus();
4376
+ renderWalletSelector();
4377
+ renderAccountsList();
4378
+ updateCustomPathDefault();
4379
+ });
4380
+ $('wallet-manage-btn')?.addEventListener('click', () => {
4381
+ closeWalletActionMenus();
4382
+ showWalletsView();
4383
+ });
4384
+ $('wallet-scan-btn')?.addEventListener('click', () => {
4385
+ scanActiveAccounts();
4386
+ });
4387
+ const sendAction = $('wallet-send-action');
4388
+ const receiveAction = $('wallet-receive-action');
4389
+ $('wallet-send-btn')?.addEventListener('click', (e) => {
4390
+ e.stopPropagation();
4391
+ updateWalletActionMenus();
4392
+ const sendMenu = $('wallet-send-menu');
4393
+ const receiveMenu = $('wallet-receive-menu');
4394
+ if (!sendMenu || !receiveMenu) return;
4395
+ const nextVisible = !sendMenu.classList.contains('visible');
4396
+ receiveMenu.classList.remove('visible');
4397
+ sendMenu.classList.toggle('visible', nextVisible);
4398
+ });
4399
+ $('wallet-receive-btn-main')?.addEventListener('click', (e) => {
4400
+ e.stopPropagation();
4401
+ updateWalletActionMenus();
4402
+ const sendMenu = $('wallet-send-menu');
4403
+ const receiveMenu = $('wallet-receive-menu');
4404
+ if (!sendMenu || !receiveMenu) return;
4405
+ const nextVisible = !receiveMenu.classList.contains('visible');
4406
+ sendMenu.classList.remove('visible');
4407
+ receiveMenu.classList.toggle('visible', nextVisible);
4408
+ });
4409
+ $qa('#wallet-send-menu .ph-action-menu-item').forEach((btn) => {
4410
+ btn.addEventListener('click', (e) => {
4411
+ e.stopPropagation();
4412
+ const chain = btn.dataset.chain;
4413
+ const acct = getWalletAccountForChain(chain);
4414
+ closeWalletActionMenus();
4415
+ if (!acct) return;
4416
+ showSendView(state.activeAccounts.indexOf(acct));
4417
+ });
4418
+ });
4419
+ $qa('#wallet-receive-menu .ph-action-menu-item').forEach((btn) => {
4420
+ btn.addEventListener('click', (e) => {
4421
+ e.stopPropagation();
4422
+ const chain = btn.dataset.chain;
4423
+ const acct = getWalletAccountForChain(chain);
4424
+ closeWalletActionMenus();
4425
+ if (!acct) return;
4426
+ showReceiveModal(acct);
4427
+ });
4428
+ });
4429
+ _root.addEventListener('click', (e) => {
4430
+ if (sendAction?.contains(e.target) || receiveAction?.contains(e.target)) return;
4431
+ closeWalletActionMenus();
2777
4432
  });
2778
- $('hd-account')?.addEventListener('input', () => {
2779
- updatePathDisplay();
2780
- deriveAndDisplayAddress();
2781
- updateEncryptionTab();
4433
+ $('wallet-export-btn-main')?.addEventListener('click', () => {
4434
+ closeWalletActionMenus();
4435
+ showExportView();
2782
4436
  });
2783
- $('hd-index')?.addEventListener('input', () => {
2784
- updatePathDisplay();
2785
- deriveAndDisplayAddress();
2786
- updateEncryptionTab();
4437
+ $('wallet-advanced-btn-main')?.addEventListener('click', () => {
4438
+ closeWalletActionMenus();
4439
+ showAdvancedView();
4440
+ });
4441
+ $('wallet-wallets-back')?.addEventListener('click', () => {
4442
+ showWalletMainView();
4443
+ });
4444
+ $('wallet-manage-tab-active')?.addEventListener('click', () => {
4445
+ setWalletManageTab('active');
4446
+ });
4447
+ $('wallet-manage-tab-inactive')?.addEventListener('click', () => {
4448
+ setWalletManageTab('inactive');
4449
+ });
4450
+ $('wallet-export-back')?.addEventListener('click', () => {
4451
+ showWalletMainView();
4452
+ });
4453
+ $('wallet-advanced-back')?.addEventListener('click', () => {
4454
+ showWalletMainView();
4455
+ });
4456
+ // New Wallet in wallets view
4457
+ $('wallet-new-btn')?.addEventListener('click', () => {
4458
+ createNewWallet();
4459
+ });
4460
+ // Custom derivation path
4461
+ $('custom-path-add')?.addEventListener('click', () => {
4462
+ addCustomPathAccount();
4463
+ });
4464
+ $('custom-path-chain')?.addEventListener('change', () => {
4465
+ updateCustomPathDefault();
4466
+ });
4467
+ $('custom-path-input')?.addEventListener('input', (e) => {
4468
+ e.target.dataset.autogenerated = 'false';
4469
+ });
4470
+ // Send flow
4471
+ $('wallet-send-back')?.addEventListener('click', () => {
4472
+ hideSendView();
4473
+ });
4474
+ $('send-from-account')?.addEventListener('change', () => {
4475
+ updateSendFromSelection();
4476
+ });
4477
+ $('send-to-address')?.addEventListener('input', () => {
4478
+ validateSendForm();
4479
+ });
4480
+ $('send-amount')?.addEventListener('input', () => {
4481
+ validateSendForm();
4482
+ updateSendFiatEstimate();
4483
+ });
4484
+ $('send-max-btn')?.addEventListener('click', () => {
4485
+ const select = $('send-from-account');
4486
+ const amountInput = $('send-amount');
4487
+ if (!select || !amountInput) return;
4488
+ const idx = parseInt(select.value);
4489
+ const acct = state.activeAccounts[idx];
4490
+ if (!acct) return;
4491
+ const bal = parseFloat(acct.balance);
4492
+ if (!isNaN(bal) && bal > 0) {
4493
+ amountInput.value = bal;
4494
+ validateSendForm();
4495
+ updateSendFiatEstimate();
4496
+ }
4497
+ });
4498
+ $('send-review-btn')?.addEventListener('click', () => {
4499
+ showSendReview();
4500
+ });
4501
+ $('send-confirm-btn')?.addEventListener('click', () => {
4502
+ executeSend();
4503
+ });
4504
+ $('send-edit-btn')?.addEventListener('click', () => {
4505
+ const compose = $('send-compose-step');
4506
+ const review = $('send-review-step');
4507
+ if (compose) compose.style.display = 'block';
4508
+ if (review) review.style.display = 'none';
2787
4509
  });
2788
4510
 
2789
4511
  // PKI clear keys
@@ -2845,7 +4567,9 @@ function setupMainAppHandlers() {
2845
4567
  middleName: $('vcard-middlename')?.value || '',
2846
4568
  lastName: $('vcard-lastname')?.value || '',
2847
4569
  suffix: $('vcard-suffix')?.value || '',
4570
+ nickname: $('vcard-nickname')?.value || '',
2848
4571
  email: $('vcard-email')?.value || '',
4572
+ phone: $('vcard-phone')?.value || '',
2849
4573
  org: $('vcard-org')?.value || '',
2850
4574
  title: $('vcard-title')?.value || '',
2851
4575
  includeKeys: true,
@@ -2856,10 +4580,9 @@ function setupMainAppHandlers() {
2856
4580
  return;
2857
4581
  }
2858
4582
 
2859
- const vcard = generateVCard(info);
2860
- const vcardForQR = generateVCard(info, { skipPhoto: true });
2861
- const vcardPreview = $('vcard-preview');
2862
- if (vcardPreview) vcardPreview.textContent = vcard;
4583
+ const vcard = signVCard(generateVCard(info));
4584
+ const vcardForQR = signVCard(generateVCard(info, { skipPhoto: true }));
4585
+ state._exportedVCard = vcard;
2863
4586
 
2864
4587
  try {
2865
4588
  const qrCanvas = $('qr-code');
@@ -2874,11 +4597,49 @@ function setupMainAppHandlers() {
2874
4597
  const resultView = $('vcard-result-view');
2875
4598
  if (formView) formView.style.display = 'none';
2876
4599
  if (resultView) resultView.style.display = '';
4600
+
4601
+ // Show/hide signature badge
4602
+ const sigBadge = $('vcard-sig-badge');
4603
+ if (sigBadge) {
4604
+ sigBadge.style.display = state.wallet?.ed25519?.privateKey ? 'flex' : 'none';
4605
+ }
4606
+
4607
+ // Populate raw view (strip PHOTO base64 data)
4608
+ const rawView = $('vcard-raw-view');
4609
+ if (rawView) {
4610
+ const rawText = vcard.replace(/PHOTO;ENCODING=b;TYPE=\w+:[\s\S]*?(?=\n[A-Z])/m, 'PHOTO:[image data omitted]');
4611
+ rawView.textContent = rawText;
4612
+ }
4613
+
4614
+ // Reset toggle to QR
4615
+ $('vcard-toggle-qr')?.classList.add('active');
4616
+ $('vcard-toggle-raw')?.classList.remove('active');
4617
+ document.querySelector('.qr-container')?.style.setProperty('display', '');
4618
+ if (rawView) rawView.style.display = 'none';
2877
4619
  } catch (err) {
2878
4620
  alert('Error generating QR code: ' + err.message);
2879
4621
  }
2880
4622
  });
2881
4623
 
4624
+ // Toggle QR / Raw
4625
+ $('vcard-toggle-qr')?.addEventListener('click', () => {
4626
+ $('vcard-toggle-qr')?.classList.add('active');
4627
+ $('vcard-toggle-raw')?.classList.remove('active');
4628
+ const qr = document.querySelector('#vcard-result-view .qr-container');
4629
+ const raw = $('vcard-raw-view');
4630
+ if (qr) qr.style.display = '';
4631
+ if (raw) raw.style.display = 'none';
4632
+ });
4633
+
4634
+ $('vcard-toggle-raw')?.addEventListener('click', () => {
4635
+ $('vcard-toggle-raw')?.classList.add('active');
4636
+ $('vcard-toggle-qr')?.classList.remove('active');
4637
+ const qr = document.querySelector('#vcard-result-view .qr-container');
4638
+ const raw = $('vcard-raw-view');
4639
+ if (qr) qr.style.display = 'none';
4640
+ if (raw) raw.style.display = '';
4641
+ });
4642
+
2882
4643
  // Back to editor from result view
2883
4644
  $('vcard-back-btn')?.addEventListener('click', () => {
2884
4645
  const resultView = $('vcard-result-view');
@@ -2889,7 +4650,7 @@ function setupMainAppHandlers() {
2889
4650
 
2890
4651
  // Download vCard
2891
4652
  $('download-vcard')?.addEventListener('click', () => {
2892
- const vcard = $('vcard-preview')?.textContent || '';
4653
+ const vcard = state._exportedVCard || '';
2893
4654
  const blob = new Blob([vcard], { type: 'text/vcard' });
2894
4655
  const url = URL.createObjectURL(blob);
2895
4656
  const a = document.createElement('a');
@@ -2901,7 +4662,7 @@ function setupMainAppHandlers() {
2901
4662
 
2902
4663
  // Copy vCard
2903
4664
  $('copy-vcard')?.addEventListener('click', async () => {
2904
- const vcard = $('vcard-preview')?.textContent || '';
4665
+ const vcard = state._exportedVCard || '';
2905
4666
  try {
2906
4667
  await navigator.clipboard.writeText(vcard);
2907
4668
  const btn = $('copy-vcard');
@@ -2936,13 +4697,19 @@ function setupMainAppHandlers() {
2936
4697
 
2937
4698
  function setupTrustHandlers() {
2938
4699
  let trustScanInterval = null;
2939
- const TRUST_SCAN_INTERVAL_MS = 60000; // 60 seconds
4700
+ let trustScanRunning = false;
4701
+ let trustNextAllowedAt = 0;
4702
+ const TRUST_SCAN_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
4703
+ const TRUST_SCAN_FAIL_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes on failure
2940
4704
  const TRUST_RULES_KEY = 'trust-rules';
2941
4705
  const TRUST_IMPORTED_KEY = 'trust-imported-txs';
2942
4706
 
2943
4707
  // Auto-scan trust transactions
2944
4708
  async function runTrustScan() {
2945
4709
  if (!state.loggedIn || !state.addresses) return;
4710
+ if (trustScanRunning) return;
4711
+ if (Date.now() < trustNextAllowedAt) return;
4712
+ trustScanRunning = true;
2946
4713
 
2947
4714
  const statusEl = $('trust-scan-status');
2948
4715
  const labelEl = $('trust-scan-label');
@@ -2999,14 +4766,22 @@ function setupTrustHandlers() {
2999
4766
  if (countEl) countEl.textContent = `${relationships.length} relationships`;
3000
4767
 
3001
4768
  console.log(`Trust scan: ${dedupedTxs.length} txs, ${relationships.length} relationships`);
4769
+ trustNextAllowedAt = 0;
3002
4770
  } catch (err) {
3003
4771
  console.error('Trust scan failed:', err);
3004
- if (labelEl) labelEl.textContent = 'Scan failed';
4772
+ trustNextAllowedAt = Date.now() + TRUST_SCAN_FAIL_COOLDOWN_MS;
4773
+ if (labelEl) labelEl.textContent = 'Scan delayed (endpoint limited)';
4774
+ } finally {
4775
+ trustScanRunning = false;
3005
4776
  }
3006
4777
  }
3007
4778
 
3008
4779
  // Start auto-scanning
3009
4780
  function startTrustScanning() {
4781
+ if (trustScanInterval) {
4782
+ clearInterval(trustScanInterval);
4783
+ trustScanInterval = null;
4784
+ }
3010
4785
  runTrustScan();
3011
4786
  trustScanInterval = setInterval(runTrustScan, TRUST_SCAN_INTERVAL_MS);
3012
4787
  }
@@ -3091,7 +4866,7 @@ function setupTrustHandlers() {
3091
4866
  return;
3092
4867
  }
3093
4868
  const { exportTrustData } = await import('./trust-ui.js');
3094
- const xpub = state.hdRoot ? state.hdRoot.publicExtendedKey() : '';
4869
+ const xpub = state.hdRoot ? state.hdRoot.toXpub() : '';
3095
4870
  exportTrustData(state.trustTransactions, xpub);
3096
4871
  });
3097
4872
 
@@ -3158,7 +4933,7 @@ function setupTrustHandlers() {
3158
4933
  }
3159
4934
 
3160
4935
  // Update encryption tab when it becomes active or HD controls change
3161
- $qa('.modal-tab[data-modal-tab="encrypt-tab-content"]').forEach(tab => {
4936
+ $qa('.modal-tab[data-modal-tab="messaging-tab-content"]').forEach(tab => {
3162
4937
  tab.addEventListener('click', () => {
3163
4938
  if (state.hdRoot) updateEncryptionTab();
3164
4939
  });