hd-wallet-ui 1.0.0
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/README.md +158 -0
- package/package.json +59 -0
- package/src/address-derivation.js +476 -0
- package/src/app.js +3302 -0
- package/src/blockchain-trust.js +699 -0
- package/src/constants.js +87 -0
- package/src/lib.js +63 -0
- package/src/template.js +348 -0
- package/src/trust-ui.js +745 -0
- package/src/wallet-storage.js +696 -0
- package/styles/main.css +4521 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,3302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HD Wallet UI - Main Application
|
|
3
|
+
*
|
|
4
|
+
* Standalone wallet interface with HD key derivation, multi-chain address
|
|
5
|
+
* generation, balance fetching, vCard export, and PIN/passkey storage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// External Imports
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
import initHDWallet, { Curve } from 'hd-wallet-wasm';
|
|
13
|
+
import { x25519, ed25519 } from '@noble/curves/ed25519';
|
|
14
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
15
|
+
import { p256 } from '@noble/curves/p256';
|
|
16
|
+
import { sha256 as sha256Noble } from '@noble/hashes/sha256';
|
|
17
|
+
import { keccak_256 } from '@noble/hashes/sha3';
|
|
18
|
+
import QRCode from 'qrcode';
|
|
19
|
+
import { Buffer } from 'buffer';
|
|
20
|
+
import { createV3 } from 'vcard-cryptoperson';
|
|
21
|
+
|
|
22
|
+
// Make Buffer available globally for various crypto libraries
|
|
23
|
+
window.Buffer = Buffer;
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Local Module Imports
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
import { getModalHTML } from './template.js';
|
|
30
|
+
import WalletStorage, { StorageMethod } from './wallet-storage.js';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
cryptoConfig,
|
|
34
|
+
coinTypeToConfig,
|
|
35
|
+
buildSigningPath,
|
|
36
|
+
buildEncryptionPath,
|
|
37
|
+
} from './constants.js';
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
toHexCompact,
|
|
41
|
+
toHex,
|
|
42
|
+
hexToBytes,
|
|
43
|
+
ensureUint8Array,
|
|
44
|
+
generateBtcAddress,
|
|
45
|
+
generateEthAddress,
|
|
46
|
+
generateSolAddress,
|
|
47
|
+
deriveEthAddress,
|
|
48
|
+
// deriveSuiAddress, // Commented out — BTC/ETH/SOL only
|
|
49
|
+
// deriveMonadAddress,
|
|
50
|
+
// deriveCardanoAddress,
|
|
51
|
+
generateAddresses,
|
|
52
|
+
generateAddressForCoin,
|
|
53
|
+
truncateAddress,
|
|
54
|
+
fetchBtcBalance,
|
|
55
|
+
fetchEthBalance,
|
|
56
|
+
fetchSolBalance,
|
|
57
|
+
// fetchSuiBalance, // Commented out — BTC/ETH/SOL only
|
|
58
|
+
// fetchMonadBalance,
|
|
59
|
+
// fetchAdaBalance,
|
|
60
|
+
// generateXrpAddress,
|
|
61
|
+
// fetchXrpBalance,
|
|
62
|
+
apiUrl,
|
|
63
|
+
} from './address-derivation.js';
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// DOM Helper
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
let _root = document;
|
|
70
|
+
const $ = (id) => {
|
|
71
|
+
const el = _root.getElementById ? _root.getElementById(id) : _root.querySelector(`#${id}`);
|
|
72
|
+
if (el) return el;
|
|
73
|
+
// Fallback to document for elements in the light DOM (e.g. widget mode)
|
|
74
|
+
if (_root !== document) return document.getElementById(id);
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
const $q = (sel) => _root.querySelector(sel) || (_root !== document ? document.querySelector(sel) : null);
|
|
78
|
+
const $qa = (sel) => {
|
|
79
|
+
const list = _root.querySelectorAll(sel);
|
|
80
|
+
if (list.length > 0 || _root === document) return list;
|
|
81
|
+
return document.querySelectorAll(sel);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Wallet Info Box (dismissible notice)
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
function dismissWalletInfo() {
|
|
89
|
+
localStorage.setItem('walletInfoDismissed', '1');
|
|
90
|
+
$('wallet-info-expanded').style.display = 'none';
|
|
91
|
+
$('wallet-info-collapsed').style.display = 'flex';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function showWalletInfo() {
|
|
95
|
+
localStorage.removeItem('walletInfoDismissed');
|
|
96
|
+
$('wallet-info-expanded').style.display = 'flex';
|
|
97
|
+
$('wallet-info-collapsed').style.display = 'none';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function initWalletInfoBox() {
|
|
101
|
+
if (localStorage.getItem('walletInfoDismissed') === '1') {
|
|
102
|
+
$('wallet-info-expanded').style.display = 'none';
|
|
103
|
+
$('wallet-info-collapsed').style.display = 'flex';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toggleXpubInfo() {
|
|
108
|
+
const box = $('xpub-info-box');
|
|
109
|
+
if (box) box.style.display = box.style.display === 'none' ? 'flex' : 'none';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toggleMemoryInfo() {
|
|
113
|
+
const box = $('memory-info-box');
|
|
114
|
+
if (box) box.style.display = box.style.display === 'none' ? 'flex' : 'none';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bindInfoHandlers() {
|
|
118
|
+
$('wallet-info-dismiss')?.addEventListener('click', dismissWalletInfo);
|
|
119
|
+
$('wallet-info-collapsed')?.addEventListener('click', showWalletInfo);
|
|
120
|
+
$('xpub-info-toggle')?.addEventListener('click', toggleXpubInfo);
|
|
121
|
+
$('xpub-info-close')?.addEventListener('click', toggleXpubInfo);
|
|
122
|
+
$('memory-info-toggle')?.addEventListener('click', toggleMemoryInfo);
|
|
123
|
+
$('memory-info-close')?.addEventListener('click', toggleMemoryInfo);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// =============================================================================
|
|
127
|
+
// Utilities
|
|
128
|
+
// =============================================================================
|
|
129
|
+
|
|
130
|
+
function setTruncatedValue(el, value) {
|
|
131
|
+
if (!el) return;
|
|
132
|
+
el.dataset.fullValue = value;
|
|
133
|
+
el.textContent = middleTruncate(value, 17, 17);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function middleTruncate(str, startChars, endChars) {
|
|
137
|
+
if (!str || str.length <= startChars + endChars + 3) return str;
|
|
138
|
+
return str.slice(0, startChars) + '…' + str.slice(-endChars);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toBase64(arr) {
|
|
142
|
+
return btoa(String.fromCharCode(...arr));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// SHA-256 and HKDF (WebCrypto-based)
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
async function sha256(data) {
|
|
150
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
151
|
+
return new Uint8Array(hash);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function hkdf(ikm, salt, info, length) {
|
|
155
|
+
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
156
|
+
const derived = await crypto.subtle.deriveBits(
|
|
157
|
+
{ name: 'HKDF', hash: 'SHA-256', salt, info },
|
|
158
|
+
key,
|
|
159
|
+
length * 8
|
|
160
|
+
);
|
|
161
|
+
return new Uint8Array(derived);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =============================================================================
|
|
165
|
+
// Key Generation
|
|
166
|
+
// =============================================================================
|
|
167
|
+
|
|
168
|
+
function generateKeyPair(curveType) {
|
|
169
|
+
if (curveType === Curve.SECP256K1) {
|
|
170
|
+
const privateKey = secp256k1.utils.randomPrivateKey();
|
|
171
|
+
const publicKey = secp256k1.getPublicKey(privateKey, true);
|
|
172
|
+
return { privateKey, publicKey };
|
|
173
|
+
}
|
|
174
|
+
if (curveType === Curve.X25519) {
|
|
175
|
+
const privateKey = x25519.utils.randomPrivateKey();
|
|
176
|
+
const publicKey = x25519.getPublicKey(privateKey);
|
|
177
|
+
return { privateKey, publicKey };
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Unsupported curve type: ${curveType}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function p256GenerateKeyPairAsync() {
|
|
183
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
184
|
+
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']
|
|
185
|
+
);
|
|
186
|
+
const rawPublic = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
|
187
|
+
const pkcs8Private = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
188
|
+
return { publicKey: new Uint8Array(rawPublic), privateKey: new Uint8Array(pkcs8Private) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function p384GenerateKeyPairAsync() {
|
|
192
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
193
|
+
{ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']
|
|
194
|
+
);
|
|
195
|
+
const rawPublic = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
|
196
|
+
const pkcs8Private = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
197
|
+
return { publicKey: new Uint8Array(rawPublic), privateKey: new Uint8Array(pkcs8Private) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// State
|
|
202
|
+
// =============================================================================
|
|
203
|
+
|
|
204
|
+
const state = {
|
|
205
|
+
initialized: false,
|
|
206
|
+
loggedIn: false,
|
|
207
|
+
selectedCrypto: 'btc',
|
|
208
|
+
addresses: {
|
|
209
|
+
btc: null,
|
|
210
|
+
eth: null,
|
|
211
|
+
sol: null,
|
|
212
|
+
},
|
|
213
|
+
wallet: {
|
|
214
|
+
x25519: null,
|
|
215
|
+
ed25519: null,
|
|
216
|
+
secp256k1: null,
|
|
217
|
+
p256: null,
|
|
218
|
+
},
|
|
219
|
+
// HD wallet state
|
|
220
|
+
hdWalletModule: null,
|
|
221
|
+
masterSeed: null,
|
|
222
|
+
hdRoot: null,
|
|
223
|
+
mnemonic: null,
|
|
224
|
+
// Encryption keys (derived from password/seed)
|
|
225
|
+
encryptionKey: null,
|
|
226
|
+
encryptionIV: null,
|
|
227
|
+
// vCard photo (base64 data URI)
|
|
228
|
+
vcardPhoto: null,
|
|
229
|
+
// PKI Demo state
|
|
230
|
+
pki: {
|
|
231
|
+
alice: null,
|
|
232
|
+
bob: null,
|
|
233
|
+
algorithm: 'x25519',
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// =============================================================================
|
|
238
|
+
// Entropy Calculation & Password Strength
|
|
239
|
+
// =============================================================================
|
|
240
|
+
|
|
241
|
+
function calculateEntropy(password) {
|
|
242
|
+
if (!password) return 0;
|
|
243
|
+
|
|
244
|
+
let charsetSize = 0;
|
|
245
|
+
if (/[a-z]/.test(password)) charsetSize += 26;
|
|
246
|
+
if (/[A-Z]/.test(password)) charsetSize += 26;
|
|
247
|
+
if (/[0-9]/.test(password)) charsetSize += 10;
|
|
248
|
+
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password)) charsetSize += 32;
|
|
249
|
+
if (/\s/.test(password)) charsetSize += 1;
|
|
250
|
+
if (/[^\x00-\x7F]/.test(password)) charsetSize += 100;
|
|
251
|
+
|
|
252
|
+
if (charsetSize === 0) return 0;
|
|
253
|
+
return Math.round(password.length * Math.log2(charsetSize));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function updatePasswordStrength(password) {
|
|
257
|
+
const entropy = calculateEntropy(password);
|
|
258
|
+
const fill = $('strength-fill');
|
|
259
|
+
const bits = $('entropy-bits');
|
|
260
|
+
const btn = $('derive-from-password');
|
|
261
|
+
|
|
262
|
+
if (bits) bits.textContent = `${entropy}`;
|
|
263
|
+
|
|
264
|
+
const MIN_SAFE_ENTROPY = 112;
|
|
265
|
+
|
|
266
|
+
let strength, percentage;
|
|
267
|
+
|
|
268
|
+
if (entropy < 40) {
|
|
269
|
+
strength = 'weak';
|
|
270
|
+
percentage = Math.min(25, (entropy / 40) * 25);
|
|
271
|
+
} else if (entropy < 80) {
|
|
272
|
+
strength = 'fair';
|
|
273
|
+
percentage = 25 + ((entropy - 40) / 40) * 25;
|
|
274
|
+
} else if (entropy < MIN_SAFE_ENTROPY) {
|
|
275
|
+
strength = 'good';
|
|
276
|
+
percentage = 50 + ((entropy - 80) / (MIN_SAFE_ENTROPY - 80)) * 25;
|
|
277
|
+
} else {
|
|
278
|
+
strength = 'strong';
|
|
279
|
+
percentage = 75 + Math.min(25, ((entropy - MIN_SAFE_ENTROPY) / 50) * 25);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (fill) {
|
|
283
|
+
fill.style.width = `${percentage}%`;
|
|
284
|
+
// Gradient: deep red (0) → orange (50%) → green (100%)
|
|
285
|
+
const ratio = percentage / 100;
|
|
286
|
+
let r, g;
|
|
287
|
+
if (ratio < 0.5) {
|
|
288
|
+
r = 180 + Math.round(75 * (ratio / 0.5)); // 180→255
|
|
289
|
+
g = Math.round(140 * (ratio / 0.5)); // 0→140
|
|
290
|
+
} else {
|
|
291
|
+
r = 255 - Math.round(200 * ((ratio - 0.5) / 0.5)); // 255→55
|
|
292
|
+
g = 140 + Math.round(115 * ((ratio - 0.5) / 0.5)); // 140→255
|
|
293
|
+
}
|
|
294
|
+
fill.style.background = `rgb(${r}, ${g}, 30)`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const username = $('wallet-username')?.value;
|
|
298
|
+
if (btn) btn.disabled = !username || password.length < 24;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// =============================================================================
|
|
302
|
+
// Key Derivation
|
|
303
|
+
// =============================================================================
|
|
304
|
+
|
|
305
|
+
async function deriveKeysFromPassword(username, password) {
|
|
306
|
+
const encoder = new TextEncoder();
|
|
307
|
+
const usernameSalt = encoder.encode(username);
|
|
308
|
+
const passwordBytes = encoder.encode(password);
|
|
309
|
+
|
|
310
|
+
const initialHash = await sha256(new Uint8Array([...usernameSalt, ...passwordBytes]));
|
|
311
|
+
const masterKey = await hkdf(initialHash, usernameSalt, encoder.encode('master-key'), 32);
|
|
312
|
+
|
|
313
|
+
state.encryptionKey = await hkdf(masterKey, new Uint8Array(0), encoder.encode('buffer-encryption-key'), 32);
|
|
314
|
+
state.encryptionIV = await hkdf(masterKey, new Uint8Array(0), encoder.encode('buffer-encryption-iv'), 16);
|
|
315
|
+
|
|
316
|
+
// Create 64-byte seed for HD wallet (password-based, not BIP39)
|
|
317
|
+
const hdSeed = await hkdf(masterKey, new Uint8Array(0), encoder.encode('hd-wallet-seed'), 64);
|
|
318
|
+
state.masterSeed = hdSeed;
|
|
319
|
+
state.hdRoot = state.hdWalletModule.hdkey.fromSeed(hdSeed);
|
|
320
|
+
state.mnemonic = null; // Not available for password-derived wallets
|
|
321
|
+
console.log('HD wallet initialized from password, hdRoot:', !!state.hdRoot);
|
|
322
|
+
|
|
323
|
+
const keys = deriveKeysFromHDRoot(state.hdRoot);
|
|
324
|
+
// Also derive auxiliary keys for encryption / key agreement
|
|
325
|
+
keys.x25519 = generateKeyPair(Curve.X25519);
|
|
326
|
+
keys.p256 = await p256GenerateKeyPairAsync();
|
|
327
|
+
keys.p384 = await p384GenerateKeyPairAsync();
|
|
328
|
+
|
|
329
|
+
return keys;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function deriveKeysFromSeed(seedPhrase) {
|
|
333
|
+
const seed = state.hdWalletModule.mnemonic.toSeed(seedPhrase);
|
|
334
|
+
const encoder = new TextEncoder();
|
|
335
|
+
|
|
336
|
+
const masterKey = await hkdf(
|
|
337
|
+
new Uint8Array(seed.slice(0, 32)),
|
|
338
|
+
new Uint8Array(0),
|
|
339
|
+
encoder.encode('wallet-master'),
|
|
340
|
+
32
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
state.encryptionKey = await hkdf(masterKey, new Uint8Array(0), encoder.encode('buffer-encryption-key'), 32);
|
|
344
|
+
state.encryptionIV = await hkdf(masterKey, new Uint8Array(0), encoder.encode('buffer-encryption-iv'), 16);
|
|
345
|
+
|
|
346
|
+
state.masterSeed = new Uint8Array(seed);
|
|
347
|
+
state.hdRoot = state.hdWalletModule.hdkey.fromSeed(new Uint8Array(seed));
|
|
348
|
+
state.mnemonic = seedPhrase;
|
|
349
|
+
console.log('HD wallet initialized from seed phrase, hdRoot:', !!state.hdRoot);
|
|
350
|
+
|
|
351
|
+
const keys = deriveKeysFromHDRoot(state.hdRoot);
|
|
352
|
+
keys.x25519 = generateKeyPair(Curve.X25519);
|
|
353
|
+
keys.p256 = await p256GenerateKeyPairAsync();
|
|
354
|
+
keys.p384 = await p384GenerateKeyPairAsync();
|
|
355
|
+
|
|
356
|
+
return keys;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Derive secp256k1 and ed25519 signing keys from the HD root using BIP44 signing paths.
|
|
361
|
+
* This ensures addresses match what the HD derivation grid produces.
|
|
362
|
+
*/
|
|
363
|
+
function deriveKeysFromHDRoot(hdRoot) {
|
|
364
|
+
// BTC signing path m/44'/0'/0'/0/0 — secp256k1
|
|
365
|
+
const btcKey = hdRoot.derivePath(buildSigningPath(0, 0, 0));
|
|
366
|
+
const secp256k1PrivKey = btcKey.privateKey();
|
|
367
|
+
const secp256k1PubKey = btcKey.publicKey();
|
|
368
|
+
|
|
369
|
+
// SOL signing path m/44'/501'/0'/0/0 — ed25519
|
|
370
|
+
const solKey = hdRoot.derivePath(buildSigningPath(501, 0, 0));
|
|
371
|
+
const ed25519PrivKey = solKey.privateKey();
|
|
372
|
+
const ed25519PubKey = ed25519.getPublicKey(ed25519PrivKey);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
secp256k1: { privateKey: secp256k1PrivKey, publicKey: secp256k1PubKey },
|
|
376
|
+
ed25519: { privateKey: ed25519PrivKey, publicKey: ed25519PubKey },
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Derive all blockchain addresses from the HD root using signing paths.
|
|
382
|
+
* Each coin uses its own BIP44 signing path: m/44'/{coinType}'/0'/0/0
|
|
383
|
+
*/
|
|
384
|
+
function deriveAllAddressesFromHD() {
|
|
385
|
+
if (!state.hdRoot) return {};
|
|
386
|
+
|
|
387
|
+
const deriveAddress = (coinType) => {
|
|
388
|
+
try {
|
|
389
|
+
const path = buildSigningPath(coinType, 0, 0);
|
|
390
|
+
const derived = state.hdRoot.derivePath(path);
|
|
391
|
+
const pubKey = derived.publicKey();
|
|
392
|
+
return generateAddressForCoin(pubKey, coinType);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
console.error(`Failed to derive address for coinType ${coinType}:`, e);
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// For SOL, derive ed25519 key and use it directly
|
|
400
|
+
let solAddress = null;
|
|
401
|
+
try {
|
|
402
|
+
const solPath = buildSigningPath(501, 0, 0);
|
|
403
|
+
const solDerived = state.hdRoot.derivePath(solPath);
|
|
404
|
+
const solPrivKey = solDerived.privateKey();
|
|
405
|
+
solAddress = generateSolAddress(ed25519.getPublicKey(solPrivKey));
|
|
406
|
+
} catch (e) {
|
|
407
|
+
console.error('Failed to derive SOL address:', e);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
btc: deriveAddress(0),
|
|
412
|
+
eth: deriveAddress(60),
|
|
413
|
+
sol: solAddress,
|
|
414
|
+
// xrp: deriveAddress(144), // Commented out — BTC/ETH/SOL only
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function generateSeedPhrase() {
|
|
419
|
+
return state.hdWalletModule.mnemonic.generate(24);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function validateSeedPhrase(phrase) {
|
|
423
|
+
return state.hdWalletModule.mnemonic.validate(phrase.trim().toLowerCase());
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// =============================================================================
|
|
427
|
+
// HD Wallet Derivation
|
|
428
|
+
// =============================================================================
|
|
429
|
+
|
|
430
|
+
function deriveHDKey(path) {
|
|
431
|
+
if (!state.hdRoot) {
|
|
432
|
+
throw new Error('HD wallet not initialized');
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
return state.hdRoot.derivePath(path);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
console.error('HD derivation error:', e, 'path:', path);
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function updatePathDisplay() {
|
|
443
|
+
const coin = $('hd-coin')?.value;
|
|
444
|
+
const account = $('hd-account')?.value || '0';
|
|
445
|
+
const index = $('hd-index')?.value || '0';
|
|
446
|
+
|
|
447
|
+
const signingPath = buildSigningPath(coin, account, index);
|
|
448
|
+
const encryptionPath = buildEncryptionPath(coin, account, index);
|
|
449
|
+
|
|
450
|
+
const signingPathEl = $('signing-path');
|
|
451
|
+
const encryptionPathEl = $('encryption-path');
|
|
452
|
+
|
|
453
|
+
if (signingPathEl) signingPathEl.textContent = signingPath;
|
|
454
|
+
if (encryptionPathEl) encryptionPathEl.textContent = encryptionPath;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function deriveAndDisplayAddress() {
|
|
458
|
+
console.log('deriveAndDisplayAddress called, hdRoot:', !!state.hdRoot);
|
|
459
|
+
|
|
460
|
+
const hdNotInitialized = $('hd-not-initialized');
|
|
461
|
+
const derivedResult = $('derived-result');
|
|
462
|
+
|
|
463
|
+
if (!state.hdRoot) {
|
|
464
|
+
console.log('HD not initialized, showing warning.');
|
|
465
|
+
if (hdNotInitialized) hdNotInitialized.style.display = 'block';
|
|
466
|
+
if (derivedResult) derivedResult.style.display = 'none';
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (hdNotInitialized) hdNotInitialized.style.display = 'none';
|
|
471
|
+
|
|
472
|
+
const coin = $('hd-coin')?.value;
|
|
473
|
+
const account = $('hd-account')?.value || '0';
|
|
474
|
+
const index = $('hd-index')?.value || '0';
|
|
475
|
+
const coinType = parseInt(coin);
|
|
476
|
+
const coinOption = $('hd-coin')?.selectedOptions[0];
|
|
477
|
+
const cryptoName = coinOption?.dataset.name || 'Unknown';
|
|
478
|
+
const cryptoSymbol = coinOption?.dataset.symbol || '???';
|
|
479
|
+
|
|
480
|
+
const signingPath = buildSigningPath(coin, account, index);
|
|
481
|
+
const encryptionPath = buildEncryptionPath(coin, account, index);
|
|
482
|
+
|
|
483
|
+
console.log('Deriving signing path:', signingPath);
|
|
484
|
+
console.log('Deriving encryption path:', encryptionPath);
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const signingKey = deriveHDKey(signingPath);
|
|
488
|
+
const signingPubKey = signingKey.publicKey();
|
|
489
|
+
|
|
490
|
+
const encryptionKey = deriveHDKey(encryptionPath);
|
|
491
|
+
const encryptionPubKey = encryptionKey.publicKey();
|
|
492
|
+
|
|
493
|
+
const address = generateAddressForCoin(signingPubKey, coinType);
|
|
494
|
+
console.log('Generated address:', address);
|
|
495
|
+
|
|
496
|
+
const config = coinTypeToConfig[coinType];
|
|
497
|
+
const explorerUrl = config ? config.explorer + address : null;
|
|
498
|
+
|
|
499
|
+
if (derivedResult) derivedResult.style.display = 'block';
|
|
500
|
+
|
|
501
|
+
const signingPathEl = $('signing-path');
|
|
502
|
+
const encryptionPathEl = $('encryption-path');
|
|
503
|
+
if (signingPathEl) signingPathEl.textContent = signingPath;
|
|
504
|
+
if (encryptionPathEl) encryptionPathEl.textContent = encryptionPath;
|
|
505
|
+
|
|
506
|
+
const signingPubkeyEl = $('signing-pubkey');
|
|
507
|
+
const encryptionPubkeyEl = $('encryption-pubkey');
|
|
508
|
+
if (signingPubkeyEl) signingPubkeyEl.textContent = toHexCompact(signingPubKey);
|
|
509
|
+
if (encryptionPubkeyEl) encryptionPubkeyEl.textContent = toHexCompact(encryptionPubKey);
|
|
510
|
+
|
|
511
|
+
const derivedCryptoName = $('derived-crypto-name');
|
|
512
|
+
const derivedIcon = $('derived-icon');
|
|
513
|
+
const derivedAddress = $('derived-address');
|
|
514
|
+
if (derivedCryptoName) derivedCryptoName.textContent = cryptoName;
|
|
515
|
+
if (derivedIcon) derivedIcon.textContent = cryptoSymbol.substring(0, 2);
|
|
516
|
+
if (derivedAddress) derivedAddress.textContent = address;
|
|
517
|
+
|
|
518
|
+
const explorerLink = $('derived-explorer-link');
|
|
519
|
+
if (explorerLink) {
|
|
520
|
+
if (explorerUrl) {
|
|
521
|
+
explorerLink.href = explorerUrl;
|
|
522
|
+
explorerLink.style.display = 'inline-flex';
|
|
523
|
+
} else {
|
|
524
|
+
explorerLink.style.display = 'none';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Generate QR code
|
|
529
|
+
try {
|
|
530
|
+
const qrCanvas = $('address-qr');
|
|
531
|
+
if (qrCanvas) {
|
|
532
|
+
await QRCode.toCanvas(qrCanvas, address, {
|
|
533
|
+
width: 64,
|
|
534
|
+
margin: 1,
|
|
535
|
+
color: { dark: '#1e293b', light: '#ffffff' },
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
} catch (qrErr) {
|
|
539
|
+
console.warn('QR generation failed:', qrErr);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error('Derivation failed:', err);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// =============================================================================
|
|
548
|
+
// PKI Key Derivation from HD Wallet
|
|
549
|
+
// =============================================================================
|
|
550
|
+
|
|
551
|
+
function deriveKeyFromPath(path) {
|
|
552
|
+
if (!state.hdRoot) {
|
|
553
|
+
throw new Error('HD wallet not initialized');
|
|
554
|
+
}
|
|
555
|
+
const derived = state.hdRoot.derivePath(path);
|
|
556
|
+
return derived.privateKey();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function deriveX25519FromSeed(seed) {
|
|
560
|
+
const privateKey = new Uint8Array(seed);
|
|
561
|
+
const publicKey = x25519.getPublicKey(privateKey);
|
|
562
|
+
return {
|
|
563
|
+
privateKey,
|
|
564
|
+
publicKey: new Uint8Array(publicKey),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function deriveSecp256k1FromSeed(seed) {
|
|
569
|
+
const privateKey = new Uint8Array(seed);
|
|
570
|
+
const publicKey = secp256k1.getPublicKey(privateKey, true);
|
|
571
|
+
return {
|
|
572
|
+
privateKey,
|
|
573
|
+
publicKey: new Uint8Array(publicKey),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function deriveP256FromSeed(seed) {
|
|
578
|
+
const privateKey = new Uint8Array(seed);
|
|
579
|
+
const publicKey = p256.getPublicKey(privateKey, true);
|
|
580
|
+
return {
|
|
581
|
+
privateKey,
|
|
582
|
+
publicKey: new Uint8Array(publicKey),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function derivePKIKeysFromHD() {
|
|
587
|
+
if (!state.hdRoot) {
|
|
588
|
+
console.warn('HD wallet not initialized, cannot derive PKI keys');
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const algorithm = $('pki-algorithm')?.value || 'x25519';
|
|
593
|
+
state.pki.algorithm = algorithm;
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const alicePath = "m/44'/0'/0'/0/0";
|
|
597
|
+
const bobPath = "m/44'/0'/0'/0/1";
|
|
598
|
+
|
|
599
|
+
const aliceSeed = deriveKeyFromPath(alicePath);
|
|
600
|
+
const bobSeed = deriveKeyFromPath(bobPath);
|
|
601
|
+
|
|
602
|
+
switch (algorithm) {
|
|
603
|
+
case 'x25519':
|
|
604
|
+
state.pki.alice = deriveX25519FromSeed(aliceSeed);
|
|
605
|
+
state.pki.bob = deriveX25519FromSeed(bobSeed);
|
|
606
|
+
break;
|
|
607
|
+
case 'secp256k1':
|
|
608
|
+
state.pki.alice = deriveSecp256k1FromSeed(aliceSeed);
|
|
609
|
+
state.pki.bob = deriveSecp256k1FromSeed(bobSeed);
|
|
610
|
+
break;
|
|
611
|
+
case 'p256':
|
|
612
|
+
state.pki.alice = deriveP256FromSeed(aliceSeed);
|
|
613
|
+
state.pki.bob = deriveP256FromSeed(bobSeed);
|
|
614
|
+
break;
|
|
615
|
+
default:
|
|
616
|
+
state.pki.alice = deriveX25519FromSeed(aliceSeed);
|
|
617
|
+
state.pki.bob = deriveX25519FromSeed(bobSeed);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return true;
|
|
621
|
+
} catch (e) {
|
|
622
|
+
console.error('Failed to derive PKI keys from HD:', e);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function savePKIKeys() {
|
|
628
|
+
if (!state.pki.alice || !state.pki.bob) {
|
|
629
|
+
console.warn('Cannot save PKI keys: alice or bob is null');
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const data = {
|
|
634
|
+
algorithm: state.pki.algorithm,
|
|
635
|
+
alice: {
|
|
636
|
+
publicKey: toHexCompact(state.pki.alice.publicKey),
|
|
637
|
+
privateKey: toHexCompact(state.pki.alice.privateKey),
|
|
638
|
+
},
|
|
639
|
+
bob: {
|
|
640
|
+
publicKey: toHexCompact(state.pki.bob.publicKey),
|
|
641
|
+
privateKey: toHexCompact(state.pki.bob.privateKey),
|
|
642
|
+
},
|
|
643
|
+
savedAt: new Date().toISOString(),
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
if (state.encryptionKey && state.encryptionIV) {
|
|
647
|
+
data.encryptionKey = toHexCompact(state.encryptionKey);
|
|
648
|
+
data.encryptionIV = toHexCompact(state.encryptionIV);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
localStorage.setItem(PKI_STORAGE_KEY, JSON.stringify(data));
|
|
653
|
+
} catch (e) {
|
|
654
|
+
console.warn('Failed to save PKI keys to localStorage:', e);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function loadPKIKeys() {
|
|
659
|
+
try {
|
|
660
|
+
const stored = localStorage.getItem(PKI_STORAGE_KEY);
|
|
661
|
+
if (!stored) return false;
|
|
662
|
+
|
|
663
|
+
const data = JSON.parse(stored);
|
|
664
|
+
if (!data.alice || !data.bob || !data.algorithm) {
|
|
665
|
+
console.warn('Invalid PKI data in localStorage');
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
state.pki.algorithm = data.algorithm;
|
|
670
|
+
state.pki.alice = {
|
|
671
|
+
publicKey: hexToBytes(data.alice.publicKey),
|
|
672
|
+
privateKey: hexToBytes(data.alice.privateKey),
|
|
673
|
+
};
|
|
674
|
+
state.pki.bob = {
|
|
675
|
+
publicKey: hexToBytes(data.bob.publicKey),
|
|
676
|
+
privateKey: hexToBytes(data.bob.privateKey),
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
if (data.encryptionKey && data.encryptionIV) {
|
|
680
|
+
state.encryptionKey = hexToBytes(data.encryptionKey);
|
|
681
|
+
state.encryptionIV = hexToBytes(data.encryptionIV);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Update UI
|
|
685
|
+
const alicePublicKey = $('alice-public-key');
|
|
686
|
+
const alicePrivateKey = $('alice-private-key');
|
|
687
|
+
const bobPublicKey = $('bob-public-key');
|
|
688
|
+
const bobPrivateKey = $('bob-private-key');
|
|
689
|
+
const pkiParties = $('pki-parties');
|
|
690
|
+
const pkiDemo = $('pki-demo');
|
|
691
|
+
const pkiSecurity = $('pki-security');
|
|
692
|
+
const pkiClearKeys = $('pki-clear-keys');
|
|
693
|
+
|
|
694
|
+
const pkiAlgorithm = $('pki-algorithm');
|
|
695
|
+
if (pkiAlgorithm) pkiAlgorithm.value = data.algorithm;
|
|
696
|
+
if (alicePublicKey) alicePublicKey.textContent = data.alice.publicKey;
|
|
697
|
+
if (alicePrivateKey) alicePrivateKey.textContent = data.alice.privateKey;
|
|
698
|
+
if (bobPublicKey) bobPublicKey.textContent = data.bob.publicKey;
|
|
699
|
+
if (bobPrivateKey) bobPrivateKey.textContent = data.bob.privateKey;
|
|
700
|
+
if (pkiParties) pkiParties.style.display = 'grid';
|
|
701
|
+
if (pkiDemo) pkiDemo.style.display = 'block';
|
|
702
|
+
if (pkiSecurity) pkiSecurity.style.display = 'block';
|
|
703
|
+
if (pkiClearKeys) pkiClearKeys.style.display = 'inline-flex';
|
|
704
|
+
|
|
705
|
+
return true;
|
|
706
|
+
} catch (e) {
|
|
707
|
+
console.warn('Failed to load PKI keys from localStorage:', e);
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function clearPKIKeys() {
|
|
713
|
+
try {
|
|
714
|
+
localStorage.removeItem(PKI_STORAGE_KEY);
|
|
715
|
+
} catch (e) {
|
|
716
|
+
console.warn('Failed to clear PKI keys:', e);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
state.pki.alice = null;
|
|
720
|
+
state.pki.bob = null;
|
|
721
|
+
state.pki.algorithm = 'x25519';
|
|
722
|
+
|
|
723
|
+
const els = ['alice-public-key', 'alice-private-key', 'bob-public-key', 'bob-private-key'];
|
|
724
|
+
els.forEach(id => {
|
|
725
|
+
const el = $(id);
|
|
726
|
+
if (el) el.textContent = '--';
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const loginPrompt = $('pki-login-prompt');
|
|
730
|
+
if (loginPrompt) loginPrompt.style.display = 'block';
|
|
731
|
+
const pkiControls = $('pki-controls');
|
|
732
|
+
if (pkiControls) pkiControls.style.display = 'none';
|
|
733
|
+
const pkiParties = $('pki-parties');
|
|
734
|
+
if (pkiParties) pkiParties.style.display = 'none';
|
|
735
|
+
const pkiDemo = $('pki-demo');
|
|
736
|
+
if (pkiDemo) pkiDemo.style.display = 'none';
|
|
737
|
+
const pkiSecurity = $('pki-security');
|
|
738
|
+
if (pkiSecurity) pkiSecurity.style.display = 'none';
|
|
739
|
+
const pkiClearKeys = $('pki-clear-keys');
|
|
740
|
+
if (pkiClearKeys) pkiClearKeys.style.display = 'none';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function generatePKIKeyPairs() {
|
|
744
|
+
// First try to derive from HD wallet
|
|
745
|
+
if (state.hdRoot && derivePKIKeysFromHD()) {
|
|
746
|
+
// PKI keys derived from HD wallet
|
|
747
|
+
} else {
|
|
748
|
+
// Fallback to random generation
|
|
749
|
+
const algorithm = $('pki-algorithm')?.value || 'x25519';
|
|
750
|
+
state.pki.algorithm = algorithm;
|
|
751
|
+
|
|
752
|
+
try {
|
|
753
|
+
if (algorithm === 'p256') {
|
|
754
|
+
state.pki.alice = await p256GenerateKeyPairAsync();
|
|
755
|
+
state.pki.bob = await p256GenerateKeyPairAsync();
|
|
756
|
+
} else if (algorithm === 'p384') {
|
|
757
|
+
state.pki.alice = await p384GenerateKeyPairAsync();
|
|
758
|
+
state.pki.bob = await p384GenerateKeyPairAsync();
|
|
759
|
+
} else {
|
|
760
|
+
const curveType = algorithm === 'secp256k1' ? Curve.SECP256K1 : Curve.X25519;
|
|
761
|
+
state.pki.alice = generateKeyPair(curveType);
|
|
762
|
+
state.pki.bob = generateKeyPair(curveType);
|
|
763
|
+
}
|
|
764
|
+
} catch (e) {
|
|
765
|
+
console.error('Failed to generate PKI keys:', e);
|
|
766
|
+
alert('Failed to generate keys: ' + e.message);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
savePKIKeys();
|
|
772
|
+
|
|
773
|
+
// Display keys
|
|
774
|
+
const alicePub = $('alice-public-key');
|
|
775
|
+
const alicePriv = $('alice-private-key');
|
|
776
|
+
const bobPub = $('bob-public-key');
|
|
777
|
+
const bobPriv = $('bob-private-key');
|
|
778
|
+
if (alicePub) alicePub.textContent = toHexCompact(state.pki.alice.publicKey);
|
|
779
|
+
if (alicePriv) alicePriv.textContent = toHexCompact(state.pki.alice.privateKey);
|
|
780
|
+
if (bobPub) bobPub.textContent = toHexCompact(state.pki.bob.publicKey);
|
|
781
|
+
if (bobPriv) bobPriv.textContent = toHexCompact(state.pki.bob.privateKey);
|
|
782
|
+
|
|
783
|
+
const algorithmNames = {
|
|
784
|
+
x25519: 'X25519 (Curve25519)',
|
|
785
|
+
secp256k1: 'secp256k1 (Bitcoin/Ethereum)',
|
|
786
|
+
p256: 'P-256 / secp256r1 (NIST)',
|
|
787
|
+
p384: 'P-384 / secp384r1 (NIST)',
|
|
788
|
+
};
|
|
789
|
+
const algDisplay = $('pki-algorithm-display');
|
|
790
|
+
if (algDisplay) algDisplay.textContent = algorithmNames[state.pki.algorithm] || state.pki.algorithm;
|
|
791
|
+
|
|
792
|
+
const selector = $('pki-algorithm');
|
|
793
|
+
if (selector) selector.value = state.pki.algorithm;
|
|
794
|
+
|
|
795
|
+
// Show UI sections
|
|
796
|
+
const loginPrompt = $('pki-login-prompt');
|
|
797
|
+
if (loginPrompt) loginPrompt.style.display = 'none';
|
|
798
|
+
const pkiControls = $('pki-controls');
|
|
799
|
+
if (pkiControls) pkiControls.style.display = 'flex';
|
|
800
|
+
const pkiParties = $('pki-parties');
|
|
801
|
+
if (pkiParties) pkiParties.style.display = 'grid';
|
|
802
|
+
const pkiDemo = $('pki-demo');
|
|
803
|
+
if (pkiDemo) pkiDemo.style.display = 'block';
|
|
804
|
+
const pkiSecurity = $('pki-security');
|
|
805
|
+
if (pkiSecurity) pkiSecurity.style.display = 'block';
|
|
806
|
+
const pkiClearKeys = $('pki-clear-keys');
|
|
807
|
+
if (pkiClearKeys) pkiClearKeys.style.display = 'inline-flex';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// =============================================================================
|
|
811
|
+
// Login / Logout
|
|
812
|
+
// =============================================================================
|
|
813
|
+
|
|
814
|
+
function login(keys) {
|
|
815
|
+
state.loggedIn = true;
|
|
816
|
+
state.wallet = keys;
|
|
817
|
+
state.addresses = deriveAllAddressesFromHD();
|
|
818
|
+
state.selectedCrypto = 'btc';
|
|
819
|
+
|
|
820
|
+
// Close login modal if open
|
|
821
|
+
$('login-modal')?.classList.remove('active');
|
|
822
|
+
|
|
823
|
+
// Update hero stats display
|
|
824
|
+
const heroWalletType = $('hero-wallet-type');
|
|
825
|
+
const heroAddress = $('hero-address');
|
|
826
|
+
const heroStats = $('hero-stats');
|
|
827
|
+
if (heroWalletType) heroWalletType.textContent = cryptoConfig[state.selectedCrypto].name;
|
|
828
|
+
if (heroAddress) heroAddress.textContent = truncateAddress(state.addresses[state.selectedCrypto]);
|
|
829
|
+
if (heroStats) heroStats.classList.remove('hidden');
|
|
830
|
+
|
|
831
|
+
// Show nav action buttons, hide login button
|
|
832
|
+
const navLogin = $('nav-login');
|
|
833
|
+
const navKeys = $('nav-keys');
|
|
834
|
+
const navLogout = $('nav-logout');
|
|
835
|
+
if (navLogin) navLogin.style.display = 'none';
|
|
836
|
+
if (navKeys) navKeys.style.display = 'flex';
|
|
837
|
+
if (navLogout) navLogout.style.display = 'flex';
|
|
838
|
+
|
|
839
|
+
// Update mobile menu buttons
|
|
840
|
+
const mobileLogin = $('mobile-login');
|
|
841
|
+
const mobileLogout = $('mobile-logout');
|
|
842
|
+
if (mobileLogin) mobileLogin.style.display = 'none';
|
|
843
|
+
if (mobileLogout) mobileLogout.style.display = 'block';
|
|
844
|
+
|
|
845
|
+
// Update HD wallet root keys display
|
|
846
|
+
if (state.hdRoot) {
|
|
847
|
+
const xpubEl = $('wallet-xpub');
|
|
848
|
+
const xprvEl = $('wallet-xprv');
|
|
849
|
+
const seedEl = $('wallet-seed-phrase');
|
|
850
|
+
|
|
851
|
+
if (xpubEl) {
|
|
852
|
+
setTruncatedValue(xpubEl, state.hdRoot.toXpub() || 'N/A');
|
|
853
|
+
}
|
|
854
|
+
const keysXpubEl = $('keys-xpub');
|
|
855
|
+
if (keysXpubEl) {
|
|
856
|
+
setTruncatedValue(keysXpubEl, state.hdRoot.toXpub() || 'N/A');
|
|
857
|
+
}
|
|
858
|
+
populateAccountAddressDropdown();
|
|
859
|
+
if (xprvEl) {
|
|
860
|
+
setTruncatedValue(xprvEl, state.hdRoot.toXprv() || 'N/A');
|
|
861
|
+
xprvEl.dataset.revealed = 'false';
|
|
862
|
+
}
|
|
863
|
+
if (seedEl && state.mnemonic) {
|
|
864
|
+
seedEl.textContent = state.mnemonic;
|
|
865
|
+
seedEl.dataset.revealed = 'false';
|
|
866
|
+
} else if (seedEl) {
|
|
867
|
+
seedEl.textContent = 'Not available (derived from password)';
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Derive PKI keys from HD wallet if available
|
|
872
|
+
if (state.hdRoot) {
|
|
873
|
+
generatePKIKeyPairs();
|
|
874
|
+
} else if (state.pki.alice && state.pki.bob) {
|
|
875
|
+
const alicePub = $('alice-public-key');
|
|
876
|
+
const alicePriv = $('alice-private-key');
|
|
877
|
+
const bobPub = $('bob-public-key');
|
|
878
|
+
const bobPriv = $('bob-private-key');
|
|
879
|
+
if (alicePub) alicePub.textContent = toHexCompact(state.pki.alice.publicKey);
|
|
880
|
+
if (alicePriv) alicePriv.textContent = toHexCompact(state.pki.alice.privateKey);
|
|
881
|
+
if (bobPub) bobPub.textContent = toHexCompact(state.pki.bob.publicKey);
|
|
882
|
+
if (bobPriv) bobPriv.textContent = toHexCompact(state.pki.bob.privateKey);
|
|
883
|
+
|
|
884
|
+
const algorithmNames = {
|
|
885
|
+
x25519: 'X25519 (Curve25519)',
|
|
886
|
+
secp256k1: 'secp256k1 (Bitcoin)',
|
|
887
|
+
p256: 'P-256 (NIST)',
|
|
888
|
+
p384: 'P-384 (NIST)',
|
|
889
|
+
};
|
|
890
|
+
const algDisplay = $('pki-algorithm-display');
|
|
891
|
+
if (algDisplay) algDisplay.textContent = algorithmNames[state.pki.algorithm] || state.pki.algorithm;
|
|
892
|
+
const loginPrompt = $('pki-login-prompt');
|
|
893
|
+
if (loginPrompt) loginPrompt.style.display = 'none';
|
|
894
|
+
const pkiControls = $('pki-controls');
|
|
895
|
+
if (pkiControls) pkiControls.style.display = 'flex';
|
|
896
|
+
const pkiParties = $('pki-parties');
|
|
897
|
+
if (pkiParties) pkiParties.style.display = 'grid';
|
|
898
|
+
const pkiDemo = $('pki-demo');
|
|
899
|
+
if (pkiDemo) pkiDemo.style.display = 'block';
|
|
900
|
+
const pkiSecurity = $('pki-security');
|
|
901
|
+
if (pkiSecurity) pkiSecurity.style.display = 'block';
|
|
902
|
+
const pkiClearKeys = $('pki-clear-keys');
|
|
903
|
+
if (pkiClearKeys) pkiClearKeys.style.display = 'inline-flex';
|
|
904
|
+
} else if (!loadPKIKeys()) {
|
|
905
|
+
generatePKIKeyPairs();
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Update wallet addresses and balances
|
|
909
|
+
updateAdversarialSecurity();
|
|
910
|
+
|
|
911
|
+
// Populate vCard keys display
|
|
912
|
+
populateVCardKeysDisplay();
|
|
913
|
+
|
|
914
|
+
// Open Account modal so user can see the wallet they just loaded
|
|
915
|
+
$('keys-modal')?.classList.add('active');
|
|
916
|
+
deriveAndDisplayAddress();
|
|
917
|
+
|
|
918
|
+
// Resolve names and update title
|
|
919
|
+
clearNameCache();
|
|
920
|
+
resolveNames().then(names => updateAccountTitle(names));
|
|
921
|
+
|
|
922
|
+
// Start trust auto-scanning
|
|
923
|
+
if (state._startTrustScanning) state._startTrustScanning();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function logout() {
|
|
927
|
+
// Stop trust auto-scanning
|
|
928
|
+
if (state._stopTrustScanning) state._stopTrustScanning();
|
|
929
|
+
|
|
930
|
+
clearNameCache();
|
|
931
|
+
const titleEl = $('account-title');
|
|
932
|
+
if (titleEl) titleEl.textContent = 'Account';
|
|
933
|
+
state.loggedIn = false;
|
|
934
|
+
state.wallet = { x25519: null, ed25519: null, secp256k1: null, p256: null };
|
|
935
|
+
state.encryptionKey = null;
|
|
936
|
+
state.encryptionIV = null;
|
|
937
|
+
state.masterSeed = null;
|
|
938
|
+
state.hdRoot = null;
|
|
939
|
+
state.mnemonic = null;
|
|
940
|
+
|
|
941
|
+
localStorage.removeItem(PKI_STORAGE_KEY);
|
|
942
|
+
|
|
943
|
+
// Update hero stats
|
|
944
|
+
const heroWalletType = $('hero-wallet-type');
|
|
945
|
+
const heroAddress = $('hero-address');
|
|
946
|
+
const heroStats = $('hero-stats');
|
|
947
|
+
if (heroWalletType) heroWalletType.textContent = '--';
|
|
948
|
+
if (heroAddress) heroAddress.textContent = '--';
|
|
949
|
+
if (heroStats) heroStats.classList.add('hidden');
|
|
950
|
+
|
|
951
|
+
// Show login button, hide other nav action buttons
|
|
952
|
+
const navLogin = $('nav-login');
|
|
953
|
+
const navKeys = $('nav-keys');
|
|
954
|
+
const navLogout = $('nav-logout');
|
|
955
|
+
if (navLogin) navLogin.style.display = 'flex';
|
|
956
|
+
if (navKeys) navKeys.style.display = 'none';
|
|
957
|
+
if (navLogout) navLogout.style.display = 'none';
|
|
958
|
+
|
|
959
|
+
// Update mobile menu buttons
|
|
960
|
+
const mobileLogin = $('mobile-login');
|
|
961
|
+
const mobileLogout = $('mobile-logout');
|
|
962
|
+
if (mobileLogin) mobileLogin.style.display = 'block';
|
|
963
|
+
if (mobileLogout) mobileLogout.style.display = 'none';
|
|
964
|
+
|
|
965
|
+
// Clear form inputs
|
|
966
|
+
const usernameEl = $('wallet-username');
|
|
967
|
+
const passwordEl = $('wallet-password');
|
|
968
|
+
const seedEl = $('seed-phrase');
|
|
969
|
+
if (usernameEl) usernameEl.value = '';
|
|
970
|
+
if (passwordEl) passwordEl.value = '';
|
|
971
|
+
if (seedEl) seedEl.value = '';
|
|
972
|
+
updatePasswordStrength('');
|
|
973
|
+
|
|
974
|
+
// Clear HD wallet UI
|
|
975
|
+
const derivedResult = $('derived-result');
|
|
976
|
+
if (derivedResult) derivedResult.style.display = 'none';
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// =============================================================================
|
|
980
|
+
// Export Wallet
|
|
981
|
+
// =============================================================================
|
|
982
|
+
|
|
983
|
+
async function exportWallet(format) {
|
|
984
|
+
if (!state.loggedIn) {
|
|
985
|
+
alert('Please log in first to export wallet data.');
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let data, filename, mimeType;
|
|
990
|
+
|
|
991
|
+
switch (format) {
|
|
992
|
+
case 'mnemonic':
|
|
993
|
+
if (!state.mnemonic) {
|
|
994
|
+
alert('Seed phrase not available. This wallet was derived from a password.');
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
data = state.mnemonic;
|
|
998
|
+
filename = 'wallet-seed-phrase.txt';
|
|
999
|
+
mimeType = 'text/plain';
|
|
1000
|
+
break;
|
|
1001
|
+
|
|
1002
|
+
case 'xpub':
|
|
1003
|
+
if (!state.hdRoot?.publicExtendedKey) {
|
|
1004
|
+
alert('Extended public key not available.');
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
data = state.hdRoot.toXpub();
|
|
1008
|
+
filename = 'wallet-xpub.txt';
|
|
1009
|
+
mimeType = 'text/plain';
|
|
1010
|
+
break;
|
|
1011
|
+
|
|
1012
|
+
case 'xprv':
|
|
1013
|
+
if (!state.hdRoot?.privateExtendedKey) {
|
|
1014
|
+
alert('Extended private key not available.');
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!confirm('Warning: You are about to export your master private key. Anyone with this key can access all your funds. Continue?')) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
data = state.hdRoot.toXprv();
|
|
1021
|
+
filename = 'wallet-xprv.txt';
|
|
1022
|
+
mimeType = 'text/plain';
|
|
1023
|
+
break;
|
|
1024
|
+
|
|
1025
|
+
case 'hex':
|
|
1026
|
+
if (!state.masterSeed) {
|
|
1027
|
+
alert('Master seed not available.');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (!confirm('Warning: You are about to export your raw master seed in hex format. This is extremely sensitive data. Continue?')) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
data = toHexCompact(state.masterSeed);
|
|
1034
|
+
filename = 'wallet-seed-hex.txt';
|
|
1035
|
+
mimeType = 'text/plain';
|
|
1036
|
+
break;
|
|
1037
|
+
|
|
1038
|
+
default:
|
|
1039
|
+
alert('Unknown export format: ' + format);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Download the file
|
|
1044
|
+
downloadData(data, filename, mimeType);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function downloadData(data, filename, mimeType) {
|
|
1048
|
+
const blob = new Blob([data], { type: mimeType });
|
|
1049
|
+
const url = URL.createObjectURL(blob);
|
|
1050
|
+
const a = document.createElement('a');
|
|
1051
|
+
a.href = url;
|
|
1052
|
+
a.download = filename;
|
|
1053
|
+
document.body.appendChild(a);
|
|
1054
|
+
a.click();
|
|
1055
|
+
document.body.removeChild(a);
|
|
1056
|
+
URL.revokeObjectURL(url);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// =============================================================================
|
|
1060
|
+
// Wallet Address Population & Balance Fetching
|
|
1061
|
+
// =============================================================================
|
|
1062
|
+
|
|
1063
|
+
// Account address dropdown — populated once after login, updated when balances arrive
|
|
1064
|
+
let _accountAddressData = {}; // { xpub: { addr, value }, btc: { addr, value }, ... }
|
|
1065
|
+
|
|
1066
|
+
function populateAccountAddressDropdown() {
|
|
1067
|
+
const sel = $('account-address-select');
|
|
1068
|
+
if (!sel) return;
|
|
1069
|
+
|
|
1070
|
+
const xpubStr = state.hdRoot ? state.hdRoot.toXpub() : '';
|
|
1071
|
+
const addrs = state.addresses || {};
|
|
1072
|
+
|
|
1073
|
+
const networks = [
|
|
1074
|
+
{ key: 'xpub', label: 'xpub', addr: xpubStr },
|
|
1075
|
+
{ key: 'btc', label: 'Bitcoin', addr: addrs.btc || '' },
|
|
1076
|
+
{ key: 'eth', label: 'Ethereum', addr: addrs.eth || '' },
|
|
1077
|
+
{ key: 'sol', label: 'Solana', addr: addrs.sol || '' },
|
|
1078
|
+
// { key: 'xrp', label: 'Ripple', addr: addrs.xrp || '' },
|
|
1079
|
+
];
|
|
1080
|
+
|
|
1081
|
+
// // Add SUI/Monad/ADA if we can derive them
|
|
1082
|
+
// if (state.hdRoot) {
|
|
1083
|
+
// try {
|
|
1084
|
+
// const suiPath = buildSigningPath(784, 0, 0);
|
|
1085
|
+
// const suiDerived = state.hdRoot.derivePath(suiPath);
|
|
1086
|
+
// const suiPubKey = ed25519.getPublicKey(suiDerived.privateKey());
|
|
1087
|
+
// networks.push({ key: 'sui', label: 'SUI', addr: deriveSuiAddress(suiPubKey, 'ed25519') });
|
|
1088
|
+
// } catch (_) {}
|
|
1089
|
+
// networks.push({ key: 'monad', label: 'Monad', addr: addrs.eth || '' });
|
|
1090
|
+
// try {
|
|
1091
|
+
// const adaPath = buildSigningPath(1815, 0, 0);
|
|
1092
|
+
// const adaDerived = state.hdRoot.derivePath(adaPath);
|
|
1093
|
+
// const adaPubKey = ed25519.getPublicKey(adaDerived.privateKey());
|
|
1094
|
+
// networks.push({ key: 'ada', label: 'Cardano', addr: deriveCardanoAddress(adaPubKey) });
|
|
1095
|
+
// } catch (_) {}
|
|
1096
|
+
// }
|
|
1097
|
+
|
|
1098
|
+
_accountAddressData = {};
|
|
1099
|
+
sel.innerHTML = '';
|
|
1100
|
+
for (const n of networks) {
|
|
1101
|
+
if (!n.addr) continue;
|
|
1102
|
+
_accountAddressData[n.key] = { addr: n.addr, value: '' };
|
|
1103
|
+
const opt = document.createElement('option');
|
|
1104
|
+
opt.value = n.key;
|
|
1105
|
+
opt.textContent = n.label;
|
|
1106
|
+
sel.appendChild(opt);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
sel.removeEventListener('change', updateAccountAddressDisplay);
|
|
1110
|
+
sel.addEventListener('change', updateAccountAddressDisplay);
|
|
1111
|
+
|
|
1112
|
+
const copyBtn = $('account-address-copy');
|
|
1113
|
+
if (copyBtn) {
|
|
1114
|
+
copyBtn.onclick = () => {
|
|
1115
|
+
const key = sel.value;
|
|
1116
|
+
const data = _accountAddressData[key];
|
|
1117
|
+
if (data?.addr) {
|
|
1118
|
+
navigator.clipboard.writeText(data.addr).then(() => {
|
|
1119
|
+
copyBtn.title = 'Copied!';
|
|
1120
|
+
setTimeout(() => { copyBtn.title = 'Copy address'; }, 1500);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
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
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function populateWalletAddresses() {
|
|
1164
|
+
if (!state.wallet) return;
|
|
1165
|
+
|
|
1166
|
+
const btcAddress = state.addresses?.btc || '--';
|
|
1167
|
+
const ethAddress = state.addresses?.eth || '--';
|
|
1168
|
+
const solAddress = state.addresses?.sol || '--';
|
|
1169
|
+
|
|
1170
|
+
// let suiAddress = '--';
|
|
1171
|
+
// let monadAddress = ethAddress; // Monad uses same address as ETH (same coin type 60)
|
|
1172
|
+
// let adaAddress = '--';
|
|
1173
|
+
|
|
1174
|
+
// // Derive SUI and ADA from HD root using their signing paths
|
|
1175
|
+
// if (state.hdRoot) {
|
|
1176
|
+
// try {
|
|
1177
|
+
// // SUI: coin type 784, uses ed25519
|
|
1178
|
+
// const suiPath = buildSigningPath(784, 0, 0);
|
|
1179
|
+
// const suiDerived = state.hdRoot.derivePath(suiPath);
|
|
1180
|
+
// const suiPrivKey = suiDerived.privateKey();
|
|
1181
|
+
// const suiPubKey = ed25519.getPublicKey(suiPrivKey);
|
|
1182
|
+
// suiAddress = deriveSuiAddress(suiPubKey, 'ed25519');
|
|
1183
|
+
// } catch (e) {
|
|
1184
|
+
// console.error('Failed to derive SUI address:', e);
|
|
1185
|
+
// }
|
|
1186
|
+
|
|
1187
|
+
// try {
|
|
1188
|
+
// // ADA: coin type 1815, uses ed25519
|
|
1189
|
+
// const adaPath = buildSigningPath(1815, 0, 0);
|
|
1190
|
+
// const adaDerived = state.hdRoot.derivePath(adaPath);
|
|
1191
|
+
// const adaPrivKey = adaDerived.privateKey();
|
|
1192
|
+
// const adaPubKey = ed25519.getPublicKey(adaPrivKey);
|
|
1193
|
+
// adaAddress = deriveCardanoAddress(adaPubKey);
|
|
1194
|
+
// } catch (e) {
|
|
1195
|
+
// console.error('Failed to derive ADA address:', e);
|
|
1196
|
+
// }
|
|
1197
|
+
// }
|
|
1198
|
+
|
|
1199
|
+
const updateAddressCard = (network, address, explorerBase) => {
|
|
1200
|
+
const addrEl = $(`wallet-${network}-address`);
|
|
1201
|
+
const linkEl = $(`wallet-${network}-explorer`);
|
|
1202
|
+
|
|
1203
|
+
if (addrEl && address !== '--') {
|
|
1204
|
+
addrEl.textContent = address.length > 20
|
|
1205
|
+
? address.slice(0, 10) + '...' + address.slice(-8)
|
|
1206
|
+
: address;
|
|
1207
|
+
addrEl.title = address;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (linkEl && address !== '--') {
|
|
1211
|
+
linkEl.href = explorerBase + address;
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
updateAddressCard('btc', btcAddress, 'https://blockstream.info/address/');
|
|
1216
|
+
updateAddressCard('eth', ethAddress, 'https://etherscan.io/address/');
|
|
1217
|
+
updateAddressCard('sol', solAddress, 'https://solscan.io/account/');
|
|
1218
|
+
// updateAddressCard('sui', suiAddress, 'https://suiscan.xyz/mainnet/account/');
|
|
1219
|
+
// updateAddressCard('monad', monadAddress, 'https://monadscan.com/address/');
|
|
1220
|
+
// updateAddressCard('ada', adaAddress, 'https://cardanoscan.io/address/');
|
|
1221
|
+
|
|
1222
|
+
// const xrpAddress = state.addresses?.xrp || '--';
|
|
1223
|
+
|
|
1224
|
+
// Also populate bond tab addresses
|
|
1225
|
+
const bondAddresses = {
|
|
1226
|
+
btc: { addr: btcAddress, explorer: 'https://blockstream.info/address/' },
|
|
1227
|
+
eth: { addr: ethAddress, explorer: 'https://etherscan.io/address/' },
|
|
1228
|
+
sol: { addr: solAddress, explorer: 'https://solscan.io/account/' },
|
|
1229
|
+
// sui: { addr: suiAddress, explorer: 'https://suiscan.xyz/mainnet/account/' },
|
|
1230
|
+
// monad: { addr: monadAddress, explorer: 'https://monadscan.com/address/' },
|
|
1231
|
+
// ada: { addr: adaAddress, explorer: 'https://cardanoscan.io/address/' },
|
|
1232
|
+
// xrp: { addr: xrpAddress, explorer: 'https://xrpscan.com/account/' },
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
Object.entries(bondAddresses).forEach(([net, { addr, explorer }]) => {
|
|
1236
|
+
const addrEl = $(`bond-${net}-address`);
|
|
1237
|
+
const linkEl = $(`bond-${net}-explorer`);
|
|
1238
|
+
if (addrEl && addr !== '--') {
|
|
1239
|
+
addrEl.textContent = addr.length > 20
|
|
1240
|
+
? addr.slice(0, 10) + '...' + addr.slice(-8)
|
|
1241
|
+
: addr;
|
|
1242
|
+
addrEl.title = addr;
|
|
1243
|
+
}
|
|
1244
|
+
if (linkEl && addr !== '--') {
|
|
1245
|
+
linkEl.href = explorer + addr;
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// =============================================================================
|
|
1251
|
+
// Currency Conversion (Coinbase API)
|
|
1252
|
+
// =============================================================================
|
|
1253
|
+
|
|
1254
|
+
const CURRENCY_SYMBOLS = {
|
|
1255
|
+
USD: '$', EUR: '€', GBP: '£', JPY: '¥', CAD: 'C$', AUD: 'A$',
|
|
1256
|
+
CHF: 'CHF', CNY: '¥', BTC: '₿',
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const CURRENCY_OPTIONS = Object.keys(CURRENCY_SYMBOLS);
|
|
1260
|
+
|
|
1261
|
+
let priceCache = { data: null, currency: null, timestamp: 0 };
|
|
1262
|
+
|
|
1263
|
+
function getSelectedCurrency() {
|
|
1264
|
+
return localStorage.getItem('bond-currency') || 'USD';
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function setSelectedCurrency(currency) {
|
|
1268
|
+
localStorage.setItem('bond-currency', currency);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function fetchCryptoPrices(currency) {
|
|
1272
|
+
const now = Date.now();
|
|
1273
|
+
if (priceCache.data && priceCache.currency === currency && now - priceCache.timestamp < 60000) {
|
|
1274
|
+
return priceCache.data;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const cryptos = ['BTC', 'ETH', 'SOL'];
|
|
1278
|
+
const prices = {};
|
|
1279
|
+
|
|
1280
|
+
if (currency === 'BTC') {
|
|
1281
|
+
// For BTC denomination, fetch each crypto's price in BTC
|
|
1282
|
+
prices.BTC = 1;
|
|
1283
|
+
const others = ['ETH', 'SOL'];
|
|
1284
|
+
const results = await Promise.allSettled(
|
|
1285
|
+
others.map(async (crypto) => {
|
|
1286
|
+
const url = apiUrl(`https://api.coinbase.com/v2/exchange-rates?currency=${crypto}`);
|
|
1287
|
+
const res = await fetch(url);
|
|
1288
|
+
const json = await res.json();
|
|
1289
|
+
return { crypto, rate: parseFloat(json.data?.rates?.BTC) || 0 };
|
|
1290
|
+
})
|
|
1291
|
+
);
|
|
1292
|
+
results.forEach(r => {
|
|
1293
|
+
if (r.status === 'fulfilled') prices[r.value.crypto] = r.value.rate;
|
|
1294
|
+
});
|
|
1295
|
+
// prices.MONAD = 0; // Testnet token, no market price
|
|
1296
|
+
} else {
|
|
1297
|
+
// Fetch exchange rates with USD as base, then convert
|
|
1298
|
+
const results = await Promise.allSettled(
|
|
1299
|
+
cryptos.map(async (crypto) => {
|
|
1300
|
+
const url = apiUrl(`https://api.coinbase.com/v2/prices/${crypto}-${currency}/spot`);
|
|
1301
|
+
const res = await fetch(url);
|
|
1302
|
+
const json = await res.json();
|
|
1303
|
+
return { crypto, price: parseFloat(json.data?.amount) || 0 };
|
|
1304
|
+
})
|
|
1305
|
+
);
|
|
1306
|
+
results.forEach(r => {
|
|
1307
|
+
if (r.status === 'fulfilled') prices[r.value.crypto] = r.value.price;
|
|
1308
|
+
});
|
|
1309
|
+
// prices.MONAD = 0;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
priceCache = { data: prices, currency, timestamp: now };
|
|
1313
|
+
return prices;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function formatCurrencyValue(value, currency) {
|
|
1317
|
+
const symbol = CURRENCY_SYMBOLS[currency] || currency;
|
|
1318
|
+
if (currency === 'BTC') {
|
|
1319
|
+
return `${symbol}${value.toFixed(8)}`;
|
|
1320
|
+
}
|
|
1321
|
+
if (currency === 'JPY' || currency === 'CNY') {
|
|
1322
|
+
return `${symbol}${Math.round(value).toLocaleString()}`;
|
|
1323
|
+
}
|
|
1324
|
+
return `${symbol}${value.toFixed(2)}`;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// =============================================================================
|
|
1328
|
+
// Name Resolution (ENS, BNS, Solana Names)
|
|
1329
|
+
// =============================================================================
|
|
1330
|
+
|
|
1331
|
+
let nameCache = null;
|
|
1332
|
+
|
|
1333
|
+
async function resolveENSName(ethAddress) {
|
|
1334
|
+
if (!ethAddress) return null;
|
|
1335
|
+
try {
|
|
1336
|
+
// ENS reverse resolution: call addr.reverse resolver
|
|
1337
|
+
const addr = ethAddress.toLowerCase().slice(2);
|
|
1338
|
+
// namehash of <addr>.addr.reverse
|
|
1339
|
+
const node = await ensReverseNode(addr);
|
|
1340
|
+
// Call the ENS universal resolver
|
|
1341
|
+
const data = '0x691f3431' + node.slice(2); // name(bytes32)
|
|
1342
|
+
const response = await fetch(apiUrl('https://cloudflare-eth.com'), {
|
|
1343
|
+
method: 'POST',
|
|
1344
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1345
|
+
body: JSON.stringify({
|
|
1346
|
+
jsonrpc: '2.0', id: 1,
|
|
1347
|
+
method: 'eth_call',
|
|
1348
|
+
params: [{ to: '0xa58E81fe9b61B5c3fE2AFD33CF304c454AbFc7Cb', data }, 'latest'],
|
|
1349
|
+
}),
|
|
1350
|
+
});
|
|
1351
|
+
const json = await response.json();
|
|
1352
|
+
if (json.result && json.result !== '0x' && json.result.length > 130) {
|
|
1353
|
+
const name = decodeENSName(json.result);
|
|
1354
|
+
if (name && name.endsWith('.eth')) return name;
|
|
1355
|
+
}
|
|
1356
|
+
} catch (e) { console.warn('ENS resolution failed:', e); }
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
async function ensReverseNode(addrHex) {
|
|
1361
|
+
// namehash for <addr>.addr.reverse
|
|
1362
|
+
// Start with namehash('') = 0x0...0
|
|
1363
|
+
let node = new Uint8Array(32);
|
|
1364
|
+
node = keccak_256(new Uint8Array([...node, ...keccak_256(new TextEncoder().encode('reverse'))]));
|
|
1365
|
+
node = keccak_256(new Uint8Array([...node, ...keccak_256(new TextEncoder().encode('addr'))]));
|
|
1366
|
+
node = keccak_256(new Uint8Array([...node, ...keccak_256(new TextEncoder().encode(addrHex))]));
|
|
1367
|
+
return '0x' + Array.from(node).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function decodeENSName(hexResult) {
|
|
1371
|
+
try {
|
|
1372
|
+
// ABI-decode the string result
|
|
1373
|
+
const bytes = hexResult.slice(2);
|
|
1374
|
+
const offset = parseInt(bytes.slice(0, 64), 16) * 2;
|
|
1375
|
+
const length = parseInt(bytes.slice(offset, offset + 64), 16);
|
|
1376
|
+
const nameHex = bytes.slice(offset + 64, offset + 64 + length * 2);
|
|
1377
|
+
let name = '';
|
|
1378
|
+
for (let i = 0; i < nameHex.length; i += 2) {
|
|
1379
|
+
name += String.fromCharCode(parseInt(nameHex.slice(i, i + 2), 16));
|
|
1380
|
+
}
|
|
1381
|
+
return name;
|
|
1382
|
+
} catch { return null; }
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
async function resolveBNSName(btcAddress) {
|
|
1386
|
+
if (!btcAddress) return null;
|
|
1387
|
+
try {
|
|
1388
|
+
const response = await fetch(apiUrl(`https://api.hiro.so/v1/addresses/stacks/${btcAddress}`));
|
|
1389
|
+
if (!response.ok) return null;
|
|
1390
|
+
const json = await response.json();
|
|
1391
|
+
const names = json.names || [];
|
|
1392
|
+
if (names.length > 0) return names[0]; // Returns e.g. "alice.btc"
|
|
1393
|
+
} catch (e) { console.warn('BNS resolution failed:', e); }
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function resolveSolanaName(solAddress) {
|
|
1398
|
+
if (!solAddress) return null;
|
|
1399
|
+
try {
|
|
1400
|
+
// Use Solana Name Service reverse lookup via public API
|
|
1401
|
+
const response = await fetch(`https://sns-sdk-proxy.bonfida.workers.dev/v2/domain/${solAddress}`);
|
|
1402
|
+
if (!response.ok) return null;
|
|
1403
|
+
const json = await response.json();
|
|
1404
|
+
if (json.result && json.result.length > 0) {
|
|
1405
|
+
return json.result[0] + '.sol';
|
|
1406
|
+
}
|
|
1407
|
+
} catch (e) { console.warn('Solana name resolution failed:', e); }
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async function resolveNames() {
|
|
1412
|
+
if (nameCache) return nameCache;
|
|
1413
|
+
|
|
1414
|
+
const btcAddress = state.addresses?.btc;
|
|
1415
|
+
const ethAddress = state.addresses?.eth;
|
|
1416
|
+
const solAddress = state.addresses?.sol;
|
|
1417
|
+
|
|
1418
|
+
const [bns, ens, sol] = await Promise.allSettled([
|
|
1419
|
+
resolveBNSName(btcAddress),
|
|
1420
|
+
resolveENSName(ethAddress),
|
|
1421
|
+
resolveSolanaName(solAddress),
|
|
1422
|
+
]);
|
|
1423
|
+
|
|
1424
|
+
nameCache = {
|
|
1425
|
+
bns: bns.status === 'fulfilled' ? bns.value : null,
|
|
1426
|
+
ens: ens.status === 'fulfilled' ? ens.value : null,
|
|
1427
|
+
sol: sol.status === 'fulfilled' ? sol.value : null,
|
|
1428
|
+
};
|
|
1429
|
+
return nameCache;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function clearNameCache() {
|
|
1433
|
+
nameCache = null;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function updateAccountTitle(names) {
|
|
1437
|
+
const titleEl = $('account-title');
|
|
1438
|
+
if (!titleEl) return;
|
|
1439
|
+
|
|
1440
|
+
const resolved = [];
|
|
1441
|
+
if (names.bns) resolved.push({ name: names.bns, service: 'BNS' });
|
|
1442
|
+
if (names.ens) resolved.push({ name: names.ens, service: 'ENS' });
|
|
1443
|
+
if (names.sol) resolved.push({ name: names.sol, service: 'SOL' });
|
|
1444
|
+
|
|
1445
|
+
if (resolved.length === 0) {
|
|
1446
|
+
// Fallback to truncated xpub
|
|
1447
|
+
const xpub = state.hdRoot?.toXpub?.() || '';
|
|
1448
|
+
titleEl.innerHTML = xpub ? middleTruncate(xpub, 12, 8) : 'Account';
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
titleEl.innerHTML = resolved.map(({ name, service }) =>
|
|
1453
|
+
`${name}<sub class="name-service-label">${service}</sub>`
|
|
1454
|
+
).join(' · ');
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// =============================================================================
|
|
1458
|
+
// Currency Selector UI
|
|
1459
|
+
// =============================================================================
|
|
1460
|
+
|
|
1461
|
+
function initCurrencySelector() {
|
|
1462
|
+
const gearBtn = $('bond-currency-gear');
|
|
1463
|
+
const popover = $('bond-currency-popover');
|
|
1464
|
+
if (!gearBtn || !popover) return;
|
|
1465
|
+
|
|
1466
|
+
// Populate options
|
|
1467
|
+
const current = getSelectedCurrency();
|
|
1468
|
+
popover.innerHTML = CURRENCY_OPTIONS.map(c =>
|
|
1469
|
+
`<button class="currency-option${c === current ? ' active' : ''}" data-currency="${c}">${CURRENCY_SYMBOLS[c]} ${c}</button>`
|
|
1470
|
+
).join('');
|
|
1471
|
+
|
|
1472
|
+
gearBtn.addEventListener('click', (e) => {
|
|
1473
|
+
e.stopPropagation();
|
|
1474
|
+
popover.classList.toggle('visible');
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
popover.addEventListener('click', async (e) => {
|
|
1478
|
+
const btn = e.target.closest('[data-currency]');
|
|
1479
|
+
if (!btn) return;
|
|
1480
|
+
const currency = btn.dataset.currency;
|
|
1481
|
+
setSelectedCurrency(currency);
|
|
1482
|
+
popover.querySelectorAll('.currency-option').forEach(b => b.classList.remove('active'));
|
|
1483
|
+
btn.classList.add('active');
|
|
1484
|
+
popover.classList.remove('visible');
|
|
1485
|
+
priceCache = { data: null, currency: null, timestamp: 0 }; // invalidate
|
|
1486
|
+
await updateAdversarialSecurity();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
// Close popover on outside click
|
|
1490
|
+
document.addEventListener('click', () => popover.classList.remove('visible'));
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// =============================================================================
|
|
1494
|
+
// Adversarial Security / Bond Balances
|
|
1495
|
+
// =============================================================================
|
|
1496
|
+
|
|
1497
|
+
async function updateAdversarialSecurity() {
|
|
1498
|
+
const loginRequired = $('adversarial-login-required');
|
|
1499
|
+
const balancesSection = $('adversarial-balances');
|
|
1500
|
+
|
|
1501
|
+
const hasWallet = state.wallet && (state.wallet.secp256k1 || state.wallet.ed25519);
|
|
1502
|
+
|
|
1503
|
+
if (!hasWallet) {
|
|
1504
|
+
if (loginRequired) loginRequired.style.display = 'block';
|
|
1505
|
+
if (balancesSection) balancesSection.style.display = 'none';
|
|
1506
|
+
const trustNote = $('trust-note');
|
|
1507
|
+
if (trustNote) trustNote.textContent = 'Login to derive addresses and check balances.';
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (loginRequired) loginRequired.style.display = 'none';
|
|
1512
|
+
if (balancesSection) balancesSection.style.display = 'block';
|
|
1513
|
+
|
|
1514
|
+
populateWalletAddresses();
|
|
1515
|
+
|
|
1516
|
+
const btcAddress = state.addresses?.btc;
|
|
1517
|
+
const ethAddress = state.addresses?.eth;
|
|
1518
|
+
const solAddress = state.addresses?.sol;
|
|
1519
|
+
|
|
1520
|
+
// let suiAddress = null;
|
|
1521
|
+
// let adaAddress = null;
|
|
1522
|
+
// if (state.hdRoot) {
|
|
1523
|
+
// try {
|
|
1524
|
+
// const suiPath = buildSigningPath(784, 0, 0);
|
|
1525
|
+
// const suiDerived = state.hdRoot.derivePath(suiPath);
|
|
1526
|
+
// const suiPubKey = ed25519.getPublicKey(suiDerived.privateKey());
|
|
1527
|
+
// suiAddress = deriveSuiAddress(suiPubKey, 'ed25519');
|
|
1528
|
+
// } catch (e) { console.error('SUI derivation error:', e); }
|
|
1529
|
+
|
|
1530
|
+
// try {
|
|
1531
|
+
// const adaPath = buildSigningPath(1815, 0, 0);
|
|
1532
|
+
// const adaDerived = state.hdRoot.derivePath(adaPath);
|
|
1533
|
+
// const adaPubKey = ed25519.getPublicKey(adaDerived.privateKey());
|
|
1534
|
+
// adaAddress = deriveCardanoAddress(adaPubKey);
|
|
1535
|
+
// } catch (e) { console.error('ADA derivation error:', e); }
|
|
1536
|
+
// }
|
|
1537
|
+
|
|
1538
|
+
// const monadAddress = ethAddress;
|
|
1539
|
+
// const xrpAddress = state.addresses?.xrp;
|
|
1540
|
+
|
|
1541
|
+
// Set loading state
|
|
1542
|
+
const networks = ['btc', 'eth', 'sol'];
|
|
1543
|
+
networks.forEach(net => {
|
|
1544
|
+
const balEl = $(`wallet-${net}-balance`);
|
|
1545
|
+
if (balEl) balEl.textContent = '...';
|
|
1546
|
+
});
|
|
1547
|
+
const trustNote = $('trust-note');
|
|
1548
|
+
if (trustNote) trustNote.textContent = 'Fetching balances from blockchain...';
|
|
1549
|
+
|
|
1550
|
+
const fetchResults = await Promise.allSettled([
|
|
1551
|
+
btcAddress ? fetchBtcBalance(btcAddress) : Promise.resolve({ balance: '0' }),
|
|
1552
|
+
ethAddress ? fetchEthBalance(ethAddress) : Promise.resolve({ balance: '0' }),
|
|
1553
|
+
solAddress ? fetchSolBalance(solAddress) : Promise.resolve({ balance: '0' }),
|
|
1554
|
+
// suiAddress ? fetchSuiBalance(suiAddress) : Promise.resolve({ balance: '0' }),
|
|
1555
|
+
// monadAddress ? fetchMonadBalance(monadAddress) : Promise.resolve({ balance: '0' }),
|
|
1556
|
+
// adaAddress ? fetchAdaBalance(adaAddress) : Promise.resolve({ balance: '0' }),
|
|
1557
|
+
// xrpAddress ? fetchXrpBalance(xrpAddress) : Promise.resolve({ balance: '0' }),
|
|
1558
|
+
]);
|
|
1559
|
+
|
|
1560
|
+
const [btcResult, ethResult, solResult] = fetchResults.map(
|
|
1561
|
+
r => r.status === 'fulfilled' ? r.value : { balance: '0' }
|
|
1562
|
+
);
|
|
1563
|
+
|
|
1564
|
+
const updateBalance = (network, balance, decimals = 4) => {
|
|
1565
|
+
const balEl = $(`wallet-${network}-balance`);
|
|
1566
|
+
if (balEl) {
|
|
1567
|
+
const val = parseFloat(balance) || 0;
|
|
1568
|
+
balEl.textContent = val > 0 ? val.toFixed(val < 0.0001 ? 8 : decimals) : '0';
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const card = $(`wallet-${network}-card`);
|
|
1572
|
+
if (card) {
|
|
1573
|
+
const hasBalance = parseFloat(balance) > 0;
|
|
1574
|
+
card.classList.toggle('has-balance', hasBalance);
|
|
1575
|
+
card.classList.toggle('secure', hasBalance);
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
updateBalance('btc', btcResult.balance, 8);
|
|
1580
|
+
updateBalance('eth', ethResult.balance, 6);
|
|
1581
|
+
updateBalance('sol', solResult.balance, 6);
|
|
1582
|
+
// updateBalance('sui', suiResult.balance, 4);
|
|
1583
|
+
// updateBalance('monad', monadResult.balance, 4);
|
|
1584
|
+
// updateBalance('ada', adaResult.balance, 6);
|
|
1585
|
+
// updateBalance('xrp', xrpResult.balance, 6);
|
|
1586
|
+
|
|
1587
|
+
// Update bond tab per-network balances
|
|
1588
|
+
const bondBalances = {
|
|
1589
|
+
btc: btcResult.balance, eth: ethResult.balance, sol: solResult.balance,
|
|
1590
|
+
// sui: suiResult.balance, monad: monadResult.balance, ada: adaResult.balance,
|
|
1591
|
+
// xrp: xrpResult.balance,
|
|
1592
|
+
};
|
|
1593
|
+
Object.entries(bondBalances).forEach(([net, bal]) => {
|
|
1594
|
+
const el = $(`bond-${net}-balance`);
|
|
1595
|
+
const card = $(`bond-${net}-card`);
|
|
1596
|
+
const val = parseFloat(bal) || 0;
|
|
1597
|
+
if (el) el.textContent = val > 0 ? val.toFixed(val < 0.0001 ? 8 : 4) : '0';
|
|
1598
|
+
if (card) card.classList.toggle('has-balance', val > 0);
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// Convert to selected currency
|
|
1602
|
+
const currency = getSelectedCurrency();
|
|
1603
|
+
let totalConverted = 0;
|
|
1604
|
+
let cryptoPrices = null;
|
|
1605
|
+
|
|
1606
|
+
try {
|
|
1607
|
+
cryptoPrices = await fetchCryptoPrices(currency);
|
|
1608
|
+
const prices = cryptoPrices;
|
|
1609
|
+
const balances = {
|
|
1610
|
+
BTC: parseFloat(btcResult.balance) || 0,
|
|
1611
|
+
ETH: parseFloat(ethResult.balance) || 0,
|
|
1612
|
+
SOL: parseFloat(solResult.balance) || 0,
|
|
1613
|
+
// SUI: parseFloat(suiResult.balance) || 0,
|
|
1614
|
+
// MONAD: parseFloat(monadResult.balance) || 0,
|
|
1615
|
+
// ADA: parseFloat(adaResult.balance) || 0,
|
|
1616
|
+
// XRP: parseFloat(xrpResult.balance) || 0,
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
for (const [crypto, bal] of Object.entries(balances)) {
|
|
1620
|
+
totalConverted += bal * (prices[crypto] || 0);
|
|
1621
|
+
}
|
|
1622
|
+
} catch (e) {
|
|
1623
|
+
console.warn('Price conversion failed:', e);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Update account header total value
|
|
1627
|
+
const accountTotalEl = $('account-total-value');
|
|
1628
|
+
if (accountTotalEl) {
|
|
1629
|
+
accountTotalEl.textContent = 'Security Level: ' + formatCurrencyValue(totalConverted, currency);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Update account address dropdown values
|
|
1633
|
+
updateAccountAddressValues(bondBalances, cryptoPrices, currency);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// =============================================================================
|
|
1637
|
+
// vCard Generation
|
|
1638
|
+
// =============================================================================
|
|
1639
|
+
|
|
1640
|
+
function generateVCard(info, { skipPhoto = false } = {}) {
|
|
1641
|
+
const person = {};
|
|
1642
|
+
|
|
1643
|
+
if (info.firstName || info.lastName) {
|
|
1644
|
+
if (info.lastName) person.FAMILY_NAME = info.lastName;
|
|
1645
|
+
if (info.firstName) person.GIVEN_NAME = info.firstName;
|
|
1646
|
+
if (info.middleName) person.ADDITIONAL_NAME = info.middleName;
|
|
1647
|
+
if (info.prefix) person.HONORIFIC_PREFIX = info.prefix;
|
|
1648
|
+
if (info.suffix) person.HONORIFIC_SUFFIX = info.suffix;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (info.email) {
|
|
1652
|
+
person.CONTACT_POINT = [{ EMAIL: info.email }];
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
if (info.org) {
|
|
1656
|
+
person.AFFILIATION = { LEGAL_NAME: info.org };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (info.title) {
|
|
1660
|
+
person.HAS_OCCUPATION = { NAME: info.title };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (!skipPhoto && state.vcardPhoto) {
|
|
1664
|
+
person.IMAGE = state.vcardPhoto;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (info.includeKeys && state.wallet.x25519) {
|
|
1668
|
+
person.KEY = [
|
|
1669
|
+
...(state.hdRoot?.publicExtendedKey ? [{
|
|
1670
|
+
KEY_TYPE: 'xpub',
|
|
1671
|
+
PUBLIC_KEY: state.hdRoot.toXpub(),
|
|
1672
|
+
}] : []),
|
|
1673
|
+
{
|
|
1674
|
+
KEY_TYPE: 'X25519',
|
|
1675
|
+
PUBLIC_KEY: toBase64(state.wallet.x25519.publicKey),
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
KEY_TYPE: 'Ed25519',
|
|
1679
|
+
PUBLIC_KEY: toBase64(state.wallet.ed25519.publicKey),
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
KEY_TYPE: 'secp256k1',
|
|
1683
|
+
PUBLIC_KEY: toBase64(state.wallet.secp256k1.publicKey),
|
|
1684
|
+
CRYPTO_ADDRESS: state.addresses.btc || undefined,
|
|
1685
|
+
},
|
|
1686
|
+
];
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const note = info.includeKeys
|
|
1690
|
+
? 'Generated by HD Wallet UI'
|
|
1691
|
+
: undefined;
|
|
1692
|
+
|
|
1693
|
+
let vcard = createV3(person, note);
|
|
1694
|
+
|
|
1695
|
+
// Convert PHOTO from data URI format to iOS-compatible inline base64 format
|
|
1696
|
+
vcard = vcard.replace(
|
|
1697
|
+
/PHOTO;VALUE=URI:data:image\/(\w+);base64,([^\n]+)\n/,
|
|
1698
|
+
(_, type, b64) => {
|
|
1699
|
+
const vcardType = type.toUpperCase();
|
|
1700
|
+
let folded = `PHOTO;ENCODING=b;TYPE=${vcardType}:`;
|
|
1701
|
+
for (let i = 0; i < b64.length; i += 74) {
|
|
1702
|
+
folded += '\n ' + b64.slice(i, i + 74);
|
|
1703
|
+
}
|
|
1704
|
+
return folded + '\n';
|
|
1705
|
+
}
|
|
1706
|
+
);
|
|
1707
|
+
|
|
1708
|
+
return vcard;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// =============================================================================
|
|
1712
|
+
// vCard Keys Display
|
|
1713
|
+
// =============================================================================
|
|
1714
|
+
|
|
1715
|
+
function populateVCardKeysDisplay() {
|
|
1716
|
+
const keysDisplay = $('vcard-keys-display');
|
|
1717
|
+
if (!keysDisplay) return;
|
|
1718
|
+
|
|
1719
|
+
const keys = [];
|
|
1720
|
+
|
|
1721
|
+
// Bitcoin signing key
|
|
1722
|
+
if (state.addresses.btc) {
|
|
1723
|
+
keys.push({
|
|
1724
|
+
label: 'Bitcoin Signing',
|
|
1725
|
+
curve: 'secp256k1',
|
|
1726
|
+
address: state.addresses.btc,
|
|
1727
|
+
pubkey: state.wallet.secp256k1 ? toHex(state.wallet.secp256k1.publicKey) : '—',
|
|
1728
|
+
path: buildSigningPath(0, 0, 0), // m/44'/0'/0'/0'/0'
|
|
1729
|
+
role: 'signing',
|
|
1730
|
+
explorer: `https://blockstream.info/address/${state.addresses.btc}`,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Ethereum signing key
|
|
1735
|
+
if (state.addresses.eth) {
|
|
1736
|
+
keys.push({
|
|
1737
|
+
label: 'Ethereum Signing',
|
|
1738
|
+
curve: 'secp256k1',
|
|
1739
|
+
address: state.addresses.eth,
|
|
1740
|
+
pubkey: state.wallet.secp256k1 ? toHex(state.wallet.secp256k1.publicKey) : '—',
|
|
1741
|
+
path: buildSigningPath(60, 0, 0), // m/44'/60'/0'/0'/0'
|
|
1742
|
+
role: 'signing',
|
|
1743
|
+
explorer: `https://etherscan.io/address/${state.addresses.eth}`,
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Solana signing key
|
|
1748
|
+
if (state.addresses.sol) {
|
|
1749
|
+
keys.push({
|
|
1750
|
+
label: 'Solana Signing',
|
|
1751
|
+
curve: 'Ed25519',
|
|
1752
|
+
address: state.addresses.sol,
|
|
1753
|
+
pubkey: state.wallet.ed25519 ? toHex(state.wallet.ed25519.publicKey) : '—',
|
|
1754
|
+
path: buildSigningPath(501, 0, 0), // m/44'/501'/0'/0'
|
|
1755
|
+
role: 'signing',
|
|
1756
|
+
explorer: `https://explorer.solana.com/address/${state.addresses.sol}`,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// P-256 encryption key
|
|
1761
|
+
if (state.wallet.p256) {
|
|
1762
|
+
keys.push({
|
|
1763
|
+
label: 'Encryption Key',
|
|
1764
|
+
curve: 'P-256 (NIST)',
|
|
1765
|
+
address: '—',
|
|
1766
|
+
pubkey: toHex(state.wallet.p256.publicKey),
|
|
1767
|
+
path: buildEncryptionPath(0, 0, 0), // m/44'/0'/0'/1'/0'
|
|
1768
|
+
role: 'encryption',
|
|
1769
|
+
explorer: null,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// Clear and populate
|
|
1774
|
+
keysDisplay.innerHTML = '';
|
|
1775
|
+
|
|
1776
|
+
keys.forEach(key => {
|
|
1777
|
+
const keyCard = document.createElement('div');
|
|
1778
|
+
keyCard.className = 'key-display-card';
|
|
1779
|
+
keyCard.innerHTML = `
|
|
1780
|
+
<div class="key-display-header">
|
|
1781
|
+
<span class="key-display-label">${key.label}</span>
|
|
1782
|
+
<span class="key-display-badge ${key.role}">${key.role}</span>
|
|
1783
|
+
</div>
|
|
1784
|
+
<div class="key-display-row">
|
|
1785
|
+
<span class="key-display-field">Curve</span>
|
|
1786
|
+
<code class="key-display-value">${key.curve}</code>
|
|
1787
|
+
</div>
|
|
1788
|
+
<div class="key-display-row">
|
|
1789
|
+
<span class="key-display-field">Public Key</span>
|
|
1790
|
+
<code class="key-display-value truncate" title="${key.pubkey}">${truncateAddress(key.pubkey, 16)}</code>
|
|
1791
|
+
<button class="copy-btn-small" data-copy-text="${key.pubkey}" title="Copy">
|
|
1792
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1793
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
1794
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
1795
|
+
</svg>
|
|
1796
|
+
</button>
|
|
1797
|
+
</div>
|
|
1798
|
+
${key.address !== '—' ? `
|
|
1799
|
+
<div class="key-display-row">
|
|
1800
|
+
<span class="key-display-field">Address</span>
|
|
1801
|
+
<code class="key-display-value truncate" title="${key.address}">${truncateAddress(key.address, 16)}</code>
|
|
1802
|
+
${key.explorer ? `<a href="${key.explorer}" target="_blank" rel="noopener" class="explorer-link-small" title="View on Explorer">
|
|
1803
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1804
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
1805
|
+
<polyline points="15 3 21 3 21 9"/>
|
|
1806
|
+
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
1807
|
+
</svg>
|
|
1808
|
+
</a>` : ''}
|
|
1809
|
+
</div>
|
|
1810
|
+
` : ''}
|
|
1811
|
+
<div class="key-display-row">
|
|
1812
|
+
<span class="key-display-field">Derivation Path</span>
|
|
1813
|
+
<code class="key-display-value">${key.path}</code>
|
|
1814
|
+
</div>
|
|
1815
|
+
`;
|
|
1816
|
+
keysDisplay.appendChild(keyCard);
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
// Add copy button event listeners
|
|
1820
|
+
keysDisplay.querySelectorAll('.copy-btn-small').forEach(btn => {
|
|
1821
|
+
btn.addEventListener('click', async () => {
|
|
1822
|
+
const text = btn.getAttribute('data-copy-text');
|
|
1823
|
+
try {
|
|
1824
|
+
await navigator.clipboard.writeText(text);
|
|
1825
|
+
const originalHTML = btn.innerHTML;
|
|
1826
|
+
btn.innerHTML = '✓';
|
|
1827
|
+
setTimeout(() => { btn.innerHTML = originalHTML; }, 1000);
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
console.error('Copy failed:', err);
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function parseAndDisplayVCF(vcfText) {
|
|
1836
|
+
const lines = vcfText.replace(/\r?\n /g, '').split(/\r?\n/);
|
|
1837
|
+
const fields = {};
|
|
1838
|
+
const keys = [];
|
|
1839
|
+
let photo = null;
|
|
1840
|
+
|
|
1841
|
+
for (const line of lines) {
|
|
1842
|
+
const colonIdx = line.indexOf(':');
|
|
1843
|
+
if (colonIdx === -1) continue;
|
|
1844
|
+
const prop = line.substring(0, colonIdx).toUpperCase();
|
|
1845
|
+
const value = line.substring(colonIdx + 1);
|
|
1846
|
+
|
|
1847
|
+
if (prop === 'FN') {
|
|
1848
|
+
fields.name = value;
|
|
1849
|
+
} else if (prop.startsWith('N')) {
|
|
1850
|
+
if (!fields.name) {
|
|
1851
|
+
const parts = value.split(';');
|
|
1852
|
+
fields.name = [parts[3], parts[1], parts[2], parts[0], parts[4]].filter(Boolean).join(' ');
|
|
1853
|
+
}
|
|
1854
|
+
} else if (prop.startsWith('EMAIL')) {
|
|
1855
|
+
fields.email = value;
|
|
1856
|
+
} else if (prop.startsWith('ORG')) {
|
|
1857
|
+
fields.org = value.replace(/;/g, ', ');
|
|
1858
|
+
} else if (prop.startsWith('TITLE')) {
|
|
1859
|
+
fields.title = value;
|
|
1860
|
+
} else if (prop.startsWith('TEL')) {
|
|
1861
|
+
fields.tel = value;
|
|
1862
|
+
} else if (prop.startsWith('PHOTO')) {
|
|
1863
|
+
if (prop.includes('VALUE=URI') || value.startsWith('data:') || value.startsWith('http')) {
|
|
1864
|
+
photo = value;
|
|
1865
|
+
} else if (prop.includes('ENCODING=B') || prop.includes('ENCODING=b')) {
|
|
1866
|
+
const typeMatch = prop.match(/TYPE=(\w+)/i);
|
|
1867
|
+
const imgType = typeMatch ? typeMatch[1].toLowerCase() : 'jpeg';
|
|
1868
|
+
photo = `data:image/${imgType};base64,${value}`;
|
|
1869
|
+
}
|
|
1870
|
+
} else if (prop.startsWith('KEY')) {
|
|
1871
|
+
const typeMatch = prop.match(/TYPE=(\w+)/i);
|
|
1872
|
+
keys.push({ type: typeMatch ? typeMatch[1] : 'Unknown', value });
|
|
1873
|
+
} else if (prop.startsWith('X-CRYPTO-KEY') || prop.startsWith('X-KEY')) {
|
|
1874
|
+
keys.push({ type: prop.split(';')[0], value });
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const resultEl = $('vcf-import-result');
|
|
1879
|
+
const photoEl = $('vcf-import-photo');
|
|
1880
|
+
const fieldsEl = $('vcf-import-fields');
|
|
1881
|
+
if (!resultEl || !fieldsEl) return;
|
|
1882
|
+
|
|
1883
|
+
if (photoEl) {
|
|
1884
|
+
photoEl.innerHTML = photo
|
|
1885
|
+
? `<img src="${photo}" alt="Contact photo">`
|
|
1886
|
+
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;opacity:0.3">
|
|
1887
|
+
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
|
1888
|
+
</svg>`;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
let html = '';
|
|
1892
|
+
const fieldMap = [
|
|
1893
|
+
['Name', fields.name],
|
|
1894
|
+
['Email', fields.email],
|
|
1895
|
+
['Org', fields.org],
|
|
1896
|
+
['Title', fields.title],
|
|
1897
|
+
['Phone', fields.tel],
|
|
1898
|
+
];
|
|
1899
|
+
for (const [label, val] of fieldMap) {
|
|
1900
|
+
if (val) {
|
|
1901
|
+
html += `<div class="vcf-import-field">
|
|
1902
|
+
<span class="vcf-import-field-label">${label}</span>
|
|
1903
|
+
<span class="vcf-import-field-value">${val}</span>
|
|
1904
|
+
</div>`;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
if (keys.length > 0) {
|
|
1909
|
+
html += '<div class="vcf-import-keys">';
|
|
1910
|
+
for (const k of keys) {
|
|
1911
|
+
html += `<div class="vcf-import-key"><strong>${k.type}:</strong> <code>${k.value}</code></div>`;
|
|
1912
|
+
}
|
|
1913
|
+
html += '</div>';
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
fieldsEl.innerHTML = html;
|
|
1917
|
+
resultEl.style.display = 'block';
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// =============================================================================
|
|
1921
|
+
// Grid Canvas Animation
|
|
1922
|
+
// =============================================================================
|
|
1923
|
+
|
|
1924
|
+
function initGridAnimation() {
|
|
1925
|
+
const canvas = $('grid-canvas') || document.getElementById('grid-canvas');
|
|
1926
|
+
if (!canvas) return;
|
|
1927
|
+
|
|
1928
|
+
const ctx = canvas.getContext('2d');
|
|
1929
|
+
const gridSize = 40;
|
|
1930
|
+
const dotRadius = 1.5;
|
|
1931
|
+
|
|
1932
|
+
const travelers = [];
|
|
1933
|
+
const maxTravelers = 30;
|
|
1934
|
+
|
|
1935
|
+
function resize() {
|
|
1936
|
+
canvas.width = window.innerWidth;
|
|
1937
|
+
canvas.height = window.innerHeight;
|
|
1938
|
+
}
|
|
1939
|
+
resize();
|
|
1940
|
+
window.addEventListener('resize', resize);
|
|
1941
|
+
|
|
1942
|
+
function createTraveler() {
|
|
1943
|
+
const horizontal = Math.random() > 0.5;
|
|
1944
|
+
const value = Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase();
|
|
1945
|
+
|
|
1946
|
+
if (horizontal) {
|
|
1947
|
+
const row = Math.floor(Math.random() * (canvas.height / gridSize)) * gridSize;
|
|
1948
|
+
return {
|
|
1949
|
+
x: Math.random() > 0.5 ? -20 : canvas.width + 20,
|
|
1950
|
+
y: row,
|
|
1951
|
+
dx: (Math.random() > 0.5 ? 1 : -1) * (0.3 + Math.random() * 0.4),
|
|
1952
|
+
dy: 0,
|
|
1953
|
+
value,
|
|
1954
|
+
opacity: 0.3 + Math.random() * 0.4
|
|
1955
|
+
};
|
|
1956
|
+
} else {
|
|
1957
|
+
const col = Math.floor(Math.random() * (canvas.width / gridSize)) * gridSize;
|
|
1958
|
+
return {
|
|
1959
|
+
x: col,
|
|
1960
|
+
y: Math.random() > 0.5 ? -20 : canvas.height + 20,
|
|
1961
|
+
dx: 0,
|
|
1962
|
+
dy: (Math.random() > 0.5 ? 1 : -1) * (0.3 + Math.random() * 0.4),
|
|
1963
|
+
value,
|
|
1964
|
+
opacity: 0.3 + Math.random() * 0.4
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
for (let i = 0; i < maxTravelers; i++) {
|
|
1970
|
+
travelers.push(createTraveler());
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function draw() {
|
|
1974
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1975
|
+
|
|
1976
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
|
1977
|
+
for (let x = 0; x <= canvas.width; x += gridSize) {
|
|
1978
|
+
for (let y = 0; y <= canvas.height; y += gridSize) {
|
|
1979
|
+
ctx.beginPath();
|
|
1980
|
+
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
|
|
1981
|
+
ctx.fill();
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
ctx.font = '10px monospace';
|
|
1986
|
+
for (let i = 0; i < travelers.length; i++) {
|
|
1987
|
+
const t = travelers[i];
|
|
1988
|
+
|
|
1989
|
+
t.x += t.dx;
|
|
1990
|
+
t.y += t.dy;
|
|
1991
|
+
|
|
1992
|
+
if (t.x < -30 || t.x > canvas.width + 30 || t.y < -30 || t.y > canvas.height + 30) {
|
|
1993
|
+
travelers[i] = createTraveler();
|
|
1994
|
+
continue;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
ctx.fillStyle = `rgba(100, 200, 255, ${t.opacity})`;
|
|
1998
|
+
ctx.fillText(t.value, t.x - 6, t.y + 3);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
requestAnimationFrame(draw);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
draw();
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// =============================================================================
|
|
2008
|
+
// WebAuthn / Passkey Helpers
|
|
2009
|
+
// =============================================================================
|
|
2010
|
+
|
|
2011
|
+
function isPasskeySupported() {
|
|
2012
|
+
return window.PublicKeyCredential !== undefined &&
|
|
2013
|
+
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function';
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// =============================================================================
|
|
2017
|
+
// Login Handler Setup
|
|
2018
|
+
// =============================================================================
|
|
2019
|
+
|
|
2020
|
+
// Track selected remember method (pin or passkey) for each login type
|
|
2021
|
+
const rememberMethod = {
|
|
2022
|
+
password: 'passkey',
|
|
2023
|
+
seed: 'passkey'
|
|
2024
|
+
};
|
|
2025
|
+
|
|
2026
|
+
function setupLoginHandlers() {
|
|
2027
|
+
// Migrate from old storage format if needed
|
|
2028
|
+
WalletStorage.migrateStorage();
|
|
2029
|
+
|
|
2030
|
+
// Check for stored wallet using module
|
|
2031
|
+
const storageMetadata = WalletStorage.getStorageMetadata();
|
|
2032
|
+
const storageMethod = storageMetadata?.method || StorageMethod.NONE;
|
|
2033
|
+
|
|
2034
|
+
if (storageMethod !== StorageMethod.NONE) {
|
|
2035
|
+
const storedTab = $('stored-tab');
|
|
2036
|
+
if (storedTab) storedTab.style.display = '';
|
|
2037
|
+
|
|
2038
|
+
const dateEl = $('stored-wallet-date');
|
|
2039
|
+
if (dateEl && storageMetadata?.date) {
|
|
2040
|
+
dateEl.textContent = `Saved on ${storageMetadata.date}`;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const pinSection = $('stored-pin-section');
|
|
2044
|
+
const passkeySection = $('stored-passkey-section');
|
|
2045
|
+
const divider = $('stored-divider');
|
|
2046
|
+
|
|
2047
|
+
if (divider) divider.style.display = 'none';
|
|
2048
|
+
|
|
2049
|
+
if (storageMethod === StorageMethod.PIN) {
|
|
2050
|
+
if (pinSection) pinSection.style.display = 'block';
|
|
2051
|
+
if (passkeySection) passkeySection.style.display = 'none';
|
|
2052
|
+
} else if (storageMethod === StorageMethod.PASSKEY) {
|
|
2053
|
+
if (pinSection) pinSection.style.display = 'none';
|
|
2054
|
+
if (passkeySection) passkeySection.style.display = 'block';
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Pre-select stored tab (but don't open modal automatically)
|
|
2058
|
+
$qa('.method-tab').forEach(t => t.classList.remove('active'));
|
|
2059
|
+
$qa('.method-content').forEach(c => c.classList.remove('active'));
|
|
2060
|
+
if (storedTab) storedTab.classList.add('active');
|
|
2061
|
+
const storedMethod = $('stored-method');
|
|
2062
|
+
if (storedMethod) storedMethod.classList.add('active');
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// Hide passkey buttons if not supported
|
|
2066
|
+
if (!isPasskeySupported()) {
|
|
2067
|
+
const ppBtn = $('passkey-btn-password');
|
|
2068
|
+
if (ppBtn) ppBtn.style.display = 'none';
|
|
2069
|
+
const psBtn = $('passkey-btn-seed');
|
|
2070
|
+
if (psBtn) psBtn.style.display = 'none';
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// Method tab switching
|
|
2074
|
+
$qa('.method-tab').forEach(tab => {
|
|
2075
|
+
tab.addEventListener('click', () => {
|
|
2076
|
+
$qa('.method-tab').forEach(t => t.classList.remove('active'));
|
|
2077
|
+
$qa('.method-content').forEach(c => c.classList.remove('active'));
|
|
2078
|
+
tab.classList.add('active');
|
|
2079
|
+
const methodEl = $(`${tab.dataset.method}-method`);
|
|
2080
|
+
if (methodEl) methodEl.classList.add('active');
|
|
2081
|
+
});
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
// Remember method selector (PIN vs Passkey)
|
|
2085
|
+
$qa('.remember-method-btn').forEach(btn => {
|
|
2086
|
+
btn.addEventListener('click', () => {
|
|
2087
|
+
const target = btn.dataset.target;
|
|
2088
|
+
const method = btn.dataset.method;
|
|
2089
|
+
rememberMethod[target] = method;
|
|
2090
|
+
|
|
2091
|
+
$qa(`.remember-method-btn[data-target="${target}"]`).forEach(b => b.classList.remove('active'));
|
|
2092
|
+
btn.classList.add('active');
|
|
2093
|
+
|
|
2094
|
+
const pinGroup = $(`pin-group-${target}`);
|
|
2095
|
+
const passkeyInfo = $(`passkey-info-${target}`);
|
|
2096
|
+
if (pinGroup) pinGroup.style.display = method === 'pin' ? 'block' : 'none';
|
|
2097
|
+
if (passkeyInfo) passkeyInfo.style.display = method === 'passkey' ? 'flex' : 'none';
|
|
2098
|
+
});
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
// Remember wallet checkbox handlers
|
|
2102
|
+
$('remember-wallet-password')?.addEventListener('change', (e) => {
|
|
2103
|
+
const opts = $('remember-options-password');
|
|
2104
|
+
if (opts) opts.style.display = e.target.checked ? 'block' : 'none';
|
|
2105
|
+
if (e.target.checked && rememberMethod.password === 'pin') {
|
|
2106
|
+
$('pin-input-password')?.focus();
|
|
2107
|
+
}
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
$('remember-wallet-seed')?.addEventListener('change', (e) => {
|
|
2111
|
+
const opts = $('remember-options-seed');
|
|
2112
|
+
if (opts) opts.style.display = e.target.checked ? 'block' : 'none';
|
|
2113
|
+
if (e.target.checked && rememberMethod.seed === 'pin') {
|
|
2114
|
+
$('pin-input-seed')?.focus();
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
// PIN input validation
|
|
2119
|
+
['pin-input-password', 'pin-input-seed', 'pin-input-unlock'].forEach(id => {
|
|
2120
|
+
$(id)?.addEventListener('input', (e) => {
|
|
2121
|
+
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
|
2122
|
+
if (id === 'pin-input-unlock') {
|
|
2123
|
+
const unlockBtn = $('unlock-stored-wallet');
|
|
2124
|
+
if (unlockBtn) unlockBtn.disabled = e.target.value.length !== 6;
|
|
2125
|
+
}
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
// Password input handler
|
|
2130
|
+
$('wallet-password')?.addEventListener('input', (e) => {
|
|
2131
|
+
updatePasswordStrength(e.target.value);
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
$('wallet-username')?.addEventListener('input', () => {
|
|
2135
|
+
const pw = $('wallet-password');
|
|
2136
|
+
if (pw) updatePasswordStrength(pw.value);
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
// Derive from password button
|
|
2140
|
+
$('derive-from-password')?.addEventListener('click', async () => {
|
|
2141
|
+
const username = $('wallet-username')?.value;
|
|
2142
|
+
const password = $('wallet-password')?.value;
|
|
2143
|
+
const rememberWallet = $('remember-wallet-password')?.checked;
|
|
2144
|
+
const usePasskey = rememberMethod.password === 'passkey';
|
|
2145
|
+
const pin = $('pin-input-password')?.value;
|
|
2146
|
+
|
|
2147
|
+
console.log('Login clicked, username:', username, 'password length:', password?.length);
|
|
2148
|
+
if (!username || !password || password.length < 24) {
|
|
2149
|
+
console.log('Login validation failed');
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (rememberWallet && !usePasskey && (!pin || pin.length !== 6)) {
|
|
2154
|
+
alert('Please enter a 6-digit PIN to store your wallet');
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const btn = $('derive-from-password');
|
|
2159
|
+
btn.disabled = true;
|
|
2160
|
+
btn.textContent = 'Logging in...';
|
|
2161
|
+
|
|
2162
|
+
try {
|
|
2163
|
+
console.log('Calling deriveKeysFromPassword...');
|
|
2164
|
+
const keys = await deriveKeysFromPassword(username, password);
|
|
2165
|
+
console.log('Keys derived, hdRoot after derivation:', !!state.hdRoot);
|
|
2166
|
+
|
|
2167
|
+
if (rememberWallet) {
|
|
2168
|
+
const walletData = {
|
|
2169
|
+
type: 'password',
|
|
2170
|
+
username,
|
|
2171
|
+
password,
|
|
2172
|
+
masterSeed: Array.from(state.masterSeed)
|
|
2173
|
+
};
|
|
2174
|
+
|
|
2175
|
+
if (usePasskey) {
|
|
2176
|
+
await WalletStorage.storeWithPasskey(walletData, {
|
|
2177
|
+
rpName: 'HD Wallet',
|
|
2178
|
+
userName: username,
|
|
2179
|
+
userDisplayName: username
|
|
2180
|
+
});
|
|
2181
|
+
const pinSect = $('stored-pin-section');
|
|
2182
|
+
if (pinSect) pinSect.style.display = 'none';
|
|
2183
|
+
const psSect = $('stored-passkey-section');
|
|
2184
|
+
if (psSect) psSect.style.display = 'block';
|
|
2185
|
+
} else {
|
|
2186
|
+
await WalletStorage.storeWithPIN(pin, walletData);
|
|
2187
|
+
const pinSect = $('stored-pin-section');
|
|
2188
|
+
if (pinSect) pinSect.style.display = 'block';
|
|
2189
|
+
const psSect = $('stored-passkey-section');
|
|
2190
|
+
if (psSect) psSect.style.display = 'none';
|
|
2191
|
+
}
|
|
2192
|
+
const storedTab = $('stored-tab');
|
|
2193
|
+
if (storedTab) storedTab.style.display = '';
|
|
2194
|
+
const divider = $('stored-divider');
|
|
2195
|
+
if (divider) divider.style.display = 'none';
|
|
2196
|
+
const dateEl = $('stored-wallet-date');
|
|
2197
|
+
if (dateEl) dateEl.textContent = `Saved on ${new Date().toLocaleDateString()}`;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
login(keys);
|
|
2201
|
+
console.log('Login complete, hdRoot:', !!state.hdRoot);
|
|
2202
|
+
} catch (err) {
|
|
2203
|
+
console.error('Login error:', err);
|
|
2204
|
+
alert('Error: ' + err.message);
|
|
2205
|
+
} finally {
|
|
2206
|
+
btn.disabled = false;
|
|
2207
|
+
btn.textContent = 'Login';
|
|
2208
|
+
}
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
// Generate seed phrase button
|
|
2212
|
+
$('generate-seed')?.addEventListener('click', () => {
|
|
2213
|
+
const seedEl = $('seed-phrase');
|
|
2214
|
+
if (seedEl) seedEl.value = generateSeedPhrase();
|
|
2215
|
+
const deriveBtn = $('derive-from-seed');
|
|
2216
|
+
if (deriveBtn) deriveBtn.disabled = false;
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
// Validate seed phrase button
|
|
2220
|
+
$('validate-seed')?.addEventListener('click', () => {
|
|
2221
|
+
const seedEl = $('seed-phrase');
|
|
2222
|
+
const valid = validateSeedPhrase(seedEl?.value || '');
|
|
2223
|
+
if (valid) {
|
|
2224
|
+
alert('Valid BIP39 seed phrase!');
|
|
2225
|
+
const deriveBtn = $('derive-from-seed');
|
|
2226
|
+
if (deriveBtn) deriveBtn.disabled = false;
|
|
2227
|
+
} else {
|
|
2228
|
+
alert('Invalid seed phrase');
|
|
2229
|
+
const deriveBtn = $('derive-from-seed');
|
|
2230
|
+
if (deriveBtn) deriveBtn.disabled = true;
|
|
2231
|
+
}
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
// Seed phrase input validation
|
|
2235
|
+
$('seed-phrase')?.addEventListener('input', () => {
|
|
2236
|
+
const phrase = $('seed-phrase')?.value.trim();
|
|
2237
|
+
const deriveBtn = $('derive-from-seed');
|
|
2238
|
+
if (phrase && phrase.split(/\s+/).length >= 12) {
|
|
2239
|
+
if (deriveBtn) deriveBtn.disabled = !validateSeedPhrase(phrase);
|
|
2240
|
+
} else {
|
|
2241
|
+
if (deriveBtn) deriveBtn.disabled = true;
|
|
2242
|
+
}
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
// Derive from seed button
|
|
2246
|
+
$('derive-from-seed')?.addEventListener('click', async () => {
|
|
2247
|
+
const phrase = $('seed-phrase')?.value;
|
|
2248
|
+
if (!phrase || !validateSeedPhrase(phrase)) return;
|
|
2249
|
+
|
|
2250
|
+
const rememberWallet = $('remember-wallet-seed')?.checked;
|
|
2251
|
+
const usePasskey = rememberMethod.seed === 'passkey';
|
|
2252
|
+
const pin = $('pin-input-seed')?.value;
|
|
2253
|
+
|
|
2254
|
+
if (rememberWallet && !usePasskey && (!pin || pin.length !== 6)) {
|
|
2255
|
+
alert('Please enter a 6-digit PIN to store your wallet');
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
const btn = $('derive-from-seed');
|
|
2260
|
+
btn.disabled = true;
|
|
2261
|
+
btn.textContent = 'Logging in...';
|
|
2262
|
+
|
|
2263
|
+
try {
|
|
2264
|
+
const keys = await deriveKeysFromSeed(phrase);
|
|
2265
|
+
|
|
2266
|
+
if (rememberWallet) {
|
|
2267
|
+
const walletData = {
|
|
2268
|
+
type: 'seed',
|
|
2269
|
+
seedPhrase: phrase,
|
|
2270
|
+
masterSeed: Array.from(state.masterSeed)
|
|
2271
|
+
};
|
|
2272
|
+
|
|
2273
|
+
if (usePasskey) {
|
|
2274
|
+
await WalletStorage.storeWithPasskey(walletData, {
|
|
2275
|
+
rpName: 'HD Wallet',
|
|
2276
|
+
userName: 'seed-wallet',
|
|
2277
|
+
userDisplayName: 'Seed Phrase Wallet'
|
|
2278
|
+
});
|
|
2279
|
+
const pinSect = $('stored-pin-section');
|
|
2280
|
+
if (pinSect) pinSect.style.display = 'none';
|
|
2281
|
+
const psSect = $('stored-passkey-section');
|
|
2282
|
+
if (psSect) psSect.style.display = 'block';
|
|
2283
|
+
} else {
|
|
2284
|
+
await WalletStorage.storeWithPIN(pin, walletData);
|
|
2285
|
+
const pinSect = $('stored-pin-section');
|
|
2286
|
+
if (pinSect) pinSect.style.display = 'block';
|
|
2287
|
+
const psSect = $('stored-passkey-section');
|
|
2288
|
+
if (psSect) psSect.style.display = 'none';
|
|
2289
|
+
}
|
|
2290
|
+
const storedTab = $('stored-tab');
|
|
2291
|
+
if (storedTab) storedTab.style.display = '';
|
|
2292
|
+
const divider = $('stored-divider');
|
|
2293
|
+
if (divider) divider.style.display = 'none';
|
|
2294
|
+
const dateEl = $('stored-wallet-date');
|
|
2295
|
+
if (dateEl) dateEl.textContent = `Saved on ${new Date().toLocaleDateString()}`;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
login(keys);
|
|
2299
|
+
} catch (err) {
|
|
2300
|
+
alert('Error: ' + err.message);
|
|
2301
|
+
} finally {
|
|
2302
|
+
btn.disabled = false;
|
|
2303
|
+
btn.textContent = 'Login';
|
|
2304
|
+
}
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
// Unlock stored wallet with PIN
|
|
2308
|
+
$('unlock-stored-wallet')?.addEventListener('click', async () => {
|
|
2309
|
+
const pin = $('pin-input-unlock')?.value;
|
|
2310
|
+
if (!pin || pin.length !== 6) {
|
|
2311
|
+
alert('Please enter a 6-digit PIN');
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const btn = $('unlock-stored-wallet');
|
|
2316
|
+
btn.disabled = true;
|
|
2317
|
+
btn.textContent = 'Unlocking...';
|
|
2318
|
+
|
|
2319
|
+
try {
|
|
2320
|
+
const walletData = await WalletStorage.retrieveWithPIN(pin);
|
|
2321
|
+
|
|
2322
|
+
let keys;
|
|
2323
|
+
if (walletData.type === 'password') {
|
|
2324
|
+
keys = await deriveKeysFromPassword(walletData.username, walletData.password);
|
|
2325
|
+
} else if (walletData.type === 'seed') {
|
|
2326
|
+
keys = await deriveKeysFromSeed(walletData.seedPhrase);
|
|
2327
|
+
} else {
|
|
2328
|
+
throw new Error('Unknown wallet type');
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
login(keys);
|
|
2332
|
+
} catch (err) {
|
|
2333
|
+
alert('Error: ' + err.message);
|
|
2334
|
+
const pinInput = $('pin-input-unlock');
|
|
2335
|
+
if (pinInput) pinInput.value = '';
|
|
2336
|
+
} finally {
|
|
2337
|
+
btn.disabled = false;
|
|
2338
|
+
btn.textContent = 'Unlock with PIN';
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
// Unlock stored wallet with Passkey
|
|
2343
|
+
$('unlock-with-passkey')?.addEventListener('click', async () => {
|
|
2344
|
+
const btn = $('unlock-with-passkey');
|
|
2345
|
+
btn.disabled = true;
|
|
2346
|
+
btn.innerHTML = 'Authenticating...';
|
|
2347
|
+
|
|
2348
|
+
try {
|
|
2349
|
+
const walletData = await WalletStorage.retrieveWithPasskey();
|
|
2350
|
+
|
|
2351
|
+
let keys;
|
|
2352
|
+
if (walletData.type === 'password') {
|
|
2353
|
+
keys = await deriveKeysFromPassword(walletData.username, walletData.password);
|
|
2354
|
+
} else if (walletData.type === 'seed') {
|
|
2355
|
+
keys = await deriveKeysFromSeed(walletData.seedPhrase);
|
|
2356
|
+
} else {
|
|
2357
|
+
throw new Error('Unknown wallet type');
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
login(keys);
|
|
2361
|
+
} catch (err) {
|
|
2362
|
+
alert('Error: ' + err.message);
|
|
2363
|
+
} finally {
|
|
2364
|
+
btn.disabled = false;
|
|
2365
|
+
btn.innerHTML = 'Unlock with Passkey';
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
// Forget stored wallet
|
|
2370
|
+
$('forget-stored-wallet')?.addEventListener('click', () => {
|
|
2371
|
+
if (confirm('Are you sure you want to forget your stored wallet? You will need to enter your password or seed phrase again.')) {
|
|
2372
|
+
WalletStorage.clearStorage();
|
|
2373
|
+
const storedTab = $('stored-tab');
|
|
2374
|
+
if (storedTab) storedTab.style.display = 'none';
|
|
2375
|
+
const pinSect = $('stored-pin-section');
|
|
2376
|
+
if (pinSect) pinSect.style.display = 'block';
|
|
2377
|
+
const psSect = $('stored-passkey-section');
|
|
2378
|
+
if (psSect) psSect.style.display = 'none';
|
|
2379
|
+
const divider = $('stored-divider');
|
|
2380
|
+
if (divider) divider.style.display = 'none';
|
|
2381
|
+
// Switch to password tab
|
|
2382
|
+
$qa('.method-tab').forEach(t => t.classList.remove('active'));
|
|
2383
|
+
$qa('.method-content').forEach(c => c.classList.remove('active'));
|
|
2384
|
+
const pwMethod = $('password-method');
|
|
2385
|
+
if (pwMethod) pwMethod.classList.add('active');
|
|
2386
|
+
const pwTab = $q('.method-tab[data-method="password"]');
|
|
2387
|
+
if (pwTab) pwTab.classList.add('active');
|
|
2388
|
+
}
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// =============================================================================
|
|
2393
|
+
// Main App UI Handlers
|
|
2394
|
+
// =============================================================================
|
|
2395
|
+
|
|
2396
|
+
function setupMainAppHandlers() {
|
|
2397
|
+
// Nav actions
|
|
2398
|
+
$('nav-login')?.addEventListener('click', () => {
|
|
2399
|
+
$('login-modal')?.classList.add('active');
|
|
2400
|
+
});
|
|
2401
|
+
$('hero-login')?.addEventListener('click', () => {
|
|
2402
|
+
$('login-modal')?.classList.add('active');
|
|
2403
|
+
});
|
|
2404
|
+
$('nav-logout')?.addEventListener('click', logout);
|
|
2405
|
+
$('nav-keys')?.addEventListener('click', async () => {
|
|
2406
|
+
$('keys-modal')?.classList.add('active');
|
|
2407
|
+
deriveAndDisplayAddress();
|
|
2408
|
+
if (state.loggedIn) {
|
|
2409
|
+
const names = await resolveNames();
|
|
2410
|
+
updateAccountTitle(names);
|
|
2411
|
+
}
|
|
2412
|
+
});
|
|
2413
|
+
|
|
2414
|
+
// Modal close handlers
|
|
2415
|
+
$qa('.modal').forEach(modal => {
|
|
2416
|
+
modal.addEventListener('click', (e) => {
|
|
2417
|
+
if (e.target === modal || e.target.classList.contains('modal-close')) {
|
|
2418
|
+
modal.classList.remove('active');
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
// Account modal tab switching
|
|
2424
|
+
$qa('.modal-tab[data-modal-tab]').forEach(tab => {
|
|
2425
|
+
tab.addEventListener('click', () => {
|
|
2426
|
+
$qa('.modal-tab[data-modal-tab]').forEach(t => t.classList.remove('active'));
|
|
2427
|
+
$qa('.modal-tab-content').forEach(c => c.classList.remove('active'));
|
|
2428
|
+
tab.classList.add('active');
|
|
2429
|
+
const target = $(tab.dataset.modalTab);
|
|
2430
|
+
if (target) target.classList.add('active');
|
|
2431
|
+
});
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
// vCard identity auto-save
|
|
2435
|
+
const VCARD_STORAGE_KEY = 'hd-wallet-vcard-identity';
|
|
2436
|
+
const vcardFieldIds = [
|
|
2437
|
+
'vcard-prefix', 'vcard-firstname', 'vcard-middlename', 'vcard-lastname',
|
|
2438
|
+
'vcard-suffix', 'vcard-email', 'vcard-phone', 'vcard-org', 'vcard-title',
|
|
2439
|
+
'vcard-street', 'vcard-city', 'vcard-region', 'vcard-postal', 'vcard-country'
|
|
2440
|
+
];
|
|
2441
|
+
|
|
2442
|
+
function saveVcardIdentity() {
|
|
2443
|
+
const data = {};
|
|
2444
|
+
for (const id of vcardFieldIds) {
|
|
2445
|
+
const el = $(id);
|
|
2446
|
+
if (el) data[id] = el.value;
|
|
2447
|
+
}
|
|
2448
|
+
if (state.vcardPhoto) data._photo = state.vcardPhoto;
|
|
2449
|
+
try { localStorage.setItem(VCARD_STORAGE_KEY, JSON.stringify(data)); } catch (e) { /* ignore */ }
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function restoreVcardIdentity() {
|
|
2453
|
+
try {
|
|
2454
|
+
const raw = localStorage.getItem(VCARD_STORAGE_KEY);
|
|
2455
|
+
if (!raw) return;
|
|
2456
|
+
const data = JSON.parse(raw);
|
|
2457
|
+
for (const id of vcardFieldIds) {
|
|
2458
|
+
const el = $(id);
|
|
2459
|
+
if (el && data[id]) el.value = data[id];
|
|
2460
|
+
}
|
|
2461
|
+
if (data._photo) {
|
|
2462
|
+
state.vcardPhoto = data._photo;
|
|
2463
|
+
showPhotoPreview(data._photo);
|
|
2464
|
+
}
|
|
2465
|
+
} catch (e) { /* ignore */ }
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
restoreVcardIdentity();
|
|
2469
|
+
|
|
2470
|
+
let vcardSaveTimer = null;
|
|
2471
|
+
function debouncedVcardSave() {
|
|
2472
|
+
clearTimeout(vcardSaveTimer);
|
|
2473
|
+
vcardSaveTimer = setTimeout(saveVcardIdentity, 500);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
for (const id of vcardFieldIds) {
|
|
2477
|
+
$(id)?.addEventListener('input', debouncedVcardSave);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// Photo upload handler
|
|
2481
|
+
$('vcard-photo-input')?.addEventListener('change', (e) => {
|
|
2482
|
+
const file = e.target.files[0];
|
|
2483
|
+
if (!file) return;
|
|
2484
|
+
const reader = new FileReader();
|
|
2485
|
+
reader.onload = (ev) => {
|
|
2486
|
+
const img = new Image();
|
|
2487
|
+
img.onload = () => {
|
|
2488
|
+
const canvas = document.createElement('canvas');
|
|
2489
|
+
const size = 128;
|
|
2490
|
+
canvas.width = size;
|
|
2491
|
+
canvas.height = size;
|
|
2492
|
+
const ctx = canvas.getContext('2d');
|
|
2493
|
+
const min = Math.min(img.width, img.height);
|
|
2494
|
+
const sx = (img.width - min) / 2;
|
|
2495
|
+
const sy = (img.height - min) / 2;
|
|
2496
|
+
ctx.drawImage(img, sx, sy, min, min, 0, 0, size, size);
|
|
2497
|
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
|
2498
|
+
state.vcardPhoto = dataUrl;
|
|
2499
|
+
stopCamera();
|
|
2500
|
+
showPhotoPreview(dataUrl);
|
|
2501
|
+
saveVcardIdentity();
|
|
2502
|
+
};
|
|
2503
|
+
img.src = ev.target.result;
|
|
2504
|
+
};
|
|
2505
|
+
reader.readAsDataURL(file);
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
// Photo remove handler with confirmation modal
|
|
2509
|
+
$('vcard-photo-remove')?.addEventListener('click', () => {
|
|
2510
|
+
const modal = $('photo-remove-confirm-modal');
|
|
2511
|
+
if (modal) modal.classList.add('active');
|
|
2512
|
+
});
|
|
2513
|
+
|
|
2514
|
+
$('photo-remove-yes')?.addEventListener('click', () => {
|
|
2515
|
+
state.vcardPhoto = null;
|
|
2516
|
+
resetPhotoPreview();
|
|
2517
|
+
saveVcardIdentity();
|
|
2518
|
+
const removeBtn = $('vcard-photo-remove');
|
|
2519
|
+
if (removeBtn) removeBtn.style.display = 'none';
|
|
2520
|
+
const input = $('vcard-photo-input');
|
|
2521
|
+
if (input) input.value = '';
|
|
2522
|
+
// Show upload/camera buttons again
|
|
2523
|
+
const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
|
|
2524
|
+
if (uploadLabel) uploadLabel.style.display = '';
|
|
2525
|
+
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
2526
|
+
const cameraBtn = $('vcard-camera-btn');
|
|
2527
|
+
if (cameraBtn) cameraBtn.style.display = '';
|
|
2528
|
+
}
|
|
2529
|
+
const modal = $('photo-remove-confirm-modal');
|
|
2530
|
+
if (modal) modal.classList.remove('active');
|
|
2531
|
+
});
|
|
2532
|
+
|
|
2533
|
+
$('photo-remove-no')?.addEventListener('click', () => {
|
|
2534
|
+
const modal = $('photo-remove-confirm-modal');
|
|
2535
|
+
if (modal) modal.classList.remove('active');
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
function resetPhotoPreview() {
|
|
2539
|
+
const preview = $('vcard-photo-preview');
|
|
2540
|
+
if (!preview) return;
|
|
2541
|
+
preview.querySelectorAll('img').forEach(el => el.remove());
|
|
2542
|
+
const placeholder = preview.querySelector('.photo-placeholder-icon');
|
|
2543
|
+
if (placeholder) placeholder.style.display = '';
|
|
2544
|
+
const video = $('vcard-camera-video');
|
|
2545
|
+
if (video) video.style.display = 'none';
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
function showPhotoPreview(dataUrl) {
|
|
2549
|
+
const preview = $('vcard-photo-preview');
|
|
2550
|
+
if (!preview) return;
|
|
2551
|
+
const placeholder = preview.querySelector('.photo-placeholder-icon');
|
|
2552
|
+
if (placeholder) placeholder.style.display = 'none';
|
|
2553
|
+
const video = $('vcard-camera-video');
|
|
2554
|
+
if (video) video.style.display = 'none';
|
|
2555
|
+
preview.querySelectorAll('img').forEach(el => el.remove());
|
|
2556
|
+
const img = document.createElement('img');
|
|
2557
|
+
img.src = dataUrl;
|
|
2558
|
+
img.alt = 'Photo';
|
|
2559
|
+
preview.appendChild(img);
|
|
2560
|
+
const removeBtn = $('vcard-photo-remove');
|
|
2561
|
+
if (removeBtn) removeBtn.style.display = '';
|
|
2562
|
+
// Hide upload/camera buttons when photo is present
|
|
2563
|
+
const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
|
|
2564
|
+
if (uploadLabel) uploadLabel.style.display = 'none';
|
|
2565
|
+
const cameraBtn = $('vcard-camera-btn');
|
|
2566
|
+
if (cameraBtn) cameraBtn.style.display = 'none';
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// Camera support
|
|
2570
|
+
let cameraStream = null;
|
|
2571
|
+
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
2572
|
+
const cameraBtn = $('vcard-camera-btn');
|
|
2573
|
+
if (cameraBtn && !state.vcardPhoto) cameraBtn.style.display = '';
|
|
2574
|
+
|
|
2575
|
+
cameraBtn?.addEventListener('click', async () => {
|
|
2576
|
+
try {
|
|
2577
|
+
cameraStream = await navigator.mediaDevices.getUserMedia({
|
|
2578
|
+
video: { facingMode: 'user', width: { ideal: 512 }, height: { ideal: 512 } }
|
|
2579
|
+
});
|
|
2580
|
+
const video = $('vcard-camera-video');
|
|
2581
|
+
if (video) {
|
|
2582
|
+
video.srcObject = cameraStream;
|
|
2583
|
+
video.style.display = '';
|
|
2584
|
+
}
|
|
2585
|
+
const preview = $('vcard-photo-preview');
|
|
2586
|
+
if (preview) {
|
|
2587
|
+
const placeholder = preview.querySelector('.photo-placeholder-icon');
|
|
2588
|
+
if (placeholder) placeholder.style.display = 'none';
|
|
2589
|
+
preview.querySelectorAll('img').forEach(el => el.style.display = 'none');
|
|
2590
|
+
}
|
|
2591
|
+
cameraBtn.style.display = 'none';
|
|
2592
|
+
const captureBtn = $('vcard-camera-capture');
|
|
2593
|
+
const cancelBtn = $('vcard-camera-cancel');
|
|
2594
|
+
if (captureBtn) captureBtn.style.display = '';
|
|
2595
|
+
if (cancelBtn) cancelBtn.style.display = '';
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
console.error('Camera access denied:', err);
|
|
2598
|
+
alert('Could not access camera. Please check your browser permissions.');
|
|
2599
|
+
}
|
|
2600
|
+
});
|
|
2601
|
+
|
|
2602
|
+
$('vcard-camera-capture')?.addEventListener('click', () => {
|
|
2603
|
+
const video = $('vcard-camera-video');
|
|
2604
|
+
if (!video) return;
|
|
2605
|
+
const canvas = document.createElement('canvas');
|
|
2606
|
+
const size = 128;
|
|
2607
|
+
canvas.width = size;
|
|
2608
|
+
canvas.height = size;
|
|
2609
|
+
const ctx = canvas.getContext('2d');
|
|
2610
|
+
const vw = video.videoWidth;
|
|
2611
|
+
const vh = video.videoHeight;
|
|
2612
|
+
const min = Math.min(vw, vh);
|
|
2613
|
+
const sx = (vw - min) / 2;
|
|
2614
|
+
const sy = (vh - min) / 2;
|
|
2615
|
+
ctx.drawImage(video, sx, sy, min, min, 0, 0, size, size);
|
|
2616
|
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
|
2617
|
+
state.vcardPhoto = dataUrl;
|
|
2618
|
+
stopCamera();
|
|
2619
|
+
showPhotoPreview(dataUrl);
|
|
2620
|
+
saveVcardIdentity();
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
$('vcard-camera-cancel')?.addEventListener('click', () => {
|
|
2624
|
+
stopCamera();
|
|
2625
|
+
if (state.vcardPhoto) {
|
|
2626
|
+
showPhotoPreview(state.vcardPhoto);
|
|
2627
|
+
} else {
|
|
2628
|
+
resetPhotoPreview();
|
|
2629
|
+
}
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function stopCamera() {
|
|
2634
|
+
if (cameraStream) {
|
|
2635
|
+
cameraStream.getTracks().forEach(t => t.stop());
|
|
2636
|
+
cameraStream = null;
|
|
2637
|
+
}
|
|
2638
|
+
const video = $('vcard-camera-video');
|
|
2639
|
+
if (video) {
|
|
2640
|
+
video.srcObject = null;
|
|
2641
|
+
video.style.display = 'none';
|
|
2642
|
+
}
|
|
2643
|
+
const cameraBtn = $('vcard-camera-btn');
|
|
2644
|
+
const captureBtn = $('vcard-camera-capture');
|
|
2645
|
+
const cancelBtn = $('vcard-camera-cancel');
|
|
2646
|
+
if (cameraBtn) cameraBtn.style.display = state.vcardPhoto ? 'none' : '';
|
|
2647
|
+
if (captureBtn) captureBtn.style.display = 'none';
|
|
2648
|
+
if (cancelBtn) cancelBtn.style.display = 'none';
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// VCF import handler
|
|
2652
|
+
$('vcf-import-input')?.addEventListener('change', (e) => {
|
|
2653
|
+
const file = e.target.files[0];
|
|
2654
|
+
if (!file) return;
|
|
2655
|
+
const reader = new FileReader();
|
|
2656
|
+
reader.onload = (ev) => {
|
|
2657
|
+
const vcfText = ev.target.result;
|
|
2658
|
+
parseAndDisplayVCF(vcfText);
|
|
2659
|
+
};
|
|
2660
|
+
reader.readAsText(file);
|
|
2661
|
+
e.target.value = '';
|
|
2662
|
+
});
|
|
2663
|
+
|
|
2664
|
+
// Reveal sensitive key buttons
|
|
2665
|
+
$qa('.reveal-key-btn').forEach(btn => {
|
|
2666
|
+
btn.addEventListener('click', () => {
|
|
2667
|
+
const targetId = btn.dataset.target;
|
|
2668
|
+
const targetEl = $(targetId);
|
|
2669
|
+
if (targetEl) {
|
|
2670
|
+
const isRevealed = targetEl.dataset.revealed === 'true';
|
|
2671
|
+
targetEl.dataset.revealed = isRevealed ? 'false' : 'true';
|
|
2672
|
+
btn.innerHTML = isRevealed
|
|
2673
|
+
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
|
|
2674
|
+
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
// Copy key buttons
|
|
2680
|
+
$qa('.copy-key-btn').forEach(btn => {
|
|
2681
|
+
btn.addEventListener('click', async () => {
|
|
2682
|
+
const targetId = btn.dataset.copy;
|
|
2683
|
+
const targetEl = $(targetId);
|
|
2684
|
+
if (targetEl) {
|
|
2685
|
+
try {
|
|
2686
|
+
await navigator.clipboard.writeText(targetEl.dataset.fullValue || targetEl.textContent);
|
|
2687
|
+
btn.classList.add('copied');
|
|
2688
|
+
setTimeout(() => btn.classList.remove('copied'), 1500);
|
|
2689
|
+
} catch (err) {
|
|
2690
|
+
console.error('Copy failed:', err);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
// Export wallet dropdown
|
|
2697
|
+
const exportBtn = $('export-wallet-btn');
|
|
2698
|
+
const exportMenu = $('export-menu');
|
|
2699
|
+
if (exportBtn && exportMenu) {
|
|
2700
|
+
exportBtn.addEventListener('click', () => {
|
|
2701
|
+
exportMenu.classList.toggle('active');
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
_root.addEventListener('click', (e) => {
|
|
2705
|
+
if (!exportBtn.contains(e.target) && !exportMenu.contains(e.target)) {
|
|
2706
|
+
exportMenu.classList.remove('active');
|
|
2707
|
+
}
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
$qa('.export-option').forEach(option => {
|
|
2711
|
+
option.addEventListener('click', async () => {
|
|
2712
|
+
const format = option.dataset.format;
|
|
2713
|
+
await exportWallet(format);
|
|
2714
|
+
exportMenu.classList.remove('active');
|
|
2715
|
+
});
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// Mobile menu toggle
|
|
2720
|
+
const mobileMenuBtn = $('nav-menu-btn');
|
|
2721
|
+
const mobileMenu = $('nav-mobile-menu');
|
|
2722
|
+
|
|
2723
|
+
if (mobileMenuBtn && mobileMenu) {
|
|
2724
|
+
mobileMenuBtn.addEventListener('click', () => {
|
|
2725
|
+
mobileMenu.classList.toggle('open');
|
|
2726
|
+
const isOpen = mobileMenu.classList.contains('open');
|
|
2727
|
+
mobileMenuBtn.innerHTML = isOpen
|
|
2728
|
+
? '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
|
2729
|
+
: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
|
|
2730
|
+
});
|
|
2731
|
+
|
|
2732
|
+
const mobileLogin = $('mobile-login');
|
|
2733
|
+
const mobileLogout = $('mobile-logout');
|
|
2734
|
+
|
|
2735
|
+
if (mobileLogin) {
|
|
2736
|
+
mobileLogin.addEventListener('click', () => {
|
|
2737
|
+
$('login-modal')?.classList.add('active');
|
|
2738
|
+
mobileMenu.classList.remove('open');
|
|
2739
|
+
mobileMenuBtn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
if (mobileLogout) {
|
|
2744
|
+
mobileLogout.addEventListener('click', () => {
|
|
2745
|
+
logout();
|
|
2746
|
+
mobileMenu.classList.remove('open');
|
|
2747
|
+
mobileMenuBtn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// Navigation links - scroll to sections
|
|
2753
|
+
$qa('.nav-link[data-tab]').forEach(link => {
|
|
2754
|
+
link.addEventListener('click', (e) => {
|
|
2755
|
+
e.preventDefault();
|
|
2756
|
+
$qa('.nav-link[data-tab]').forEach(l => l.classList.remove('active'));
|
|
2757
|
+
link.classList.add('active');
|
|
2758
|
+
const tabEl = $(`${link.dataset.tab}-tab`);
|
|
2759
|
+
if (tabEl) {
|
|
2760
|
+
tabEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
2761
|
+
}
|
|
2762
|
+
if (mobileMenu) {
|
|
2763
|
+
mobileMenu.classList.remove('open');
|
|
2764
|
+
if (mobileMenuBtn) {
|
|
2765
|
+
mobileMenuBtn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
// HD wallet controls
|
|
2772
|
+
$('hd-coin')?.addEventListener('change', () => {
|
|
2773
|
+
updatePathDisplay();
|
|
2774
|
+
deriveAndDisplayAddress();
|
|
2775
|
+
});
|
|
2776
|
+
$('hd-account')?.addEventListener('input', () => {
|
|
2777
|
+
updatePathDisplay();
|
|
2778
|
+
deriveAndDisplayAddress();
|
|
2779
|
+
});
|
|
2780
|
+
$('hd-index')?.addEventListener('input', () => {
|
|
2781
|
+
updatePathDisplay();
|
|
2782
|
+
deriveAndDisplayAddress();
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
// PKI clear keys
|
|
2786
|
+
$('pki-clear-keys')?.addEventListener('click', clearPKIKeys);
|
|
2787
|
+
|
|
2788
|
+
// PKI algorithm change
|
|
2789
|
+
$('pki-algorithm')?.addEventListener('change', async () => {
|
|
2790
|
+
const newAlgorithm = $('pki-algorithm').value;
|
|
2791
|
+
state.pki.algorithm = newAlgorithm;
|
|
2792
|
+
|
|
2793
|
+
if (state.hdRoot) {
|
|
2794
|
+
derivePKIKeysFromHD();
|
|
2795
|
+
savePKIKeys();
|
|
2796
|
+
} else {
|
|
2797
|
+
try {
|
|
2798
|
+
if (newAlgorithm === 'p256') {
|
|
2799
|
+
state.pki.alice = await p256GenerateKeyPairAsync();
|
|
2800
|
+
state.pki.bob = await p256GenerateKeyPairAsync();
|
|
2801
|
+
} else if (newAlgorithm === 'p384') {
|
|
2802
|
+
state.pki.alice = await p384GenerateKeyPairAsync();
|
|
2803
|
+
state.pki.bob = await p384GenerateKeyPairAsync();
|
|
2804
|
+
} else {
|
|
2805
|
+
const curveType = newAlgorithm === 'secp256k1' ? Curve.SECP256K1 : Curve.X25519;
|
|
2806
|
+
state.pki.alice = generateKeyPair(curveType);
|
|
2807
|
+
state.pki.bob = generateKeyPair(curveType);
|
|
2808
|
+
}
|
|
2809
|
+
savePKIKeys();
|
|
2810
|
+
} catch (err) {
|
|
2811
|
+
console.error('Failed to generate keys for', newAlgorithm, err);
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// Update display
|
|
2817
|
+
const alicePub = $('alice-public-key');
|
|
2818
|
+
const alicePriv = $('alice-private-key');
|
|
2819
|
+
const bobPub = $('bob-public-key');
|
|
2820
|
+
const bobPriv = $('bob-private-key');
|
|
2821
|
+
if (alicePub) alicePub.textContent = toHexCompact(state.pki.alice.publicKey);
|
|
2822
|
+
if (alicePriv) alicePriv.textContent = toHexCompact(state.pki.alice.privateKey);
|
|
2823
|
+
if (bobPub) bobPub.textContent = toHexCompact(state.pki.bob.publicKey);
|
|
2824
|
+
if (bobPriv) bobPriv.textContent = toHexCompact(state.pki.bob.privateKey);
|
|
2825
|
+
|
|
2826
|
+
const algorithmNames = {
|
|
2827
|
+
x25519: 'X25519 (Curve25519)',
|
|
2828
|
+
secp256k1: 'secp256k1 (Bitcoin/Ethereum)',
|
|
2829
|
+
p256: 'P-256 / secp256r1 (NIST)',
|
|
2830
|
+
p384: 'P-384 / secp384r1 (NIST)',
|
|
2831
|
+
};
|
|
2832
|
+
const algDisplay = $('pki-algorithm-display');
|
|
2833
|
+
if (algDisplay) algDisplay.textContent = algorithmNames[newAlgorithm] || newAlgorithm;
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
// vCard generation
|
|
2837
|
+
$('generate-vcard')?.addEventListener('click', async () => {
|
|
2838
|
+
const info = {
|
|
2839
|
+
prefix: $('vcard-prefix')?.value || '',
|
|
2840
|
+
firstName: $('vcard-firstname')?.value || '',
|
|
2841
|
+
middleName: $('vcard-middlename')?.value || '',
|
|
2842
|
+
lastName: $('vcard-lastname')?.value || '',
|
|
2843
|
+
suffix: $('vcard-suffix')?.value || '',
|
|
2844
|
+
email: $('vcard-email')?.value || '',
|
|
2845
|
+
org: $('vcard-org')?.value || '',
|
|
2846
|
+
title: $('vcard-title')?.value || '',
|
|
2847
|
+
includeKeys: true,
|
|
2848
|
+
};
|
|
2849
|
+
|
|
2850
|
+
if (!info.firstName && !info.lastName) {
|
|
2851
|
+
alert('Please enter at least a first or last name');
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
const vcard = generateVCard(info);
|
|
2856
|
+
const vcardForQR = generateVCard(info, { skipPhoto: true });
|
|
2857
|
+
const vcardPreview = $('vcard-preview');
|
|
2858
|
+
if (vcardPreview) vcardPreview.textContent = vcard;
|
|
2859
|
+
|
|
2860
|
+
try {
|
|
2861
|
+
const qrCanvas = $('qr-code');
|
|
2862
|
+
if (qrCanvas) {
|
|
2863
|
+
await QRCode.toCanvas(qrCanvas, vcardForQR, {
|
|
2864
|
+
width: 256,
|
|
2865
|
+
margin: 2,
|
|
2866
|
+
color: { dark: '#1e293b', light: '#ffffff' },
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
const formView = $('vcard-form-view');
|
|
2870
|
+
const resultView = $('vcard-result-view');
|
|
2871
|
+
if (formView) formView.style.display = 'none';
|
|
2872
|
+
if (resultView) resultView.style.display = '';
|
|
2873
|
+
} catch (err) {
|
|
2874
|
+
alert('Error generating QR code: ' + err.message);
|
|
2875
|
+
}
|
|
2876
|
+
});
|
|
2877
|
+
|
|
2878
|
+
// Back to editor from result view
|
|
2879
|
+
$('vcard-back-btn')?.addEventListener('click', () => {
|
|
2880
|
+
const resultView = $('vcard-result-view');
|
|
2881
|
+
const formView = $('vcard-form-view');
|
|
2882
|
+
if (resultView) resultView.style.display = 'none';
|
|
2883
|
+
if (formView) formView.style.display = '';
|
|
2884
|
+
});
|
|
2885
|
+
|
|
2886
|
+
// Download vCard
|
|
2887
|
+
$('download-vcard')?.addEventListener('click', () => {
|
|
2888
|
+
const vcard = $('vcard-preview')?.textContent || '';
|
|
2889
|
+
const blob = new Blob([vcard], { type: 'text/vcard' });
|
|
2890
|
+
const url = URL.createObjectURL(blob);
|
|
2891
|
+
const a = document.createElement('a');
|
|
2892
|
+
a.href = url;
|
|
2893
|
+
a.download = 'contact.vcf';
|
|
2894
|
+
a.click();
|
|
2895
|
+
URL.revokeObjectURL(url);
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
// Copy vCard
|
|
2899
|
+
$('copy-vcard')?.addEventListener('click', async () => {
|
|
2900
|
+
const vcard = $('vcard-preview')?.textContent || '';
|
|
2901
|
+
try {
|
|
2902
|
+
await navigator.clipboard.writeText(vcard);
|
|
2903
|
+
const btn = $('copy-vcard');
|
|
2904
|
+
if (btn) {
|
|
2905
|
+
btn.textContent = 'Copied!';
|
|
2906
|
+
setTimeout(() => { btn.textContent = 'Copy vCard'; }, 2000);
|
|
2907
|
+
}
|
|
2908
|
+
} catch (err) {
|
|
2909
|
+
alert('Failed to copy: ' + err.message);
|
|
2910
|
+
}
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
// Refresh balances button
|
|
2914
|
+
$('refresh-balances')?.addEventListener('click', () => {
|
|
2915
|
+
updateAdversarialSecurity();
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
// Escape key closes modals
|
|
2919
|
+
document.addEventListener('keydown', (e) => {
|
|
2920
|
+
if (e.key === 'Escape') {
|
|
2921
|
+
$qa('.modal.active').forEach(m => m.classList.remove('active'));
|
|
2922
|
+
}
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
// Trust system handlers
|
|
2926
|
+
setupTrustHandlers();
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// =============================================================================
|
|
2930
|
+
// Trust System Handlers
|
|
2931
|
+
// =============================================================================
|
|
2932
|
+
|
|
2933
|
+
function setupTrustHandlers() {
|
|
2934
|
+
let trustScanInterval = null;
|
|
2935
|
+
const TRUST_SCAN_INTERVAL_MS = 60000; // 60 seconds
|
|
2936
|
+
const TRUST_RULES_KEY = 'trust-rules';
|
|
2937
|
+
const TRUST_IMPORTED_KEY = 'trust-imported-txs';
|
|
2938
|
+
|
|
2939
|
+
// Auto-scan trust transactions
|
|
2940
|
+
async function runTrustScan() {
|
|
2941
|
+
if (!state.loggedIn || !state.addresses) return;
|
|
2942
|
+
|
|
2943
|
+
const statusEl = $('trust-scan-status');
|
|
2944
|
+
const labelEl = $('trust-scan-label');
|
|
2945
|
+
const countEl = $('trust-scan-count');
|
|
2946
|
+
if (statusEl) statusEl.classList.add('active');
|
|
2947
|
+
if (labelEl) labelEl.textContent = 'Scanning...';
|
|
2948
|
+
|
|
2949
|
+
try {
|
|
2950
|
+
const { scanAllTrustTransactions, renderTrustList } = await import('./trust-ui.js');
|
|
2951
|
+
const { buildTrustGraph, analyzeTrustRelationships } = await import('./blockchain-trust.js');
|
|
2952
|
+
|
|
2953
|
+
// Scan on-chain transactions
|
|
2954
|
+
const onChainTxs = await scanAllTrustTransactions(state.addresses);
|
|
2955
|
+
|
|
2956
|
+
// Merge with imported transactions
|
|
2957
|
+
let importedTxs = [];
|
|
2958
|
+
try {
|
|
2959
|
+
const raw = localStorage.getItem(TRUST_IMPORTED_KEY);
|
|
2960
|
+
if (raw) importedTxs = JSON.parse(raw);
|
|
2961
|
+
} catch (e) { /* ignore */ }
|
|
2962
|
+
|
|
2963
|
+
const allTxs = [...onChainTxs, ...importedTxs];
|
|
2964
|
+
|
|
2965
|
+
// Deduplicate by txHash
|
|
2966
|
+
const seen = new Set();
|
|
2967
|
+
const dedupedTxs = allTxs.filter(tx => {
|
|
2968
|
+
if (seen.has(tx.txHash)) return false;
|
|
2969
|
+
seen.add(tx.txHash);
|
|
2970
|
+
return true;
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
// Build graph and analyze relationships
|
|
2974
|
+
const graph = buildTrustGraph(dedupedTxs);
|
|
2975
|
+
const relationships = analyzeTrustRelationships(state.addresses, dedupedTxs);
|
|
2976
|
+
|
|
2977
|
+
// Apply trust rules
|
|
2978
|
+
const rules = loadTrustRules();
|
|
2979
|
+
if (rules.length > 0) {
|
|
2980
|
+
applyTrustRules(relationships, rules);
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// Store in state
|
|
2984
|
+
state.trustGraph = graph;
|
|
2985
|
+
state.trustTransactions = dedupedTxs;
|
|
2986
|
+
state.trustRelationships = relationships;
|
|
2987
|
+
|
|
2988
|
+
// Update UI
|
|
2989
|
+
const listEl = $('trust-list');
|
|
2990
|
+
if (listEl) {
|
|
2991
|
+
renderTrustList(listEl, relationships, state.addresses);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
if (labelEl) labelEl.textContent = 'Last scan: just now';
|
|
2995
|
+
if (countEl) countEl.textContent = `${relationships.length} relationships`;
|
|
2996
|
+
|
|
2997
|
+
console.log(`Trust scan: ${dedupedTxs.length} txs, ${relationships.length} relationships`);
|
|
2998
|
+
} catch (err) {
|
|
2999
|
+
console.error('Trust scan failed:', err);
|
|
3000
|
+
if (labelEl) labelEl.textContent = 'Scan failed';
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
// Start auto-scanning
|
|
3005
|
+
function startTrustScanning() {
|
|
3006
|
+
runTrustScan();
|
|
3007
|
+
trustScanInterval = setInterval(runTrustScan, TRUST_SCAN_INTERVAL_MS);
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
// Stop auto-scanning
|
|
3011
|
+
function stopTrustScanning() {
|
|
3012
|
+
if (trustScanInterval) {
|
|
3013
|
+
clearInterval(trustScanInterval);
|
|
3014
|
+
trustScanInterval = null;
|
|
3015
|
+
}
|
|
3016
|
+
const statusEl = $('trust-scan-status');
|
|
3017
|
+
if (statusEl) statusEl.classList.remove('active');
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// Load trust rules from localStorage
|
|
3021
|
+
function loadTrustRules() {
|
|
3022
|
+
try {
|
|
3023
|
+
const raw = localStorage.getItem(TRUST_RULES_KEY);
|
|
3024
|
+
return raw ? JSON.parse(raw) : [];
|
|
3025
|
+
} catch (e) { return []; }
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
// Apply trust rules to relationships
|
|
3029
|
+
function applyTrustRules(relationships, rules) {
|
|
3030
|
+
for (const rel of relationships) {
|
|
3031
|
+
for (const rule of rules) {
|
|
3032
|
+
switch (rule.type) {
|
|
3033
|
+
case 'mutual_tx_count':
|
|
3034
|
+
if (rel.direction === 'mutual' && rel.txCount >= rule.params.threshold) {
|
|
3035
|
+
rel.ruleLevel = Math.max(rel.ruleLevel || 0, rule.resultLevel);
|
|
3036
|
+
}
|
|
3037
|
+
break;
|
|
3038
|
+
case 'last_interaction_days': {
|
|
3039
|
+
const daysSince = (Date.now() - rel.lastSeen) / (1000 * 60 * 60 * 24);
|
|
3040
|
+
if (daysSince <= rule.params.threshold) {
|
|
3041
|
+
rel.ruleLevel = Math.max(rel.ruleLevel || 0, rule.resultLevel);
|
|
3042
|
+
}
|
|
3043
|
+
break;
|
|
3044
|
+
}
|
|
3045
|
+
case 'bidirectional_trust':
|
|
3046
|
+
if (rel.direction === 'mutual') {
|
|
3047
|
+
rel.ruleLevel = Math.min((rel.level || 2) + 1, 5);
|
|
3048
|
+
}
|
|
3049
|
+
break;
|
|
3050
|
+
case 'address_blocklist':
|
|
3051
|
+
// Handled by NEVER trust level on-chain
|
|
3052
|
+
break;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
// Establish trust button
|
|
3059
|
+
$('establish-trust-btn')?.addEventListener('click', async () => {
|
|
3060
|
+
if (!state.loggedIn) { alert('Please login first'); return; }
|
|
3061
|
+
const { showEstablishTrustModal } = await import('./trust-ui.js');
|
|
3062
|
+
showEstablishTrustModal(({ level, network, recipientAddress }) => {
|
|
3063
|
+
console.log('Establish trust:', { level, network, recipientAddress });
|
|
3064
|
+
// TODO: Build, sign, and broadcast trust transaction
|
|
3065
|
+
alert(`Trust transaction would be published on ${network.toUpperCase()} for level ${level}.\nTransaction signing/broadcasting is not yet implemented.`);
|
|
3066
|
+
});
|
|
3067
|
+
});
|
|
3068
|
+
|
|
3069
|
+
// Rules button
|
|
3070
|
+
$('trust-rules-btn')?.addEventListener('click', async () => {
|
|
3071
|
+
const { showRulesModal } = await import('./trust-ui.js');
|
|
3072
|
+
const currentRules = loadTrustRules();
|
|
3073
|
+
showRulesModal(currentRules, (updatedRules) => {
|
|
3074
|
+
localStorage.setItem(TRUST_RULES_KEY, JSON.stringify(updatedRules));
|
|
3075
|
+
// Re-apply rules
|
|
3076
|
+
if (state.trustRelationships) {
|
|
3077
|
+
applyTrustRules(state.trustRelationships, updatedRules);
|
|
3078
|
+
runTrustScan();
|
|
3079
|
+
}
|
|
3080
|
+
});
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
// Export trust data
|
|
3084
|
+
$('trust-export-btn')?.addEventListener('click', async () => {
|
|
3085
|
+
if (!state.trustTransactions || state.trustTransactions.length === 0) {
|
|
3086
|
+
alert('No trust data to export. Wait for a scan to complete.');
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
const { exportTrustData } = await import('./trust-ui.js');
|
|
3090
|
+
const xpub = state.hdRoot ? state.hdRoot.publicExtendedKey() : '';
|
|
3091
|
+
exportTrustData(state.trustTransactions, xpub);
|
|
3092
|
+
});
|
|
3093
|
+
|
|
3094
|
+
// Import trust data
|
|
3095
|
+
$('trust-import-input')?.addEventListener('change', async (e) => {
|
|
3096
|
+
const file = e.target.files[0];
|
|
3097
|
+
if (!file) return;
|
|
3098
|
+
try {
|
|
3099
|
+
const { importTrustData } = await import('./trust-ui.js');
|
|
3100
|
+
const importedTxs = await importTrustData(file);
|
|
3101
|
+
|
|
3102
|
+
// Merge with existing imported txs
|
|
3103
|
+
let existing = [];
|
|
3104
|
+
try {
|
|
3105
|
+
const raw = localStorage.getItem(TRUST_IMPORTED_KEY);
|
|
3106
|
+
if (raw) existing = JSON.parse(raw);
|
|
3107
|
+
} catch (err) { /* ignore */ }
|
|
3108
|
+
|
|
3109
|
+
const merged = [...existing, ...importedTxs];
|
|
3110
|
+
const seen = new Set();
|
|
3111
|
+
const deduped = merged.filter(tx => {
|
|
3112
|
+
if (seen.has(tx.txHash)) return false;
|
|
3113
|
+
seen.add(tx.txHash);
|
|
3114
|
+
return true;
|
|
3115
|
+
});
|
|
3116
|
+
|
|
3117
|
+
localStorage.setItem(TRUST_IMPORTED_KEY, JSON.stringify(deduped));
|
|
3118
|
+
alert(`Imported ${importedTxs.length} trust transactions.`);
|
|
3119
|
+
|
|
3120
|
+
// Re-scan to incorporate
|
|
3121
|
+
runTrustScan();
|
|
3122
|
+
} catch (err) {
|
|
3123
|
+
console.error('Trust import failed:', err);
|
|
3124
|
+
alert('Failed to import trust data: ' + err.message);
|
|
3125
|
+
}
|
|
3126
|
+
e.target.value = '';
|
|
3127
|
+
});
|
|
3128
|
+
|
|
3129
|
+
// Expose start/stop for login/logout
|
|
3130
|
+
state._startTrustScanning = startTrustScanning;
|
|
3131
|
+
state._stopTrustScanning = stopTrustScanning;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
// =============================================================================
|
|
3135
|
+
// Homepage Handlers
|
|
3136
|
+
// =============================================================================
|
|
3137
|
+
|
|
3138
|
+
function setupHomepageHandlers() {
|
|
3139
|
+
// Version tag
|
|
3140
|
+
const versionTag = $('version-tag');
|
|
3141
|
+
if (versionTag) {
|
|
3142
|
+
try {
|
|
3143
|
+
const pkg = __APP_VERSION__;
|
|
3144
|
+
versionTag.textContent = pkg ? `v${pkg}` : '';
|
|
3145
|
+
} catch { /* ignore */ }
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
// Code copy buttons
|
|
3149
|
+
document.querySelectorAll('.code-copy-btn').forEach(btn => {
|
|
3150
|
+
btn.addEventListener('click', () => {
|
|
3151
|
+
const code = btn.closest('.code-block')?.querySelector('code');
|
|
3152
|
+
if (code) {
|
|
3153
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
3154
|
+
btn.title = 'Copied!';
|
|
3155
|
+
setTimeout(() => { btn.title = 'Copy code'; }, 2000);
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
});
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
// =============================================================================
|
|
3163
|
+
// Initialization
|
|
3164
|
+
// =============================================================================
|
|
3165
|
+
|
|
3166
|
+
export async function init(rootElement, options = {}) {
|
|
3167
|
+
const { autoOpenWallet = false } = typeof rootElement === 'object' && !(rootElement instanceof Node)
|
|
3168
|
+
? (options = rootElement, {}) : options;
|
|
3169
|
+
if (rootElement && rootElement instanceof Node) _root = rootElement;
|
|
3170
|
+
|
|
3171
|
+
// Inject modal HTML if not already present in the DOM
|
|
3172
|
+
if (!document.getElementById('keys-modal')) {
|
|
3173
|
+
const container = document.createElement('div');
|
|
3174
|
+
container.id = 'hd-wallet-ui-container';
|
|
3175
|
+
container.innerHTML = getModalHTML();
|
|
3176
|
+
document.body.appendChild(container);
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
const status = $('status');
|
|
3180
|
+
const loadingOverlay = $('loading-overlay');
|
|
3181
|
+
|
|
3182
|
+
// Initialize grid animation
|
|
3183
|
+
initGridAnimation();
|
|
3184
|
+
|
|
3185
|
+
// Initialize wallet info box state
|
|
3186
|
+
initWalletInfoBox();
|
|
3187
|
+
bindInfoHandlers();
|
|
3188
|
+
|
|
3189
|
+
try {
|
|
3190
|
+
// Load HD wallet WASM
|
|
3191
|
+
if (status) status.textContent = 'Loading HD wallet module...';
|
|
3192
|
+
state.hdWalletModule = await initHDWallet();
|
|
3193
|
+
|
|
3194
|
+
// Load saved PKI keys if available
|
|
3195
|
+
const hasSavedKeys = loadPKIKeys();
|
|
3196
|
+
|
|
3197
|
+
state.initialized = true;
|
|
3198
|
+
|
|
3199
|
+
// Update nav status
|
|
3200
|
+
const navStatus = $('nav-status');
|
|
3201
|
+
if (navStatus) {
|
|
3202
|
+
navStatus.className = 'nav-status ready';
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
// Hide loading overlay with fade
|
|
3206
|
+
if (loadingOverlay) {
|
|
3207
|
+
loadingOverlay.classList.add('hidden');
|
|
3208
|
+
setTimeout(() => {
|
|
3209
|
+
loadingOverlay.style.display = 'none';
|
|
3210
|
+
}, 500);
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
setupLoginHandlers();
|
|
3214
|
+
setupMainAppHandlers();
|
|
3215
|
+
initCurrencySelector();
|
|
3216
|
+
setupHomepageHandlers();
|
|
3217
|
+
|
|
3218
|
+
// Handle initial hash navigation
|
|
3219
|
+
const initialHash = window.location.hash.slice(1);
|
|
3220
|
+
if (initialHash) {
|
|
3221
|
+
const tabEl = $(`${initialHash}-tab`);
|
|
3222
|
+
if (tabEl) {
|
|
3223
|
+
setTimeout(() => {
|
|
3224
|
+
tabEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
3225
|
+
$qa('.nav-link[data-tab]').forEach(link => {
|
|
3226
|
+
link.classList.remove('active');
|
|
3227
|
+
if (link.dataset.tab === initialHash) {
|
|
3228
|
+
link.classList.add('active');
|
|
3229
|
+
}
|
|
3230
|
+
});
|
|
3231
|
+
}, 100);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
// Check if there's a stored wallet
|
|
3236
|
+
const storageMetadata = WalletStorage.getStorageMetadata();
|
|
3237
|
+
const hasStoredWallet = storageMetadata?.method && storageMetadata.method !== StorageMethod.NONE;
|
|
3238
|
+
|
|
3239
|
+
// Auto-open login modal if stored wallet found (opt-in for integrators)
|
|
3240
|
+
if (hasStoredWallet && autoOpenWallet) {
|
|
3241
|
+
const loginModal = $('login-modal');
|
|
3242
|
+
if (loginModal) {
|
|
3243
|
+
loginModal.classList.add('active');
|
|
3244
|
+
// Switch to stored wallet tab
|
|
3245
|
+
const storedTab = loginModal.querySelector('[data-tab="stored"]');
|
|
3246
|
+
if (storedTab) storedTab.click();
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
// Auto-login with saved PKI keys if no stored wallet
|
|
3251
|
+
if (hasSavedKeys && !hasStoredWallet) {
|
|
3252
|
+
const tempEd25519Seed = new Uint8Array(32);
|
|
3253
|
+
crypto.getRandomValues(tempEd25519Seed);
|
|
3254
|
+
const tempKeys = {
|
|
3255
|
+
x25519: generateKeyPair(Curve.X25519),
|
|
3256
|
+
ed25519: {
|
|
3257
|
+
privateKey: tempEd25519Seed,
|
|
3258
|
+
publicKey: ed25519.getPublicKey(tempEd25519Seed),
|
|
3259
|
+
},
|
|
3260
|
+
secp256k1: generateKeyPair(Curve.SECP256K1),
|
|
3261
|
+
p256: await p256GenerateKeyPairAsync(),
|
|
3262
|
+
p384: await p384GenerateKeyPairAsync(),
|
|
3263
|
+
};
|
|
3264
|
+
|
|
3265
|
+
login(tempKeys);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
} catch (err) {
|
|
3269
|
+
console.error('Initialization failed:', err);
|
|
3270
|
+
if (status) status.textContent = `Error: ${err.message}`;
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
/**
|
|
3275
|
+
* Create a wallet UI instance that can be controlled programmatically.
|
|
3276
|
+
* Consumers attach openLogin / openAccount to their own buttons.
|
|
3277
|
+
*
|
|
3278
|
+
* @param {Node} [rootElement] - Optional root element for DOM queries
|
|
3279
|
+
* @param {Object} [options] - Options passed to init()
|
|
3280
|
+
* @returns {Promise<{openLogin: Function, openAccount: Function, destroy: Function}>}
|
|
3281
|
+
*/
|
|
3282
|
+
export async function createWalletUI(rootElement, options = {}) {
|
|
3283
|
+
await init(rootElement, options);
|
|
3284
|
+
|
|
3285
|
+
return {
|
|
3286
|
+
/** Open the login modal */
|
|
3287
|
+
openLogin() {
|
|
3288
|
+
const modal = document.getElementById('login-modal');
|
|
3289
|
+
if (modal) modal.classList.add('active');
|
|
3290
|
+
},
|
|
3291
|
+
/** Open the account / keys modal (requires login first) */
|
|
3292
|
+
openAccount() {
|
|
3293
|
+
const modal = document.getElementById('keys-modal');
|
|
3294
|
+
if (modal) modal.classList.add('active');
|
|
3295
|
+
},
|
|
3296
|
+
/** Remove all injected wallet UI elements from the DOM */
|
|
3297
|
+
destroy() {
|
|
3298
|
+
const container = document.getElementById('hd-wallet-ui-container');
|
|
3299
|
+
if (container) container.remove();
|
|
3300
|
+
},
|
|
3301
|
+
};
|
|
3302
|
+
}
|