hd-wallet-ui 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js CHANGED
@@ -20,7 +20,7 @@ import { Buffer } from 'buffer';
20
20
  import { createV3 } from 'vcard-cryptoperson';
21
21
 
22
22
  // SpaceDataStandards EME (Encrypted Message Envelope)
23
- import { EME, EMET } from '@sds/lib/js/ts/EME/EME.js';
23
+ import { EME, EMET } from '@sds/lib/js/EME/EME.js';
24
24
  import * as flatbuffers from 'flatbuffers';
25
25
 
26
26
  // Make Buffer available globally for various crypto libraries
@@ -908,9 +908,6 @@ function login(keys) {
908
908
  // Update wallet addresses and balances
909
909
  updateAdversarialSecurity();
910
910
 
911
- // Populate vCard keys display
912
- populateVCardKeysDisplay();
913
-
914
911
  // Open Account modal so user can see the wallet they just loaded
915
912
  $('keys-modal')?.classList.add('active');
916
913
  deriveAndDisplayAddress();
@@ -1000,7 +997,7 @@ async function exportWallet(format) {
1000
997
  break;
1001
998
 
1002
999
  case 'xpub':
1003
- if (!state.hdRoot?.publicExtendedKey) {
1000
+ if (!state.hdRoot?.toXpub) {
1004
1001
  alert('Extended public key not available.');
1005
1002
  return;
1006
1003
  }
@@ -1010,7 +1007,7 @@ async function exportWallet(format) {
1010
1007
  break;
1011
1008
 
1012
1009
  case 'xprv':
1013
- if (!state.hdRoot?.privateExtendedKey) {
1010
+ if (!state.hdRoot?.toXprv) {
1014
1011
  alert('Extended private key not available.');
1015
1012
  return;
1016
1013
  }
@@ -1060,104 +1057,26 @@ function downloadData(data, filename, mimeType) {
1060
1057
  // Wallet Address Population & Balance Fetching
1061
1058
  // =============================================================================
1062
1059
 
1063
- // Account address dropdown populated once after login, updated when balances arrive
1064
- let _accountAddressData = {}; // { xpub: { addr, value }, btc: { addr, value }, ... }
1065
-
1060
+ // Account headershow xpub only
1066
1061
  function populateAccountAddressDropdown() {
1067
- const sel = $('account-address-select');
1068
- if (!sel) return;
1062
+ const addrEl = $('account-address-display');
1063
+ if (!addrEl) return;
1069
1064
 
1070
1065
  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);
1066
+ addrEl.textContent = xpubStr;
1067
+ addrEl.title = xpubStr;
1111
1068
 
1112
1069
  const copyBtn = $('account-address-copy');
1113
1070
  if (copyBtn) {
1114
1071
  copyBtn.onclick = () => {
1115
- const key = sel.value;
1116
- const data = _accountAddressData[key];
1117
- if (data?.addr) {
1118
- navigator.clipboard.writeText(data.addr).then(() => {
1072
+ if (xpubStr) {
1073
+ navigator.clipboard.writeText(xpubStr).then(() => {
1119
1074
  copyBtn.title = 'Copied!';
1120
- setTimeout(() => { copyBtn.title = 'Copy address'; }, 1500);
1075
+ setTimeout(() => { copyBtn.title = 'Copy xpub'; }, 1500);
1121
1076
  });
1122
1077
  }
1123
1078
  };
1124
1079
  }
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
1080
  }
1162
1081
 
1163
1082
  function populateWalletAddresses() {
@@ -1629,8 +1548,6 @@ async function updateAdversarialSecurity() {
1629
1548
  accountTotalEl.textContent = 'Bond: ' + formatCurrencyValue(totalConverted, currency);
1630
1549
  }
1631
1550
 
1632
- // Update account address dropdown values
1633
- updateAccountAddressValues(bondBalances, cryptoPrices, currency);
1634
1551
  }
1635
1552
 
1636
1553
  // =============================================================================
@@ -1638,7 +1555,7 @@ async function updateAdversarialSecurity() {
1638
1555
  // =============================================================================
1639
1556
 
1640
1557
  function generateVCard(info, { skipPhoto = false } = {}) {
1641
- const person = {};
1558
+ const person = { KEY: [] };
1642
1559
 
1643
1560
  if (info.firstName || info.lastName) {
1644
1561
  if (info.lastName) person.FAMILY_NAME = info.lastName;
@@ -1648,8 +1565,15 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1648
1565
  if (info.suffix) person.HONORIFIC_SUFFIX = info.suffix;
1649
1566
  }
1650
1567
 
1568
+ const contacts = [];
1651
1569
  if (info.email) {
1652
- person.CONTACT_POINT = [{ EMAIL: info.email }];
1570
+ contacts.push({ EMAIL: info.email, CONTACT_TYPE: 'HOME' });
1571
+ }
1572
+ if (info.phone) {
1573
+ contacts.push({ TELEPHONE: info.phone, CONTACT_TYPE: 'CELL' });
1574
+ }
1575
+ if (contacts.length) {
1576
+ person.CONTACT_POINT = contacts;
1653
1577
  }
1654
1578
 
1655
1579
  if (info.org) {
@@ -1664,34 +1588,42 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1664
1588
  person.IMAGE = state.vcardPhoto;
1665
1589
  }
1666
1590
 
1667
- if (info.includeKeys && state.wallet.x25519) {
1591
+ if (info.includeKeys && state.wallet?.x25519) {
1668
1592
  person.KEY = [
1669
- ...(state.hdRoot?.publicExtendedKey ? [{
1670
- KEY_TYPE: 'xpub',
1671
- PUBLIC_KEY: state.hdRoot.toXpub(),
1593
+ ...(state.hdRoot?.toXpub ? [{
1594
+ XPUB: state.hdRoot.toXpub(),
1595
+ LABEL: '',
1672
1596
  }] : []),
1673
1597
  {
1674
- KEY_TYPE: 'X25519',
1675
1598
  PUBLIC_KEY: toBase64(state.wallet.x25519.publicKey),
1599
+ LABEL: 'X25519',
1676
1600
  },
1677
1601
  {
1678
- KEY_TYPE: 'Ed25519',
1679
1602
  PUBLIC_KEY: toBase64(state.wallet.ed25519.publicKey),
1603
+ LABEL: "Ed25519 m/44'/501'/0'/0/0",
1680
1604
  },
1681
1605
  {
1682
- KEY_TYPE: 'secp256k1',
1683
1606
  PUBLIC_KEY: toBase64(state.wallet.secp256k1.publicKey),
1684
- CRYPTO_ADDRESS: state.addresses.btc || undefined,
1607
+ KEY_ADDRESS: state.addresses.btc || undefined,
1608
+ LABEL: "secp256k1 m/44'/0'/0'/0/0",
1685
1609
  },
1686
1610
  ];
1611
+ } else if (info.xpubOnly && state.hdRoot?.toXpub) {
1612
+ person.KEY = [{ XPUB: state.hdRoot.toXpub(), LABEL: '' }];
1687
1613
  }
1688
1614
 
1689
1615
  const note = info.includeKeys
1690
- ? 'Generated by HD Wallet UI'
1616
+ ? 'Generated by Space Data Network'
1691
1617
  : undefined;
1692
1618
 
1693
1619
  let vcard = createV3(person, note);
1694
1620
 
1621
+ // Add NICKNAME field (not supported by createV3)
1622
+ if (info.nickname) {
1623
+ vcard = vcard.replace('END:VCARD', `NICKNAME:${info.nickname}\nEND:VCARD`);
1624
+ }
1625
+
1626
+
1695
1627
  // Convert PHOTO from data URI format to iOS-compatible inline base64 format
1696
1628
  vcard = vcard.replace(
1697
1629
  /PHOTO;VALUE=URI:data:image\/(\w+);base64,([^\n]+)\n/,
@@ -1709,127 +1641,126 @@ function generateVCard(info, { skipPhoto = false } = {}) {
1709
1641
  }
1710
1642
 
1711
1643
  // =============================================================================
1712
- // vCard Keys Display
1644
+ // vCard Digital Signature (Ed25519)
1713
1645
  // =============================================================================
1714
1646
 
1715
- function populateVCardKeysDisplay() {
1716
- const keysDisplay = $('vcard-keys-display');
1717
- if (!keysDisplay) return;
1647
+ function getSignableBody(vcardText) {
1648
+ const lines = vcardText.split('\n');
1649
+ const sigItems = new Set();
1650
+ for (const line of lines) {
1651
+ const m = line.match(/^item(\d+)\.X-ABLabel:Digital Signature/);
1652
+ if (m) sigItems.add(m[1]);
1653
+ }
1654
+ return lines.filter(line => {
1655
+ if (line.trim() === 'END:VCARD') return false;
1656
+ for (const n of sigItems) {
1657
+ if (line.startsWith(`item${n}.`)) return false;
1658
+ }
1659
+ return true;
1660
+ }).join('\n') + '\n';
1661
+ }
1718
1662
 
1719
- const keys = [];
1663
+ function signVCard(vcardText) {
1664
+ if (!state.wallet?.ed25519?.privateKey) return vcardText;
1720
1665
 
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
- });
1666
+ const body = getSignableBody(vcardText);
1667
+ const messageBytes = new TextEncoder().encode(body);
1668
+ const signature = ed25519.sign(messageBytes, state.wallet.ed25519.privateKey);
1669
+ const sigB64 = toBase64(signature);
1670
+
1671
+ // Encode signature + derivation path (coinType=501, account=0, index=0)
1672
+ const sigValue = `${sigB64}:501:0:0`;
1673
+
1674
+ // Find highest itemN and key index
1675
+ let maxItem = 0;
1676
+ let maxKeyIdx = 0;
1677
+ const itemRe = /item(\d+)\./g;
1678
+ const keyIdxRe = /#(\d+)/g;
1679
+ let match;
1680
+ while ((match = itemRe.exec(vcardText)) !== null) {
1681
+ maxItem = Math.max(maxItem, parseInt(match[1], 10));
1682
+ }
1683
+ while ((match = keyIdxRe.exec(vcardText)) !== null) {
1684
+ maxKeyIdx = Math.max(maxKeyIdx, parseInt(match[1], 10));
1732
1685
  }
1733
1686
 
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
- });
1687
+ const sigLines =
1688
+ `item${maxItem + 1}.X-ABLabel:Digital Signature #${maxKeyIdx + 1}\n` +
1689
+ `item${maxItem + 1}.X-ABRELATEDNAMES:${sigValue}\n`;
1690
+
1691
+ return body + sigLines + 'END:VCARD';
1692
+ }
1693
+
1694
+ function verifyVCardSignature(vcardText) {
1695
+ // Parse all itemN label/value pairs
1696
+ const lines = vcardText.split('\n');
1697
+ const items = {};
1698
+ for (const line of lines) {
1699
+ const labelMatch = line.match(/^item(\d+)\.X-ABLabel:(.+)/);
1700
+ if (labelMatch) {
1701
+ items[labelMatch[1]] = items[labelMatch[1]] || {};
1702
+ items[labelMatch[1]].label = labelMatch[2].trim();
1703
+ }
1704
+ const valueMatch = line.match(/^item(\d+)\.X-ABRELATEDNAMES:(.+)/);
1705
+ if (valueMatch) {
1706
+ items[valueMatch[1]] = items[valueMatch[1]] || {};
1707
+ items[valueMatch[1]].value = valueMatch[2].trim();
1708
+ }
1745
1709
  }
1746
1710
 
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
- });
1711
+ // Find Digital Signature entry
1712
+ let sigValue = null;
1713
+ for (const item of Object.values(items)) {
1714
+ if (item.label?.startsWith('Digital Signature') && item.value) {
1715
+ sigValue = item.value;
1716
+ }
1758
1717
  }
1759
1718
 
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
- });
1719
+ if (!sigValue) {
1720
+ return { verified: false, path: null, publicKey: null, error: 'unsigned' };
1771
1721
  }
1772
1722
 
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');
1723
+ // Parse signature value: base64sig:coinType:account:index
1724
+ const parts = sigValue.split(':');
1725
+ if (parts.length < 4) {
1726
+ return { verified: false, path: null, publicKey: null, error: 'Malformed signature' };
1727
+ }
1728
+ const sigB64 = parts[0];
1729
+ const coinType = parts[1];
1730
+ const account = parts[2];
1731
+ const index = parts[3];
1732
+ const path = `m/44'/${coinType}'/${account}'/0/${index}`;
1733
+
1734
+ // Find Ed25519 public key — look for "Public Key" entries with 32-byte (44-char base64) values
1735
+ let ed25519PubB64 = null;
1736
+ for (const item of Object.values(items)) {
1737
+ if (item.label?.startsWith('Public Key') && item.value) {
1823
1738
  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
- });
1739
+ const decoded = Uint8Array.from(atob(item.value), c => c.charCodeAt(0));
1740
+ if (decoded.length === 32) {
1741
+ ed25519PubB64 = item.value;
1742
+ break;
1743
+ }
1744
+ } catch { /* not base64 */ }
1745
+ }
1746
+ }
1747
+
1748
+ if (!ed25519PubB64) {
1749
+ return { verified: false, path, publicKey: null, error: 'No Ed25519 public key found' };
1750
+ }
1751
+
1752
+ // Reconstruct signable body and verify
1753
+ const body = getSignableBody(vcardText);
1754
+ const messageBytes = new TextEncoder().encode(body);
1755
+ const sigBytes = Uint8Array.from(atob(sigB64), c => c.charCodeAt(0));
1756
+ const pubKeyBytes = Uint8Array.from(atob(ed25519PubB64), c => c.charCodeAt(0));
1757
+
1758
+ try {
1759
+ const valid = ed25519.verify(sigBytes, messageBytes, pubKeyBytes);
1760
+ return { verified: valid, path, publicKey: ed25519PubB64, error: valid ? null : 'Signature invalid' };
1761
+ } catch (e) {
1762
+ return { verified: false, path, publicKey: ed25519PubB64, error: e.message };
1763
+ }
1833
1764
  }
1834
1765
 
1835
1766
  function parseAndDisplayVCF(vcfText) {
@@ -1913,6 +1844,23 @@ function parseAndDisplayVCF(vcfText) {
1913
1844
  html += '</div>';
1914
1845
  }
1915
1846
 
1847
+ // Verify digital signature
1848
+ const sigStatus = $('vcf-import-sig-status');
1849
+ if (sigStatus) {
1850
+ const result = verifyVCardSignature(vcfText);
1851
+ if (result.error === 'unsigned') {
1852
+ sigStatus.className = 'vcard-sig-badge sig-unsigned';
1853
+ sigStatus.innerHTML = 'No signature';
1854
+ } else if (result.verified) {
1855
+ sigStatus.className = 'vcard-sig-badge sig-verified';
1856
+ sigStatus.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Verified (${result.path})`;
1857
+ } else {
1858
+ sigStatus.className = 'vcard-sig-badge sig-invalid';
1859
+ sigStatus.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> Invalid signature`;
1860
+ }
1861
+ sigStatus.style.display = 'flex';
1862
+ }
1863
+
1916
1864
  fieldsEl.innerHTML = html;
1917
1865
  resultEl.style.display = 'block';
1918
1866
  }
@@ -2431,11 +2379,55 @@ function setupMainAppHandlers() {
2431
2379
  });
2432
2380
  });
2433
2381
 
2382
+ // Messaging sub-tab switching
2383
+ $qa('.messaging-sub-tab').forEach(tab => {
2384
+ tab.addEventListener('click', () => {
2385
+ $qa('.messaging-sub-tab').forEach(t => t.classList.remove('active'));
2386
+ $qa('.messaging-sub-content').forEach(c => c.classList.remove('active'));
2387
+ tab.classList.add('active');
2388
+ const target = $(tab.dataset.messagingSub);
2389
+ if (target) target.classList.add('active');
2390
+ });
2391
+ });
2392
+
2393
+ // Identity card summary — updates the read-only card display from form fields
2394
+ function updateIdentityCardSummary() {
2395
+ const prefix = $('vcard-prefix')?.value || '';
2396
+ const first = $('vcard-firstname')?.value || '';
2397
+ const middle = $('vcard-middlename')?.value || '';
2398
+ const last = $('vcard-lastname')?.value || '';
2399
+ const suffix = $('vcard-suffix')?.value || '';
2400
+ const nick = $('vcard-nickname')?.value || '';
2401
+ const parts = [prefix, first, middle, last, suffix].filter(Boolean);
2402
+ const nameEl = $('identity-card-name');
2403
+ if (nameEl) {
2404
+ const namePart = parts.length > 0 ? parts.join(' ') : '--';
2405
+ if (nick) {
2406
+ nameEl.innerHTML = `${namePart} <span class="nickname">(${nick})</span>`;
2407
+ } else {
2408
+ nameEl.textContent = namePart;
2409
+ }
2410
+ }
2411
+
2412
+ const titleEl = $('identity-card-title');
2413
+ if (titleEl) titleEl.textContent = $('vcard-title')?.value || '';
2414
+
2415
+ const orgEl = $('identity-card-org');
2416
+ if (orgEl) orgEl.textContent = $('vcard-org')?.value || '';
2417
+
2418
+ const emailEl = $('identity-card-email');
2419
+ if (emailEl) emailEl.textContent = $('vcard-email')?.value || '';
2420
+
2421
+ const phoneEl = $('identity-card-phone');
2422
+ if (phoneEl) phoneEl.textContent = $('vcard-phone')?.value || '';
2423
+ }
2424
+
2425
+
2434
2426
  // vCard identity auto-save
2435
2427
  const VCARD_STORAGE_KEY = 'hd-wallet-vcard-identity';
2436
2428
  const vcardFieldIds = [
2437
2429
  'vcard-prefix', 'vcard-firstname', 'vcard-middlename', 'vcard-lastname',
2438
- 'vcard-suffix', 'vcard-email', 'vcard-phone', 'vcard-org', 'vcard-title',
2430
+ 'vcard-suffix', 'vcard-nickname', 'vcard-email', 'vcard-phone', 'vcard-org', 'vcard-title',
2439
2431
  'vcard-street', 'vcard-city', 'vcard-region', 'vcard-postal', 'vcard-country'
2440
2432
  ];
2441
2433
 
@@ -2466,17 +2458,96 @@ function setupMainAppHandlers() {
2466
2458
  }
2467
2459
 
2468
2460
  restoreVcardIdentity();
2461
+ updateIdentityCardSummary();
2469
2462
 
2470
- let vcardSaveTimer = null;
2471
- function debouncedVcardSave() {
2472
- clearTimeout(vcardSaveTimer);
2473
- vcardSaveTimer = setTimeout(saveVcardIdentity, 500);
2463
+ // Snapshot of field values before editing (for Back/cancel)
2464
+ let _vcardEditSnapshot = {};
2465
+
2466
+ function snapshotVcardFields() {
2467
+ _vcardEditSnapshot = {};
2468
+ for (const id of vcardFieldIds) {
2469
+ const el = $(id);
2470
+ if (el) _vcardEditSnapshot[id] = el.value;
2471
+ }
2474
2472
  }
2475
2473
 
2476
- for (const id of vcardFieldIds) {
2477
- $(id)?.addEventListener('input', debouncedVcardSave);
2474
+ function restoreVcardSnapshot() {
2475
+ for (const id of vcardFieldIds) {
2476
+ const el = $(id);
2477
+ if (el && _vcardEditSnapshot[id] !== undefined) el.value = _vcardEditSnapshot[id];
2478
+ }
2478
2479
  }
2479
2480
 
2481
+ // Edit button — switch to edit view
2482
+ $('identity-edit-btn')?.addEventListener('click', () => {
2483
+ setPhotoActionsVisible(false);
2484
+ stopCamera();
2485
+ snapshotVcardFields();
2486
+ $('vcard-form-view').style.display = 'none';
2487
+ $('vcard-edit-view').style.display = 'flex';
2488
+ });
2489
+
2490
+ // Save button — persist and return to card view
2491
+ $('identity-save-btn')?.addEventListener('click', () => {
2492
+ saveVcardIdentity();
2493
+ updateIdentityCardSummary();
2494
+ setPhotoActionsVisible(false);
2495
+ $('vcard-edit-view').style.display = 'none';
2496
+ $('vcard-form-view').style.display = '';
2497
+ });
2498
+
2499
+ // Back button — discard changes and return to card view
2500
+ $('identity-back-btn')?.addEventListener('click', () => {
2501
+ restoreVcardSnapshot();
2502
+ setPhotoActionsVisible(false);
2503
+ stopCamera();
2504
+ $('vcard-edit-view').style.display = 'none';
2505
+ $('vcard-form-view').style.display = '';
2506
+ });
2507
+
2508
+ const photoActions = $('vcard-photo-actions');
2509
+ const photoEditBtn = $('vcard-photo-edit-btn');
2510
+ function setPhotoActionsVisible(visible) {
2511
+ if (photoActions) photoActions.classList.toggle('visible', visible);
2512
+ if (photoEditBtn) {
2513
+ photoEditBtn.classList.toggle('is-open', visible);
2514
+ photoEditBtn.title = visible ? 'Close Photo Menu' : 'Edit Photo';
2515
+ }
2516
+ }
2517
+ function arePhotoActionsVisible() {
2518
+ return !!photoActions?.classList.contains('visible');
2519
+ }
2520
+ setPhotoActionsVisible(false);
2521
+
2522
+ function encodeVcardPhoto(source, sourceWidth, sourceHeight) {
2523
+ if (!source || !sourceWidth || !sourceHeight) return null;
2524
+ const maxDimension = 1024;
2525
+ const quality = 0.9;
2526
+ const scale = Math.min(1, maxDimension / Math.max(sourceWidth, sourceHeight));
2527
+ const outputWidth = Math.max(1, Math.round(sourceWidth * scale));
2528
+ const outputHeight = Math.max(1, Math.round(sourceHeight * scale));
2529
+ const canvas = document.createElement('canvas');
2530
+ canvas.width = outputWidth;
2531
+ canvas.height = outputHeight;
2532
+ const ctx = canvas.getContext('2d');
2533
+ if (!ctx) return null;
2534
+ ctx.imageSmoothingEnabled = true;
2535
+ ctx.imageSmoothingQuality = 'high';
2536
+ ctx.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, outputWidth, outputHeight);
2537
+ return canvas.toDataURL('image/jpeg', quality);
2538
+ }
2539
+
2540
+ photoEditBtn?.addEventListener('click', (e) => {
2541
+ e.preventDefault();
2542
+ e.stopPropagation();
2543
+ if (arePhotoActionsVisible()) {
2544
+ setPhotoActionsVisible(false);
2545
+ stopCamera();
2546
+ return;
2547
+ }
2548
+ setPhotoActionsVisible(true);
2549
+ });
2550
+
2480
2551
  // Photo upload handler
2481
2552
  $('vcard-photo-input')?.addEventListener('change', (e) => {
2482
2553
  const file = e.target.files[0];
@@ -2485,19 +2556,12 @@ function setupMainAppHandlers() {
2485
2556
  reader.onload = (ev) => {
2486
2557
  const img = new Image();
2487
2558
  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);
2559
+ const dataUrl = encodeVcardPhoto(img, img.width, img.height);
2560
+ if (!dataUrl) return;
2498
2561
  state.vcardPhoto = dataUrl;
2499
2562
  stopCamera();
2500
2563
  showPhotoPreview(dataUrl);
2564
+ setPhotoActionsVisible(false);
2501
2565
  saveVcardIdentity();
2502
2566
  };
2503
2567
  img.src = ev.target.result;
@@ -2526,6 +2590,7 @@ function setupMainAppHandlers() {
2526
2590
  const cameraBtn = $('vcard-camera-btn');
2527
2591
  if (cameraBtn) cameraBtn.style.display = '';
2528
2592
  }
2593
+ setPhotoActionsVisible(false);
2529
2594
  const modal = $('photo-remove-confirm-modal');
2530
2595
  if (modal) modal.classList.remove('active');
2531
2596
  });
@@ -2543,6 +2608,14 @@ function setupMainAppHandlers() {
2543
2608
  if (placeholder) placeholder.style.display = '';
2544
2609
  const video = $('vcard-camera-video');
2545
2610
  if (video) video.style.display = 'none';
2611
+ const removeBtn = $('vcard-photo-remove');
2612
+ if (removeBtn) removeBtn.style.display = 'none';
2613
+ const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
2614
+ if (uploadLabel) uploadLabel.style.display = '';
2615
+ const cameraBtn = $('vcard-camera-btn');
2616
+ if (cameraBtn && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
2617
+ cameraBtn.style.display = '';
2618
+ }
2546
2619
  }
2547
2620
 
2548
2621
  function showPhotoPreview(dataUrl) {
@@ -2559,23 +2632,28 @@ function setupMainAppHandlers() {
2559
2632
  preview.appendChild(img);
2560
2633
  const removeBtn = $('vcard-photo-remove');
2561
2634
  if (removeBtn) removeBtn.style.display = '';
2562
- // Hide upload/camera buttons when photo is present
2563
2635
  const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
2564
- if (uploadLabel) uploadLabel.style.display = 'none';
2636
+ if (uploadLabel) uploadLabel.style.display = '';
2565
2637
  const cameraBtn = $('vcard-camera-btn');
2566
- if (cameraBtn) cameraBtn.style.display = 'none';
2638
+ if (cameraBtn && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
2639
+ cameraBtn.style.display = '';
2640
+ }
2567
2641
  }
2568
2642
 
2569
2643
  // Camera support
2570
2644
  let cameraStream = null;
2571
2645
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
2572
2646
  const cameraBtn = $('vcard-camera-btn');
2573
- if (cameraBtn && !state.vcardPhoto) cameraBtn.style.display = '';
2647
+ if (cameraBtn) cameraBtn.style.display = '';
2574
2648
 
2575
2649
  cameraBtn?.addEventListener('click', async () => {
2576
2650
  try {
2577
2651
  cameraStream = await navigator.mediaDevices.getUserMedia({
2578
- video: { facingMode: 'user', width: { ideal: 512 }, height: { ideal: 512 } }
2652
+ video: {
2653
+ facingMode: 'user',
2654
+ width: { ideal: 1280, max: 1920 },
2655
+ height: { ideal: 720, max: 1080 },
2656
+ }
2579
2657
  });
2580
2658
  const video = $('vcard-camera-video');
2581
2659
  if (video) {
@@ -2590,6 +2668,10 @@ function setupMainAppHandlers() {
2590
2668
  preview.querySelectorAll('img').forEach(el => el.style.display = 'none');
2591
2669
  }
2592
2670
  cameraBtn.style.display = 'none';
2671
+ const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
2672
+ if (uploadLabel) uploadLabel.style.display = 'none';
2673
+ const removeBtn = $('vcard-photo-remove');
2674
+ if (removeBtn) removeBtn.style.display = 'none';
2593
2675
  const captureBtn = $('vcard-camera-capture');
2594
2676
  const cancelBtn = $('vcard-camera-cancel');
2595
2677
  if (captureBtn) captureBtn.style.display = '';
@@ -2603,21 +2685,14 @@ function setupMainAppHandlers() {
2603
2685
  $('vcard-camera-capture')?.addEventListener('click', () => {
2604
2686
  const video = $('vcard-camera-video');
2605
2687
  if (!video) return;
2606
- const canvas = document.createElement('canvas');
2607
- const size = 128;
2608
- canvas.width = size;
2609
- canvas.height = size;
2610
- const ctx = canvas.getContext('2d');
2611
2688
  const vw = video.videoWidth;
2612
2689
  const vh = video.videoHeight;
2613
- const min = Math.min(vw, vh);
2614
- const sx = (vw - min) / 2;
2615
- const sy = (vh - min) / 2;
2616
- ctx.drawImage(video, sx, sy, min, min, 0, 0, size, size);
2617
- const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
2690
+ const dataUrl = encodeVcardPhoto(video, vw, vh);
2691
+ if (!dataUrl) return;
2618
2692
  state.vcardPhoto = dataUrl;
2619
2693
  stopCamera();
2620
2694
  showPhotoPreview(dataUrl);
2695
+ setPhotoActionsVisible(false);
2621
2696
  saveVcardIdentity();
2622
2697
  });
2623
2698
 
@@ -2642,9 +2717,13 @@ function setupMainAppHandlers() {
2642
2717
  video.style.display = 'none';
2643
2718
  }
2644
2719
  const cameraBtn = $('vcard-camera-btn');
2720
+ const uploadLabel = document.querySelector('label[for="vcard-photo-input"]');
2721
+ const removeBtn = $('vcard-photo-remove');
2645
2722
  const captureBtn = $('vcard-camera-capture');
2646
2723
  const cancelBtn = $('vcard-camera-cancel');
2647
- if (cameraBtn) cameraBtn.style.display = state.vcardPhoto ? 'none' : '';
2724
+ if (uploadLabel) uploadLabel.style.display = '';
2725
+ if (removeBtn) removeBtn.style.display = state.vcardPhoto ? '' : 'none';
2726
+ if (cameraBtn && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) cameraBtn.style.display = '';
2648
2727
  if (captureBtn) captureBtn.style.display = 'none';
2649
2728
  if (cancelBtn) cancelBtn.style.display = 'none';
2650
2729
  }
@@ -2845,7 +2924,9 @@ function setupMainAppHandlers() {
2845
2924
  middleName: $('vcard-middlename')?.value || '',
2846
2925
  lastName: $('vcard-lastname')?.value || '',
2847
2926
  suffix: $('vcard-suffix')?.value || '',
2927
+ nickname: $('vcard-nickname')?.value || '',
2848
2928
  email: $('vcard-email')?.value || '',
2929
+ phone: $('vcard-phone')?.value || '',
2849
2930
  org: $('vcard-org')?.value || '',
2850
2931
  title: $('vcard-title')?.value || '',
2851
2932
  includeKeys: true,
@@ -2856,10 +2937,9 @@ function setupMainAppHandlers() {
2856
2937
  return;
2857
2938
  }
2858
2939
 
2859
- const vcard = generateVCard(info);
2860
- const vcardForQR = generateVCard(info, { skipPhoto: true });
2861
- const vcardPreview = $('vcard-preview');
2862
- if (vcardPreview) vcardPreview.textContent = vcard;
2940
+ const vcard = signVCard(generateVCard(info));
2941
+ const vcardForQR = signVCard(generateVCard(info, { skipPhoto: true }));
2942
+ state._exportedVCard = vcard;
2863
2943
 
2864
2944
  try {
2865
2945
  const qrCanvas = $('qr-code');
@@ -2874,11 +2954,49 @@ function setupMainAppHandlers() {
2874
2954
  const resultView = $('vcard-result-view');
2875
2955
  if (formView) formView.style.display = 'none';
2876
2956
  if (resultView) resultView.style.display = '';
2957
+
2958
+ // Show/hide signature badge
2959
+ const sigBadge = $('vcard-sig-badge');
2960
+ if (sigBadge) {
2961
+ sigBadge.style.display = state.wallet?.ed25519?.privateKey ? 'flex' : 'none';
2962
+ }
2963
+
2964
+ // Populate raw view (strip PHOTO base64 data)
2965
+ const rawView = $('vcard-raw-view');
2966
+ if (rawView) {
2967
+ const rawText = vcard.replace(/PHOTO;ENCODING=b;TYPE=\w+:[\s\S]*?(?=\n[A-Z])/m, 'PHOTO:[image data omitted]');
2968
+ rawView.textContent = rawText;
2969
+ }
2970
+
2971
+ // Reset toggle to QR
2972
+ $('vcard-toggle-qr')?.classList.add('active');
2973
+ $('vcard-toggle-raw')?.classList.remove('active');
2974
+ document.querySelector('.qr-container')?.style.setProperty('display', '');
2975
+ if (rawView) rawView.style.display = 'none';
2877
2976
  } catch (err) {
2878
2977
  alert('Error generating QR code: ' + err.message);
2879
2978
  }
2880
2979
  });
2881
2980
 
2981
+ // Toggle QR / Raw
2982
+ $('vcard-toggle-qr')?.addEventListener('click', () => {
2983
+ $('vcard-toggle-qr')?.classList.add('active');
2984
+ $('vcard-toggle-raw')?.classList.remove('active');
2985
+ const qr = document.querySelector('#vcard-result-view .qr-container');
2986
+ const raw = $('vcard-raw-view');
2987
+ if (qr) qr.style.display = '';
2988
+ if (raw) raw.style.display = 'none';
2989
+ });
2990
+
2991
+ $('vcard-toggle-raw')?.addEventListener('click', () => {
2992
+ $('vcard-toggle-raw')?.classList.add('active');
2993
+ $('vcard-toggle-qr')?.classList.remove('active');
2994
+ const qr = document.querySelector('#vcard-result-view .qr-container');
2995
+ const raw = $('vcard-raw-view');
2996
+ if (qr) qr.style.display = 'none';
2997
+ if (raw) raw.style.display = '';
2998
+ });
2999
+
2882
3000
  // Back to editor from result view
2883
3001
  $('vcard-back-btn')?.addEventListener('click', () => {
2884
3002
  const resultView = $('vcard-result-view');
@@ -2889,7 +3007,7 @@ function setupMainAppHandlers() {
2889
3007
 
2890
3008
  // Download vCard
2891
3009
  $('download-vcard')?.addEventListener('click', () => {
2892
- const vcard = $('vcard-preview')?.textContent || '';
3010
+ const vcard = state._exportedVCard || '';
2893
3011
  const blob = new Blob([vcard], { type: 'text/vcard' });
2894
3012
  const url = URL.createObjectURL(blob);
2895
3013
  const a = document.createElement('a');
@@ -2901,7 +3019,7 @@ function setupMainAppHandlers() {
2901
3019
 
2902
3020
  // Copy vCard
2903
3021
  $('copy-vcard')?.addEventListener('click', async () => {
2904
- const vcard = $('vcard-preview')?.textContent || '';
3022
+ const vcard = state._exportedVCard || '';
2905
3023
  try {
2906
3024
  await navigator.clipboard.writeText(vcard);
2907
3025
  const btn = $('copy-vcard');
@@ -3091,7 +3209,7 @@ function setupTrustHandlers() {
3091
3209
  return;
3092
3210
  }
3093
3211
  const { exportTrustData } = await import('./trust-ui.js');
3094
- const xpub = state.hdRoot ? state.hdRoot.publicExtendedKey() : '';
3212
+ const xpub = state.hdRoot ? state.hdRoot.toXpub() : '';
3095
3213
  exportTrustData(state.trustTransactions, xpub);
3096
3214
  });
3097
3215
 
@@ -3158,7 +3276,7 @@ function setupTrustHandlers() {
3158
3276
  }
3159
3277
 
3160
3278
  // Update encryption tab when it becomes active or HD controls change
3161
- $qa('.modal-tab[data-modal-tab="encrypt-tab-content"]').forEach(tab => {
3279
+ $qa('.modal-tab[data-modal-tab="messaging-tab-content"]').forEach(tab => {
3162
3280
  tab.addEventListener('click', () => {
3163
3281
  if (state.hdRoot) updateEncryptionTab();
3164
3282
  });