hd-wallet-ui 1.2.1 → 1.2.3

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
@@ -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 barEl = $('wallet-scan-bar');
1301
+ const scanBtn = $('wallet-scan-btn');
1302
+ if (statusEl) statusEl.style.display = 'block';
1303
+ if (barEl) barEl.style.width = '0%';
1304
+ if (scanBtn) scanBtn.disabled = true;
1305
+
1306
+ try {
1307
+ const found = [];
1308
+ const chainByCoinType = new Map(CHAIN_CONFIG.map(chain => [chain.coinType, chain]));
1309
+ const targets = [];
1310
+ const seen = new Set();
1311
+ const addTarget = (coinType, account, index, walletId, name) => {
1312
+ const chain = chainByCoinType.get(coinType);
1313
+ if (!chain) return;
1314
+ const key = `${walletId}:${coinType}:${account}:${index}`;
1315
+ if (seen.has(key)) return;
1316
+ seen.add(key);
1317
+ targets.push({
1318
+ coinType,
1319
+ account,
1320
+ index,
1321
+ walletId,
1322
+ name: name || chain.name,
1323
+ fetchBalance: chain.fetchBalance,
1324
+ });
1325
+ };
1326
+
1327
+ getActiveWallets().forEach((wallet) => {
1328
+ getWalletDerivationEntries(wallet).forEach((entry) => {
1329
+ addTarget(entry.coinType, entry.account, entry.index, wallet.id, entry.name);
1330
+ });
1331
+ });
1332
+
1333
+ for (let ti = 0; ti < targets.length; ti++) {
1334
+ const target = targets[ti];
1335
+ if (barEl) barEl.style.width = Math.round(((ti + 1) / targets.length) * 100) + '%';
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
@@ -910,7 +2485,6 @@ function login(keys) {
910
2485
 
911
2486
  // Open Account modal so user can see the wallet they just loaded
912
2487
  $('keys-modal')?.classList.add('active');
913
- deriveAndDisplayAddress();
914
2488
 
915
2489
  // Resolve names and update title
916
2490
  clearNameCache();
@@ -1063,7 +2637,7 @@ function populateAccountAddressDropdown() {
1063
2637
  if (!addrEl) return;
1064
2638
 
1065
2639
  const xpubStr = state.hdRoot ? state.hdRoot.toXpub() : '';
1066
- addrEl.textContent = xpubStr;
2640
+ addrEl.textContent = `${xpubStr.slice(0,10)}...${xpubStr.slice(-10)}`;
1067
2641
  addrEl.title = xpubStr;
1068
2642
 
1069
2643
  const copyBtn = $('account-address-copy');
@@ -1177,8 +2751,33 @@ const CURRENCY_SYMBOLS = {
1177
2751
 
1178
2752
  const CURRENCY_OPTIONS = Object.keys(CURRENCY_SYMBOLS);
1179
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;
1180
2757
  let priceCache = { data: null, currency: null, timestamp: 0 };
1181
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
+
1182
2781
  function getSelectedCurrency() {
1183
2782
  return localStorage.getItem('bond-currency') || 'USD';
1184
2783
  }
@@ -1189,12 +2788,23 @@ function setSelectedCurrency(currency) {
1189
2788
 
1190
2789
  async function fetchCryptoPrices(currency) {
1191
2790
  const now = Date.now();
1192
- if (priceCache.data && priceCache.currency === currency && now - priceCache.timestamp < 60000) {
2791
+ if (priceCache.data && priceCache.currency === currency && now - priceCache.timestamp < PRICE_CACHE_TTL_MS) {
1193
2792
  return priceCache.data;
1194
2793
  }
1195
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
+
1196
2801
  const cryptos = ['BTC', 'ETH', 'SOL'];
1197
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
+ };
1198
2808
 
1199
2809
  if (currency === 'BTC') {
1200
2810
  // For BTC denomination, fetch each crypto's price in BTC
@@ -1204,31 +2814,70 @@ async function fetchCryptoPrices(currency) {
1204
2814
  others.map(async (crypto) => {
1205
2815
  const url = apiUrl(`https://api.coinbase.com/v2/exchange-rates?currency=${crypto}`);
1206
2816
  const res = await fetch(url);
2817
+ if (!res.ok) throw new Error(`Coinbase HTTP ${res.status}`);
1207
2818
  const json = await res.json();
1208
- return { crypto, rate: parseFloat(json.data?.rates?.BTC) || 0 };
2819
+ return { crypto, rate: json.data?.rates?.BTC };
1209
2820
  })
1210
2821
  );
1211
2822
  results.forEach(r => {
1212
- if (r.status === 'fulfilled') prices[r.value.crypto] = r.value.rate;
2823
+ if (r.status === 'fulfilled') setPrice(r.value.crypto, r.value.rate);
1213
2824
  });
1214
- // prices.MONAD = 0; // Testnet token, no market price
1215
2825
  } else {
1216
- // Fetch exchange rates with USD as base, then convert
2826
+ // Fetch fiat spot prices for each supported chain coin.
1217
2827
  const results = await Promise.allSettled(
1218
2828
  cryptos.map(async (crypto) => {
1219
2829
  const url = apiUrl(`https://api.coinbase.com/v2/prices/${crypto}-${currency}/spot`);
1220
2830
  const res = await fetch(url);
2831
+ if (!res.ok) throw new Error(`Coinbase HTTP ${res.status}`);
1221
2832
  const json = await res.json();
1222
- return { crypto, price: parseFloat(json.data?.amount) || 0 };
2833
+ return { crypto, price: json.data?.amount };
1223
2834
  })
1224
2835
  );
1225
2836
  results.forEach(r => {
1226
- if (r.status === 'fulfilled') prices[r.value.crypto] = r.value.price;
2837
+ if (r.status === 'fulfilled') setPrice(r.value.crypto, r.value.price);
1227
2838
  });
1228
- // 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`);
1229
2877
  }
1230
2878
 
1231
2879
  priceCache = { data: prices, currency, timestamp: now };
2880
+ saveStoredPriceCache(priceCache);
1232
2881
  return prices;
1233
2882
  }
1234
2883
 
@@ -1414,140 +3063,11 @@ function initCurrencySelector() {
1414
3063
  // =============================================================================
1415
3064
 
1416
3065
  async function updateAdversarialSecurity() {
1417
- const loginRequired = $('adversarial-login-required');
1418
- const balancesSection = $('adversarial-balances');
1419
-
1420
3066
  const hasWallet = state.wallet && (state.wallet.secp256k1 || state.wallet.ed25519);
3067
+ if (!hasWallet) return;
1421
3068
 
1422
- if (!hasWallet) {
1423
- if (loginRequired) loginRequired.style.display = 'block';
1424
- if (balancesSection) balancesSection.style.display = 'none';
1425
- const trustNote = $('trust-note');
1426
- if (trustNote) trustNote.textContent = 'Login to derive addresses and check balances.';
1427
- return;
1428
- }
1429
-
1430
- if (loginRequired) loginRequired.style.display = 'none';
1431
- if (balancesSection) balancesSection.style.display = 'block';
1432
-
1433
- populateWalletAddresses();
1434
-
1435
- const btcAddress = state.addresses?.btc;
1436
- const ethAddress = state.addresses?.eth;
1437
- const solAddress = state.addresses?.sol;
1438
-
1439
- // let suiAddress = null;
1440
- // let adaAddress = null;
1441
- // if (state.hdRoot) {
1442
- // try {
1443
- // const suiPath = buildSigningPath(784, 0, 0);
1444
- // const suiDerived = state.hdRoot.derivePath(suiPath);
1445
- // const suiPubKey = ed25519.getPublicKey(suiDerived.privateKey());
1446
- // suiAddress = deriveSuiAddress(suiPubKey, 'ed25519');
1447
- // } catch (e) { console.error('SUI derivation error:', e); }
1448
-
1449
- // try {
1450
- // const adaPath = buildSigningPath(1815, 0, 0);
1451
- // const adaDerived = state.hdRoot.derivePath(adaPath);
1452
- // const adaPubKey = ed25519.getPublicKey(adaDerived.privateKey());
1453
- // adaAddress = deriveCardanoAddress(adaPubKey);
1454
- // } catch (e) { console.error('ADA derivation error:', e); }
1455
- // }
1456
-
1457
- // const monadAddress = ethAddress;
1458
- // const xrpAddress = state.addresses?.xrp;
1459
-
1460
- // Set loading state
1461
- const networks = ['btc', 'eth', 'sol'];
1462
- networks.forEach(net => {
1463
- const balEl = $(`wallet-${net}-balance`);
1464
- if (balEl) balEl.textContent = '...';
1465
- });
1466
- const trustNote = $('trust-note');
1467
- if (trustNote) trustNote.textContent = 'Fetching balances from blockchain...';
1468
-
1469
- const fetchResults = await Promise.allSettled([
1470
- btcAddress ? fetchBtcBalance(btcAddress) : Promise.resolve({ balance: '0' }),
1471
- ethAddress ? fetchEthBalance(ethAddress) : Promise.resolve({ balance: '0' }),
1472
- solAddress ? fetchSolBalance(solAddress) : Promise.resolve({ balance: '0' }),
1473
- // suiAddress ? fetchSuiBalance(suiAddress) : Promise.resolve({ balance: '0' }),
1474
- // monadAddress ? fetchMonadBalance(monadAddress) : Promise.resolve({ balance: '0' }),
1475
- // adaAddress ? fetchAdaBalance(adaAddress) : Promise.resolve({ balance: '0' }),
1476
- // xrpAddress ? fetchXrpBalance(xrpAddress) : Promise.resolve({ balance: '0' }),
1477
- ]);
1478
-
1479
- const [btcResult, ethResult, solResult] = fetchResults.map(
1480
- r => r.status === 'fulfilled' ? r.value : { balance: '0' }
1481
- );
1482
-
1483
- const updateBalance = (network, balance, decimals = 4) => {
1484
- const balEl = $(`wallet-${network}-balance`);
1485
- if (balEl) {
1486
- const val = parseFloat(balance) || 0;
1487
- balEl.textContent = val > 0 ? val.toFixed(val < 0.0001 ? 8 : decimals) : '0';
1488
- }
1489
-
1490
- const card = $(`wallet-${network}-card`);
1491
- if (card) {
1492
- const hasBalance = parseFloat(balance) > 0;
1493
- card.classList.toggle('has-balance', hasBalance);
1494
- card.classList.toggle('secure', hasBalance);
1495
- }
1496
- };
1497
-
1498
- updateBalance('btc', btcResult.balance, 8);
1499
- updateBalance('eth', ethResult.balance, 6);
1500
- updateBalance('sol', solResult.balance, 6);
1501
- // updateBalance('sui', suiResult.balance, 4);
1502
- // updateBalance('monad', monadResult.balance, 4);
1503
- // updateBalance('ada', adaResult.balance, 6);
1504
- // updateBalance('xrp', xrpResult.balance, 6);
1505
-
1506
- // Update bond tab per-network balances
1507
- const bondBalances = {
1508
- btc: btcResult.balance, eth: ethResult.balance, sol: solResult.balance,
1509
- // sui: suiResult.balance, monad: monadResult.balance, ada: adaResult.balance,
1510
- // xrp: xrpResult.balance,
1511
- };
1512
- Object.entries(bondBalances).forEach(([net, bal]) => {
1513
- const el = $(`bond-${net}-balance`);
1514
- const card = $(`bond-${net}-card`);
1515
- const val = parseFloat(bal) || 0;
1516
- if (el) el.textContent = val > 0 ? val.toFixed(val < 0.0001 ? 8 : 4) : '0';
1517
- if (card) card.classList.toggle('has-balance', val > 0);
1518
- });
1519
-
1520
- // Convert to selected currency
1521
- const currency = getSelectedCurrency();
1522
- let totalConverted = 0;
1523
- let cryptoPrices = null;
1524
-
1525
- try {
1526
- cryptoPrices = await fetchCryptoPrices(currency);
1527
- const prices = cryptoPrices;
1528
- const balances = {
1529
- BTC: parseFloat(btcResult.balance) || 0,
1530
- ETH: parseFloat(ethResult.balance) || 0,
1531
- SOL: parseFloat(solResult.balance) || 0,
1532
- // SUI: parseFloat(suiResult.balance) || 0,
1533
- // MONAD: parseFloat(monadResult.balance) || 0,
1534
- // ADA: parseFloat(adaResult.balance) || 0,
1535
- // XRP: parseFloat(xrpResult.balance) || 0,
1536
- };
1537
-
1538
- for (const [crypto, bal] of Object.entries(balances)) {
1539
- totalConverted += bal * (prices[crypto] || 0);
1540
- }
1541
- } catch (e) {
1542
- console.warn('Price conversion failed:', e);
1543
- }
1544
-
1545
- // Update account header total value
1546
- const accountTotalEl = $('account-total-value');
1547
- if (accountTotalEl) {
1548
- accountTotalEl.textContent = 'Bond: ' + formatCurrencyValue(totalConverted, currency);
1549
- }
1550
-
3069
+ // Wallet tab bond total is now updated by scanActiveAccounts/updateWalletBondTotal
3070
+ updateWalletBondTotal();
1551
3071
  }
1552
3072
 
1553
3073
  // =============================================================================
@@ -1590,23 +3110,38 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1590
3110
 
1591
3111
  if (info.includeKeys && state.wallet?.x25519) {
1592
3112
  person.KEY = [
3113
+ // Always include xPub
1593
3114
  ...(state.hdRoot?.toXpub ? [{
1594
3115
  XPUB: state.hdRoot.toXpub(),
1595
3116
  LABEL: '',
1596
3117
  }] : []),
3118
+ // X25519 encryption key
1597
3119
  {
1598
3120
  PUBLIC_KEY: toBase64(state.wallet.x25519.publicKey),
1599
3121
  LABEL: 'X25519',
1600
3122
  },
1601
- {
1602
- PUBLIC_KEY: toBase64(state.wallet.ed25519.publicKey),
1603
- LABEL: "Ed25519 m/44'/501'/0'/0/0",
1604
- },
1605
- {
1606
- PUBLIC_KEY: toBase64(state.wallet.secp256k1.publicKey),
1607
- KEY_ADDRESS: state.addresses.btc || undefined,
1608
- LABEL: "secp256k1 m/44'/0'/0'/0/0",
1609
- },
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
+ }),
1610
3145
  ];
1611
3146
  } else if (info.xpubOnly && state.hdRoot?.toXpub) {
1612
3147
  person.KEY = [{ XPUB: state.hdRoot.toXpub(), LABEL: '' }];
@@ -2352,7 +3887,6 @@ function setupMainAppHandlers() {
2352
3887
  $('nav-logout')?.addEventListener('click', logout);
2353
3888
  $('nav-keys')?.addEventListener('click', async () => {
2354
3889
  $('keys-modal')?.classList.add('active');
2355
- deriveAndDisplayAddress();
2356
3890
  if (state.loggedIn) {
2357
3891
  const names = await resolveNames();
2358
3892
  updateAccountTitle(names);
@@ -2773,28 +4307,13 @@ function setupMainAppHandlers() {
2773
4307
  });
2774
4308
  });
2775
4309
 
2776
- // Export wallet dropdown
2777
- const exportBtn = $('export-wallet-btn');
2778
- const exportMenu = $('export-menu');
2779
- if (exportBtn && exportMenu) {
2780
- exportBtn.addEventListener('click', () => {
2781
- exportMenu.classList.toggle('active');
2782
- });
2783
-
2784
- _root.addEventListener('click', (e) => {
2785
- if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) {
2786
- exportMenu.classList.remove('active');
2787
- }
2788
- });
2789
-
2790
- $qa('.export-option').forEach(option => {
2791
- option.addEventListener('click', async () => {
2792
- const format = option.dataset.format;
2793
- await exportWallet(format);
2794
- exportMenu.classList.remove('active');
2795
- });
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);
2796
4315
  });
2797
- }
4316
+ });
2798
4317
 
2799
4318
  // Mobile menu toggle
2800
4319
  const mobileMenuBtn = $('nav-menu-btn');
@@ -2848,21 +4367,145 @@ function setupMainAppHandlers() {
2848
4367
  });
2849
4368
  });
