Contractless-GUI-Wallet/frontend/js/address_book.js

656 lines
20 KiB
JavaScript
Raw Normal View History

2026-07-01 16:01:30 +00:00
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 = "<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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 `
<tr>
<td>
<input class="addressbook-label-input" data-address-book-label="${addressBookEscape(entry.address)}" value="${addressBookEscape(label)}" placeholder="${ADDRESS_BOOK_UNKNOWN}">
</td>
<td>${vanity ? `<button class="addressbook-copy-value" type="button" data-address-book-copy="${addressBookEscape(vanity)}">${addressBookEscape(vanity)}</button>` : ADDRESS_BOOK_UNKNOWN}</td>
<td><button class="addressbook-copy-value" type="button" data-address-book-copy="${addressBookEscape(entry.address)}"><code>${addressBookEscape(entry.address)}</code></button></td>
<td>${txid ? `<code>${addressBookEscape(shortenAddress(txid))}</code>` : "--"}</td>
<td class="addressbook-actions">
<button class="icon-button addressbook-delete-button" type="button" data-address-book-delete="${addressBookEscape(entry.address)}" title="Delete address" aria-label="Delete address">x</button>
</td>
</tr>
`;
}).join("");
if (addressBookCards) {
addressBookCards.innerHTML = entryParts.map((entry) => `
<article class="addressbook-card">
<div class="addressbook-card-top">
<input class="addressbook-label-input" data-address-book-label="${entry.escapedAddress}" value="${entry.escapedLabel}" placeholder="${ADDRESS_BOOK_UNKNOWN}" aria-label="Address label">
<button class="icon-button addressbook-delete-button" type="button" data-address-book-delete="${entry.escapedAddress}" title="Delete address" aria-label="Delete address">x</button>
</div>
<div>
<span>Vanity</span>
<b>${entry.vanity ? `<button class="addressbook-copy-value" type="button" data-address-book-copy="${entry.escapedVanity}">${entry.escapedVanity}</button>` : ADDRESS_BOOK_UNKNOWN}</b>
</div>
<div>
<span>Address</span>
<b><button class="addressbook-copy-value" type="button" data-address-book-copy="${entry.escapedAddress}"><code>${entry.escapedAddress}</code></button></b>
</div>
<div>
<span>Last Transaction</span>
<b>${entry.txid ? `<code>${entry.escapedTxid}</code>` : "--"}</b>
</div>
</article>
`).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);
});