let addressBookCache = { entries: {} }; let addressBookLoaded = false; let addressBookLoadingPromise = null; let addressBookWriteTimer = null; let addressBookWritePromise = Promise.resolve(); let addressBookVanityRefreshRunning = false; let addressBookVanityRefreshDoneForWallet = ""; let addressBookVanityLookupRunning = false; const addressBookVanityLookupQueue = new Set(); const ADDRESS_BOOK_UNKNOWN = ""; const ADDRESS_BOOK_WRITE_DELAY_MS = 500; const ADDRESS_BOOK_VANITY_DELAY_MS = 650; function normalizeAddressBook(source) { const entries = source?.entries && typeof source.entries === "object" && !Array.isArray(source.entries) ? source.entries : {}; const normalized = {}; Object.entries(entries).forEach(([address, entry]) => { if (isRawVanityAddressHex(address)) { return; } const canonical = canonicalAddressBookAddress(address); if (!canonical || !entry || typeof entry !== "object") { return; } normalized[canonical] = { label: stringOrDefault(entry.label, ADDRESS_BOOK_UNKNOWN), label_source: stringOrDefault(entry.label_source, "sync"), vanity: stringOrDefault(entry.vanity, ADDRESS_BOOK_UNKNOWN), latest_txid: stringOrDefault(entry.latest_txid, ""), created_at: Number(entry.created_at || 0) || currentUnixSeconds(), updated_at: Number(entry.updated_at || 0) || currentUnixSeconds() }; }); return { entries: normalized }; } function stringOrDefault(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; } function currentUnixSeconds() { return Math.floor(Date.now() / 1000); } function shortAddressFromRawAddressHex(address) { const text = String(address || "").trim().toLowerCase(); if (!/^[a-f0-9]{44}$/.test(text) || text.slice(40, 42) !== "2e") { return ""; } const network = { "01": "clc", "02": "cltc" }[text.slice(42, 44)]; return network ? `${text.slice(0, 40)}.${network}` : ""; } function vanityAddressFromRawAddressHex(address) { const text = String(address || "").trim().toLowerCase(); if (!/^[a-f0-9]{44}$/.test(text) || text.slice(40, 42) !== "2e") { return ""; } const network = { "01": "clc", "02": "cltc" }[text.slice(42, 44)]; if (!network) { return ""; } const payloadBytes = text.slice(0, 40).match(/../g) || []; const payload = payloadBytes .map((part) => String.fromCharCode(Number.parseInt(part, 16))) .join("") .trimStart(); if (!payload || !/^[a-z]+$/i.test(payload)) { return ""; } return `${payload.toLowerCase()}.${network}`; } function isRawVanityAddressHex(address) { return Boolean(vanityAddressFromRawAddressHex(address)); } function canonicalAddressBookAddress(address) { const text = String(address || "").trim().toLowerCase(); if (/^[a-f0-9]{40}\.[a-z0-9]+$/.test(text)) { return text; } if (isRawVanityAddressHex(text)) { return ""; } const fromRawBytes = shortAddressFromRawAddressHex(text); if (fromRawBytes) { return fromRawBytes; } return ""; } function shouldSkipAddressBookAddress(address) { const canonical = canonicalAddressBookAddress(address); return ( !canonical || canonical === canonicalAddressBookAddress(activeWalletAddressShort) || canonical === "network" || canonical === "self" ); } function addressBookSleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function addressBookEscape(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function addressBookInvoke() { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { throw new Error("Tauri bridge is not ready."); } return invoke; } async function lookupAddressBookVanity(address) { const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; if (!walletLoaded || !broadcastNode) { return ""; } const invoke = addressBookInvoke(); const vanity = await invoke("lookup_address_vanity", { broadcastNode, address }); return String(vanity || "").trim(); } function enqueueAddressBookVanityLookup(address) { const canonical = canonicalAddressBookAddress(address); if (!canonical || shouldSkipAddressBookAddress(canonical)) { return; } addressBookVanityLookupQueue.add(canonical); runAddressBookVanityLookupQueue(); } async function runAddressBookVanityLookupQueue() { if (addressBookVanityLookupRunning) { return; } addressBookVanityLookupRunning = true; try { while (walletLoaded && addressBookVanityLookupQueue.size) { const [address] = addressBookVanityLookupQueue; addressBookVanityLookupQueue.delete(address); await loadAddressBookCache(); const entry = addressBookCache.entries[address]; if (!entry || (entry.vanity && entry.vanity !== ADDRESS_BOOK_UNKNOWN)) { continue; } try { const vanity = await lookupAddressBookVanity(address); if (vanity && addressBookCache.entries[address]?.vanity !== vanity) { addressBookCache.entries[address].vanity = vanity; addressBookCache.entries[address].updated_at = currentUnixSeconds(); scheduleAddressBookWrite(); renderAddressBookPage(); } } catch (_) { // Vanity lookup is best-effort; failed lookups should not block address discovery. } if (addressBookVanityLookupQueue.size) { await addressBookSleep(ADDRESS_BOOK_VANITY_DELAY_MS); } } } finally { addressBookVanityLookupRunning = false; } } async function loadAddressBookCache(force = false) { if (!walletLoaded) { addressBookCache = { entries: {} }; addressBookLoaded = false; return addressBookCache; } if (addressBookLoaded && !force) { return addressBookCache; } if (addressBookLoadingPromise && !force) { return addressBookLoadingPromise; } addressBookLoadingPromise = (async () => { try { const invoke = addressBookInvoke(); const raw = await invoke("read_address_book"); addressBookCache = normalizeAddressBook(JSON.parse(raw || "{}")); } catch (_) { addressBookCache = { entries: {} }; } finally { addressBookLoaded = true; addressBookLoadingPromise = null; } renderAddressBookPage(); return addressBookCache; })(); return addressBookLoadingPromise; } function scheduleAddressBookWrite() { if (!walletLoaded || !addressBookLoaded) { return; } clearTimeout(addressBookWriteTimer); addressBookWriteTimer = setTimeout(() => { addressBookWritePromise = addressBookWritePromise .catch(() => {}) .then(() => { const invoke = addressBookInvoke(); return invoke("write_address_book", { addressBookJson: JSON.stringify(normalizeAddressBook(addressBookCache)) }); }) .catch((error) => console.error("Address book write failed:", error)); }, ADDRESS_BOOK_WRITE_DELAY_MS); } async function flushAddressBookWrite() { clearTimeout(addressBookWriteTimer); if (!walletLoaded || !addressBookLoaded) { return; } addressBookWritePromise = addressBookWritePromise .catch(() => {}) .then(() => { const invoke = addressBookInvoke(); return invoke("write_address_book", { addressBookJson: JSON.stringify(normalizeAddressBook(addressBookCache)) }); }); await addressBookWritePromise; } async function upsertAddressBookEntry(address, options = {}) { const canonical = canonicalAddressBookAddress(address); if (shouldSkipAddressBookAddress(canonical)) { return false; } await loadAddressBookCache(); const now = currentUnixSeconds(); const existing = addressBookCache.entries[canonical]; const label = String(options.label || "").trim(); const labelSource = options.labelSource || "sync"; const vanity = String(options.vanity || "").trim(); let changed = false; let shouldLookupVanity = false; if (!existing) { addressBookCache.entries[canonical] = { label: label || ADDRESS_BOOK_UNKNOWN, label_source: label ? labelSource : "sync", vanity: vanity || ADDRESS_BOOK_UNKNOWN, latest_txid: String(options.latestTxid || ""), created_at: now, updated_at: now }; changed = true; shouldLookupVanity = !vanity; } else { const canReplaceLabel = label && existing.label_source !== "user" && (!existing.label || existing.label === ADDRESS_BOOK_UNKNOWN || existing.label_source === "sync"); if (canReplaceLabel && existing.label !== label) { existing.label = label; existing.label_source = labelSource; changed = true; } if (options.latestTxid && existing.latest_txid !== options.latestTxid) { existing.latest_txid = String(options.latestTxid); changed = true; } if (vanity && existing.vanity !== vanity) { existing.vanity = vanity; changed = true; } if (changed) { existing.updated_at = now; } } if (changed) { scheduleAddressBookWrite(); renderAddressBookPage(); } if (shouldLookupVanity) { enqueueAddressBookVanityLookup(canonical); } return changed; } async function upsertVanityRegistrationAddressBookEntry(txid, transaction) { const vanity = transaction?.unsigned_vanity_address; if (!vanity) { return false; } const owner = canonicalAddressBookAddress(vanity.address); const displayVanity = vanityAddressFromRawAddressHex(vanity.vanity_address); if (!owner || !displayVanity) { return false; } return upsertAddressBookEntry(owner, { vanity: displayVanity, latestTxid: txid, labelSource: "sync" }); } function collectAddressBookAddressesFromValue(value, found = new Set()) { if (!value || typeof value !== "object") { if (typeof value === "string") { const canonical = canonicalAddressBookAddress(value); if (!shouldSkipAddressBookAddress(canonical)) { found.add(canonical); } } return found; } if (Array.isArray(value)) { value.forEach((item) => collectAddressBookAddressesFromValue(item, found)); return found; } Object.entries(value).forEach(([key, item]) => { const keyText = String(key || "").toLowerCase(); if (["sender", "receiver", "creator", "advertiser", "sender1", "sender2", "lender", "borrower", "address"].includes(keyText)) { const canonical = canonicalAddressBookAddress(item); if (!shouldSkipAddressBookAddress(canonical)) { found.add(canonical); } return; } collectAddressBookAddressesFromValue(item, found); }); return found; } async function syncAddressBookFromTransactionRecord(txid, record) { const vanityChanged = await upsertVanityRegistrationAddressBookEntry(txid, record?.transaction); const found = collectAddressBookAddressesFromValue(record?.transaction); if (!found.size) { if (vanityChanged) { await flushAddressBookWrite(); } return; } for (const address of found) { await upsertAddressBookEntry(address, { latestTxid: txid, labelSource: "sync" }); } } async function addPaymentUriAddressBookEntry(address, label) { const changed = await upsertAddressBookEntry(address, { label, labelSource: "payment_uri" }); if (changed) { await flushAddressBookWrite(); } } function resetAddressBookPage() { addressBookCache = { entries: {} }; addressBookLoaded = false; addressBookLoadingPromise = null; clearTimeout(addressBookWriteTimer); addressBookVanityRefreshRunning = false; addressBookVanityRefreshDoneForWallet = ""; addressBookVanityLookupRunning = false; addressBookVanityLookupQueue.clear(); if (addressBookSearch) { addressBookSearch.value = ""; } renderAddressBookPage(); } function sortedAddressBookEntries() { const query = String(addressBookSearch?.value || "").trim().toLowerCase(); const sort = addressBookSort?.value || "latest"; let entries = Object.entries(addressBookCache.entries || {}).map(([address, entry]) => ({ address, ...entry })); if (query) { entries = entries.filter((entry) => { return [entry.address, entry.label, entry.vanity, entry.latest_txid] .some((value) => String(value || "").toLowerCase().includes(query)); }); } entries.sort((left, right) => { if (sort === "label") { return String(left.label || "").localeCompare(String(right.label || "")); } if (sort === "vanity") { return String(left.vanity || "").localeCompare(String(right.vanity || "")); } return Number(right.updated_at || 0) - Number(left.updated_at || 0); }); return entries; } function renderAddressBookPage() { if (!addressBookRows) { return; } const entries = sortedAddressBookEntries(); const entryParts = entries.map((entry) => { const txid = entry.latest_txid || ""; const label = entry.label && entry.label !== ADDRESS_BOOK_UNKNOWN ? entry.label : ""; const vanity = entry.vanity && entry.vanity !== ADDRESS_BOOK_UNKNOWN ? entry.vanity : ""; const escapedAddress = addressBookEscape(entry.address); const escapedLabel = addressBookEscape(label); const escapedVanity = addressBookEscape(vanity); const escapedTxid = addressBookEscape(shortenAddress(txid)); return { txid, vanity, escapedAddress, escapedLabel, escapedVanity, escapedTxid }; }); addressBookRows.innerHTML = entries.map((entry) => { const txid = entry.latest_txid || ""; const label = entry.label && entry.label !== ADDRESS_BOOK_UNKNOWN ? entry.label : ""; const vanity = entry.vanity && entry.vanity !== ADDRESS_BOOK_UNKNOWN ? entry.vanity : ""; return ` ${vanity ? `` : ADDRESS_BOOK_UNKNOWN} ${txid ? `${addressBookEscape(shortenAddress(txid))}` : "--"} `; }).join(""); if (addressBookCards) { addressBookCards.innerHTML = entryParts.map((entry) => `
Vanity ${entry.vanity ? `` : ADDRESS_BOOK_UNKNOWN}
Address
Last Transaction ${entry.txid ? `${entry.escapedTxid}` : "--"}
`).join(""); } if (addressBookState) { addressBookState.textContent = walletLoaded ? `${entries.length} contact${entries.length === 1 ? "" : "s"}` : "Load a wallet to view the address book."; } } async function prepareAddressBookPage() { await loadAddressBookCache(); renderAddressBookPage(); } async function refreshAddressBookVanitiesOnce(force = false) { if (!walletLoaded || addressBookVanityRefreshRunning) { return; } const walletKey = activeWalletAddressShort || activeWalletPath || ""; if (!force && addressBookVanityRefreshDoneForWallet === walletKey) { return; } addressBookVanityRefreshRunning = true; addressBookVanityRefreshDoneForWallet = walletKey; try { await loadAddressBookCache(); const addresses = Object.keys(addressBookCache.entries || {}); for (const address of addresses) { if (!walletLoaded || (activeWalletAddressShort || activeWalletPath || "") !== walletKey) { return; } try { const invoke = addressBookInvoke(); const vanity = await invoke("lookup_address_vanity", { broadcastNode: activeBroadcastNode(), address }); const entry = addressBookCache.entries[address]; const nextVanity = vanity || ADDRESS_BOOK_UNKNOWN; if (entry && entry.vanity !== nextVanity) { entry.vanity = nextVanity; entry.updated_at = currentUnixSeconds(); scheduleAddressBookWrite(); renderAddressBookPage(); } } catch (_) { // Vanity refresh is best-effort; connection errors should not block the wallet. } await addressBookSleep(ADDRESS_BOOK_VANITY_DELAY_MS); } } finally { addressBookVanityRefreshRunning = false; } } addressBookSearch?.addEventListener("input", renderAddressBookPage); addressBookSort?.addEventListener("change", renderAddressBookPage); addressBookRefreshVanity?.addEventListener("click", () => refreshAddressBookVanitiesOnce(true)); async function handleAddressBookLabelChange(event) { const input = event.target.closest("[data-address-book-label]"); if (!input) { return; } const address = canonicalAddressBookAddress(input.dataset.addressBookLabel); if (!address) { return; } await loadAddressBookCache(); const entry = addressBookCache.entries[address]; if (!entry) { return; } entry.label = input.value.trim() || ADDRESS_BOOK_UNKNOWN; entry.label_source = "user"; entry.updated_at = currentUnixSeconds(); scheduleAddressBookWrite(); renderAddressBookPage(); } async function handleAddressBookClick(event) { const copyButton = event.target.closest("[data-address-book-copy]"); if (copyButton) { const value = copyButton.dataset.addressBookCopy || ""; const copied = typeof writeClipboardText === "function" ? await writeClipboardText(value) : false; if (copied) { const previous = copyButton.textContent; copyButton.textContent = "Copied"; setTimeout(() => { if (copyButton.isConnected) { copyButton.textContent = previous; } }, 1000); } return; } const button = event.target.closest("[data-address-book-delete]"); if (!button) { return; } const address = canonicalAddressBookAddress(button.dataset.addressBookDelete); if (!address) { return; } await loadAddressBookCache(); if (!addressBookCache.entries[address]) { return; } delete addressBookCache.entries[address]; scheduleAddressBookWrite(); renderAddressBookPage(); } function handleAddressBookKeydown(event) { const input = event.target.closest("[data-address-book-label]"); if (input && event.key === "Enter") { event.preventDefault(); input.blur(); } } [addressBookRows, addressBookCards].forEach((container) => { container?.addEventListener("change", handleAddressBookLabelChange); container?.addEventListener("click", handleAddressBookClick); container?.addEventListener("keydown", handleAddressBookKeydown); });