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