2850
4369
 
2851
- // HD wallet controls
2852
- $('hd-coin')?.addEventListener('change', () => {
2853
- updatePathDisplay();
2854
- deriveAndDisplayAddress();
2855
- 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();
2856
4386
  });
2857
- $('hd-account')?.addEventListener('input', () => {
2858
- updatePathDisplay();
2859
- deriveAndDisplayAddress();
2860
- updateEncryptionTab();
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();
4432
+ });
4433
+ $('wallet-export-btn-main')?.addEventListener('click', () => {
4434
+ closeWalletActionMenus();
4435
+ showExportView();
4436
+ });
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();
2861
4459
  });
2862
- $('hd-index')?.addEventListener('input', () => {
2863
- updatePathDisplay();
2864
- deriveAndDisplayAddress();
2865
- updateEncryptionTab();
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';
2866
4509
  });
2867
4510
 
2868
4511
  // PKI clear keys
@@ -3054,13 +4697,19 @@ function setupMainAppHandlers() {
3054
4697
 
3055
4698
  function setupTrustHandlers() {
3056
4699
  let trustScanInterval = null;
3057
- 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
3058
4704
  const TRUST_RULES_KEY = 'trust-rules';
3059
4705
  const TRUST_IMPORTED_KEY = 'trust-imported-txs';
3060
4706
 
3061
4707
  // Auto-scan trust transactions
3062
4708
  async function runTrustScan() {
3063
4709
  if (!state.loggedIn || !state.addresses) return;
4710
+ if (trustScanRunning) return;
4711
+ if (Date.now() < trustNextAllowedAt) return;
4712
+ trustScanRunning = true;
3064
4713
 
3065
4714
  const statusEl = $('trust-scan-status');
3066
4715
  const labelEl = $('trust-scan-label');
@@ -3117,14 +4766,22 @@ function setupTrustHandlers() {
3117
4766
  if (countEl) countEl.textContent = `${relationships.length} relationships`;
3118
4767
 
3119
4768
  console.log(`Trust scan: ${dedupedTxs.length} txs, ${relationships.length} relationships`);
4769
+ trustNextAllowedAt = 0;
3120
4770
  } catch (err) {
3121
4771
  console.error('Trust scan failed:', err);
3122
- 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;
3123
4776
  }
3124
4777
  }
3125
4778
 
3126
4779
  // Start auto-scanning
3127
4780
  function startTrustScanning() {
4781
+ if (trustScanInterval) {
4782
+ clearInterval(trustScanInterval);
4783
+ trustScanInterval = null;
4784
+ }
3128
4785
  runTrustScan();
3129
4786
  trustScanInterval = setInterval(runTrustScan, TRUST_SCAN_INTERVAL_MS);
3130
4787
  }