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/package.json +2 -2
- package/src/address-derivation.js +50 -12
- package/src/app.js +1848 -191
- package/src/blockchain-trust.js +81 -28
- package/src/template.js +183 -75
- package/styles/main.css +811 -0
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, '"');
|
|
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, '"') + '" 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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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 <
|
|
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:
|
|
2819
|
+
return { crypto, rate: json.data?.rates?.BTC };
|
|
1209
2820
|
})
|
|
1210
2821
|
);
|
|
1211
2822
|
results.forEach(r => {
|
|
1212
|
-
if (r.status === 'fulfilled')
|
|
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
|
|
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:
|
|
2833
|
+
return { crypto, price: json.data?.amount };
|
|
1223
2834
|
})
|
|
1224
2835
|
);
|
|
1225
2836
|
results.forEach(r => {
|
|
1226
|
-
if (r.status === 'fulfilled')
|
|
2837
|
+
if (r.status === 'fulfilled') setPrice(r.value.crypto, r.value.price);
|
|
1227
2838
|
});
|
|
1228
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
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
|
-
//
|
|
2852
|
-
$('
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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
|
-
$('
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
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
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|