import type { WalletStatus } from "./types"; import QRCode from "qrcode"; import { core } from "./core"; const byId = (id: string) => document.getElementById(id) as T; const screens = Array.from(document.querySelectorAll(".screen")); const notice = byId("notice"); const firefoxToolbarPopup = navigator.userAgent.includes("Firefox"); let pendingPassword = ""; let currentPassword = ""; let selectedImageBase64 = ""; let backupImageBase64 = ""; let walletStatus: WalletStatus | undefined; let pendingBackupAction: "image" | "private" | "print" | undefined; let tokenFilter: "held" | "all" = "held"; let nftPage = 1; let nftLoadId = 0; const NFT_PAGE_SIZE = 8; let walletBalances: | { balances: Array<{ asset: string; nft_series: number; balance: string; }>; tokens: Array<{ token: string; origin_txid: string; }>; nfts: Array<{ name: string; series: number; origin_txid: string; ownership_type: number; supply: string; }>; tokenCatalogError?: string; nftCatalogError?: string; } | undefined; type ActivityRecord = { txid: string; block_height: number; transaction_hex: string; miner_earnings?: Array<{ type: string; asset: string; nft_series: number; amount_atomic: string; }>; }; type OwnedNft = { name: string; series: number; origin_txid: string; ownership_type: number; supply: string; balance: string; details?: NftDetails; loading?: boolean; error?: string; }; type NftDetails = { nft_name: string; series: number; asset_name: string; genesis_txid: string; creator: string; item_ipfs: string; metadata_uri: string; current_holder?: string | null; history_count: number; history: Array<{ txid: string; block: number; transaction_type: number; action: string; from?: string | null; to?: string | null; received_asset?: string; received_series?: number; received_value?: string; }>; metadata: Record; image_data_url: string; image_available: boolean; media_error?: string; }; let ownedNfts: OwnedNft[] = []; let selectedNft: OwnedNft | undefined; let sendNft: OwnedNft | undefined; type DecodedTransaction = { txtype: number; transaction_name: string; fields: Record; stored_hash?: string | null; signature?: string | null; signature1?: string | null; signature2?: string | null; }; let selectedTransaction: ActivityRecord | undefined; let transactionReturnScreen: "activity" | "nft" = "activity"; let pendingTransfer: | { transaction: Record; receiverInput: string; asset: string; amount: string; fee: string; total: string; } | undefined; let lastSentTxid = ""; let pendingProviderApprovalId = ""; const CONFIRMED_AFTER_BLOCKS = 20; const TRANSACTION_NAMES: Record = { 1: "Mining Reward", 2: "Transfer", 3: "Token Creation", 4: "NFT/RWA Creation", 5: "Marketing", 6: "Swap", 7: "Loan", 8: "Loan Payment", 9: "Collateral Claim", 10: "Asset Burn", 11: "Token Issuance", 12: "Vanity Address", 100: "Storage Key", 101: "Data Storage Bool", 102: "Data Storage U8", 103: "Data Storage U16", 104: "Data Storage U32", 105: "Data Storage U64", 106: "Data Storage U128", 107: "Data Storage String", 108: "Data Storage I8", 109: "Data Storage I16", 110: "Data Storage I32", 111: "Data Storage I64", 112: "Data Storage I128", 113: "Delete Stored Data", 200: "Proposal", 201: "Proposal Vote", 202: "Activation Vote" }; async function send(type: string, values: Record = {}) { const response = await chrome.runtime.sendMessage({ type, ...values }); if (!response?.ok) throw new Error(response?.error ?? "Wallet request failed."); return response.result; } function showScreen(id: string, clearNotice = true): void { screens.forEach((screen) => { screen.hidden = screen.id !== id; }); if (clearNotice) notice.textContent = ""; } function showStatus(status: WalletStatus): void { walletStatus = status; const networkName = status.networkName; const networkSymbol = status.networkSymbol; document.querySelectorAll("[data-network-name]").forEach((element) => { element.textContent = networkName; }); document.querySelectorAll("[data-network-symbol]").forEach((element) => { element.textContent = networkSymbol; }); byId("base-asset-name").textContent = `Contractless ${networkName}`; byId("wallet-address").textContent = status.address ?? ""; byId("wallet-address-short").textContent = status.address ? `${status.address.slice(0, 6)}...${status.address.slice(-6)}` : "Account"; byId("settings-testnet-api-url").value = status.testnetApiUrl; byId("settings-mainnet-api-url").value = status.mainnetApiUrl; document.querySelectorAll("[data-select-network]").forEach((button) => { const active = button.dataset.selectNetwork === status.selectedNetwork; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); byId("settings-lock-timeout").value = String(status.autoLockMinutes); renderKnownAddresses(status); if (!status.exists) { showScreen("create-screen"); } else if (!status.unlocked) { showScreen("locked-screen"); } else { void showPendingProviderApproval().then((shown) => { if (!shown) { showScreen("dashboard-screen"); void loadWalletBalances(); } }); } } function approvalTitle(method: string, display: Record): string { if (method === "contractless_connect") return "Connect application"; if (method === "contractless_signMessage") return "Sign text message"; if ( method === "contractless_sendTransaction" || method === "contractless_signDualTransaction" ) { return `Sign ${String(display.transaction_type ?? "transaction")}`; } return "Review request"; } function appendApprovalValue( container: HTMLElement, label: string, value: unknown ): void { const term = document.createElement("dt"); term.textContent = label.replaceAll("_", " "); const description = document.createElement("dd"); description.textContent = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value); container.append(term, description); } function swapApprovalAmount( fields: Record, valueField: "value1" | "value2" | "tip1" | "tip2", tickerField: "ticker1" | "ticker2", seriesField: "nft_series1" | "nft_series2" ): string { const amount = formatAtomicDisplay(fields[valueField]); const ticker = String(fields[tickerField] ?? "").trim().toUpperCase(); const series = Number(fields[seriesField] ?? 0); return `${amount} ${ticker}${series > 0 ? ` #${series}` : ""}`; } function appendSwapApprovalCosts( container: HTMLElement, fields: Record, signerSlot: 1 | 2 ): void { const otherSlot = signerSlot === 1 ? 2 : 1; const baseSymbol = walletStatus?.networkSymbol ?? "CLTC"; appendApprovalValue( container, "Your miner tip", swapApprovalAmount( fields, `tip${signerSlot}`, `ticker${signerSlot}`, `nft_series${signerSlot}` ) ); appendApprovalValue( container, "Your transaction fee", `${formatAtomicDisplay(fields[`txfee${signerSlot}`])} ${baseSymbol}` ); appendApprovalValue( container, "Other party miner tip", swapApprovalAmount( fields, `tip${otherSlot}`, `ticker${otherSlot}`, `nft_series${otherSlot}` ) ); appendApprovalValue( container, "Other party transaction fee", `${formatAtomicDisplay(fields[`txfee${otherSlot}`])} ${baseSymbol}` ); } function appendSwapApprovalSummary( container: HTMLElement, fields: Record ): void { const activeAddress = walletStatus?.address?.trim().toLowerCase() ?? ""; const sender1 = String(fields.sender1 ?? "").trim().toLowerCase(); const sender2 = String(fields.sender2 ?? "").trim().toLowerCase(); const signerSlot = activeAddress === sender1 ? 1 : activeAddress === sender2 ? 2 : 0; if (signerSlot === 1) { appendApprovalValue( container, "Sending", swapApprovalAmount(fields, "value1", "ticker1", "nft_series1") ); appendApprovalValue( container, "Receiving", swapApprovalAmount(fields, "value2", "ticker2", "nft_series2") ); appendSwapApprovalCosts(container, fields, 1); return; } if (signerSlot === 2) { appendApprovalValue( container, "Sending", swapApprovalAmount(fields, "value2", "ticker2", "nft_series2") ); appendApprovalValue( container, "Receiving", swapApprovalAmount(fields, "value1", "ticker1", "nft_series1") ); appendSwapApprovalCosts(container, fields, 2); return; } appendApprovalValue( container, "First party sending", swapApprovalAmount(fields, "value1", "ticker1", "nft_series1") ); appendApprovalValue( container, "Second party sending", swapApprovalAmount(fields, "value2", "ticker2", "nft_series2") ); } function setApprovalProcessing(action: "approve" | "reject" | null): void { const approve = byId("approve-provider-approval"); const reject = byId("reject-provider-approval"); const processing = action !== null; approve.disabled = processing; reject.disabled = processing; approve.classList.toggle("is-processing", action === "approve"); reject.classList.toggle("is-processing", action === "reject"); approve.textContent = action === "approve" ? "Processing..." : "Approve"; reject.textContent = action === "reject" ? "Rejecting..." : "Reject"; } async function showPendingProviderApproval(): Promise { const pending = await send("pending-provider-approval") as { locked?: boolean; position?: number; total?: number; request?: { id: string; method: string; origin: string; risk: "safe" | "low" | "high"; display?: Record; }; } | null; if (!pending?.request) return false; const request = pending.request; setApprovalProcessing(null); pendingProviderApprovalId = request.id; const queueLabel = (pending.total ?? 0) > 1 ? ` · Request ${pending.position ?? 1} of ${pending.total}` : ""; byId("provider-approval-origin").textContent = `${request.origin}${queueLabel}`; const risk = byId("provider-approval-risk"); risk.className = `risk-label risk-${request.risk}`; risk.textContent = request.risk === "safe" ? "Safe" : request.risk === "low" ? "Low risk" : "High risk"; const display = request.display ?? {}; const transactionRequest = request.method === "contractless_sendTransaction" || request.method === "contractless_signDualTransaction"; byId("provider-approval-title").textContent = approvalTitle(request.method, display); byId("provider-approval-intro").textContent = transactionRequest ? "Review every transaction field before signing." : "Review this request before approving it."; const details = byId("provider-approval-details"); details.replaceChildren(); Object.entries(display).forEach(([label, value]) => { if (label === "transaction_fields" && value && typeof value === "object") { const fields = value as Record; const txtype = Number(fields.txtype ?? 0); const swapAmountFields = new Set([ "ticker1", "nft_series1", "value1", "ticker2", "nft_series2", "value2", "tip1", "tip2", "txfee1", "txfee2" ]); if (txtype === 6) appendSwapApprovalSummary(details, fields); Object.entries(fields).forEach(([field, fieldValue]) => { if (txtype === 6 && swapAmountFields.has(field)) return; appendApprovalValue( details, field === "txtype" ? "transaction_type_id" : field, transactionFieldDisplay(txtype, field, fieldValue) ); }); return; } appendApprovalValue(details, label, value); }); showScreen("provider-approval-screen"); return true; } async function showNextProviderApproval( remaining: number, waitForFollowup: boolean ): Promise { if (remaining > 0 && await showPendingProviderApproval()) return; if (waitForFollowup) { const expiresAt = Date.now() + 1_500; while (Date.now() < expiresAt) { if (await showPendingProviderApproval()) return; await new Promise((resolve) => setTimeout(resolve, 75)); } } window.close(); } function decimalHasValue(value: string): boolean { return value.replace(/[.0]/g, "").length > 0; } function renderTokenBalances(): void { const list = byId("token-list"); list.replaceChildren(); if (!walletBalances) { const empty = document.createElement("div"); empty.className = "empty-assets"; const title = document.createElement("strong"); title.textContent = "Loading token balances..."; const description = document.createElement("span"); description.textContent = "Balances are retrieved from the selected Contractless API."; empty.append(title, description); list.append(empty); return; } const baseSymbol = walletStatus?.networkSymbol ?? "CLTC"; const tokenNames = new Set( walletBalances.tokens.map((token) => token.token.trim().toUpperCase()) ); const nftKeys = new Set( walletBalances.nfts.map( (nft) => `${nft.name.trim().toUpperCase()}|${Number(nft.series || 0)}` ) ); const fungibleBalances = walletBalances.balances.filter((balance) => { const asset = balance.asset.trim().toUpperCase(); const key = `${asset}|${Number(balance.nft_series || 0)}`; return ( balance.nft_series === 0 && asset !== "CLTC" && asset !== "CLC" && tokenNames.has(asset) && !nftKeys.has(key) ); }); const balancesByAsset = new Map( fungibleBalances.map((balance) => [balance.asset.trim().toUpperCase(), balance]) ); const displayedTokenNames = tokenFilter === "all" ? walletBalances.tokens.map((token) => token.token) : fungibleBalances .filter((balance) => decimalHasValue(balance.balance)) .map((balance) => balance.asset); const uniqueNames = [...new Set(displayedTokenNames.map((name) => name.trim().toUpperCase()))] .filter((name) => name !== baseSymbol && !nftKeys.has(`${name}|0`)) .sort((left, right) => left.localeCompare(right)); if (uniqueNames.length === 0) { const empty = document.createElement("div"); empty.className = "empty-assets"; const title = document.createElement("strong"); title.textContent = tokenFilter === "held" ? "No token balances found" : "No tokens found"; const description = document.createElement("span"); description.textContent = tokenFilter === "held" ? "Switch to All tokens to view the complete network token list." : walletBalances.tokenCatalogError ? `Token catalog unavailable: ${walletBalances.tokenCatalogError}` : "The selected API did not return any fungible tokens."; empty.append(title, description); list.append(empty); return; } uniqueNames.forEach((name) => { const balance = balancesByAsset.get(name); const row = document.createElement("div"); row.className = "asset-row"; const symbol = document.createElement("div"); symbol.className = "asset-symbol token-symbol"; symbol.textContent = name.slice(0, 3); const identity = document.createElement("div"); identity.className = "asset-name"; const title = document.createElement("strong"); title.textContent = name; const subtitle = document.createElement("span"); subtitle.textContent = "Contractless token"; identity.append(title, subtitle); const value = document.createElement("div"); value.className = "asset-value"; const amount = document.createElement("strong"); amount.textContent = balance?.balance ?? "0.00000000"; const ticker = document.createElement("span"); ticker.textContent = name; value.append(amount, ticker); row.append(symbol, identity, value); list.append(row); }); } async function loadWalletBalances(): Promise { if (!walletStatus?.unlocked) return; walletBalances = undefined; byId("base-balance").textContent = "--"; byId("base-asset-balance").textContent = "--"; renderTokenBalances(); try { if (!walletStatus.apiUrl) { throw new Error(`No ${walletStatus.networkName} API is configured.`); } const result = await send("wallet-balances") as { balances: Array<{ asset: string; nft_series: number; balance: string }>; tokens: Array<{ token: string; origin_txid: string }>; nfts: Array<{ name: string; series: number; origin_txid: string; ownership_type: number; supply: string; }>; tokenCatalogError?: string; nftCatalogError?: string; }; walletBalances = { balances: Array.isArray(result.balances) ? result.balances : [], tokens: Array.isArray(result.tokens) ? result.tokens : [], nfts: Array.isArray(result.nfts) ? result.nfts : [], tokenCatalogError: result.tokenCatalogError, nftCatalogError: result.nftCatalogError }; const baseSymbol = walletStatus.networkSymbol; const base = walletBalances.balances.find( (balance) => balance.nft_series === 0 && balance.asset.toUpperCase() === baseSymbol ); const baseBalance = base?.balance ?? "0.00000000"; byId("base-balance").textContent = baseBalance; byId("base-asset-balance").textContent = baseBalance; renderTokenBalances(); } catch (error) { const list = byId("token-list"); list.replaceChildren(); const empty = document.createElement("div"); empty.className = "empty-assets balance-error"; const title = document.createElement("strong"); title.textContent = "Unable to load balances"; const description = document.createElement("span"); description.textContent = errorText(error); empty.append(title, description); list.append(empty); notice.textContent = errorText(error); } } function nftKey(name: string, series: number): string { return `${name.trim().toUpperCase()}|${series}`; } function positiveDecimal(value: string): boolean { try { return decimalToAtomic(value, "Balance") > 0n; } catch { return false; } } function nftOwnership(nft: OwnedNft): string { if (nft.ownership_type !== 1) return "Complete ownership"; try { const balance = decimalToAtomic(nft.balance, "Balance"); const supply = decimalToAtomic(nft.supply, "Supply"); if (supply <= 0n) return "0% ownership"; const scaled = balance * 100_000_000n / supply; const whole = scaled / 1_000_000n; const fraction = (scaled % 1_000_000n) .toString() .padStart(6, "0") .replace(/0+$/, ""); return `${whole}${fraction ? `.${fraction}` : ""}% ownership`; } catch { return "Fractional ownership"; } } function nftMetadata(nft: OwnedNft): Record { return nft.details?.metadata && typeof nft.details.metadata === "object" ? nft.details.metadata : {}; } function nftDisplayName(nft: OwnedNft): string { const metadataName = nftMetadata(nft).name; if (typeof metadataName === "string" && metadataName.trim()) { return metadataName.trim(); } return nft.series > 0 ? `${nft.name} #${nft.series}` : nft.name; } function nftKind(nft: OwnedNft): "NFT" | "RWA" { const metadata = nftMetadata(nft); const contractless = metadata.contractless; if ( contractless && typeof contractless === "object" && String((contractless as Record).metadata_type ?? "") .toLowerCase() === "rwa" ) { return "RWA"; } return "NFT"; } function nftAssetLabel(nft: OwnedNft): string { return nft.series > 0 ? `${nft.name}_${nft.series}` : nft.name; } function shortenNftValue(value: string, start = 7, end = 7): string { return value.length > start + end + 3 ? `${value.slice(0, start)}...${value.slice(-end)}` : value; } function createNftMedia(nft: OwnedNft, className: string): HTMLElement { const media = document.createElement("div"); media.className = className; if (nft.details?.image_data_url) { const image = document.createElement("img"); image.src = nft.details.image_data_url; image.alt = nftDisplayName(nft); media.append(image); return media; } const fallback = document.createElement("span"); fallback.textContent = nft.loading ? "..." : nft.name.slice(0, 2).toUpperCase(); media.append(fallback); return media; } function visibleNfts(): OwnedNft[] { const start = (nftPage - 1) * NFT_PAGE_SIZE; return ownedNfts.slice(start, start + NFT_PAGE_SIZE); } function renderNftGallery(): void { const list = byId("nfts-list"); list.replaceChildren(); const totalPages = Math.max(1, Math.ceil(ownedNfts.length / NFT_PAGE_SIZE)); nftPage = Math.min(Math.max(1, nftPage), totalPages); byId("nfts-count").textContent = ownedNfts.length === 1 ? "1 owned asset" : `${ownedNfts.length} owned assets`; byId("nfts-page").textContent = String(nftPage); byId("nfts-pages").textContent = String(totalPages); byId("nfts-pagination").hidden = totalPages <= 1; byId("nfts-previous").disabled = nftPage <= 1; byId("nfts-next").disabled = nftPage >= totalPages; if (!ownedNfts.length) { const empty = document.createElement("div"); empty.className = "nfts-empty"; const title = document.createElement("strong"); title.textContent = "No NFTs or RWAs found"; const copy = document.createElement("span"); copy.textContent = walletBalances?.nftCatalogError ? `NFT catalog unavailable: ${walletBalances.nftCatalogError}` : "Assets owned by this wallet will appear here."; empty.append(title, copy); list.append(empty); return; } visibleNfts().forEach((nft) => { const card = document.createElement("button"); card.type = "button"; card.className = "nft-card"; card.append(createNftMedia(nft, "nft-card-media")); const identity = document.createElement("div"); identity.className = "nft-card-identity"; const kind = document.createElement("span"); kind.textContent = nftKind(nft); const name = document.createElement("strong"); name.textContent = nftDisplayName(nft); const asset = document.createElement("small"); asset.textContent = nftAssetLabel(nft); const ownership = document.createElement("b"); ownership.textContent = nftOwnership(nft); identity.append(kind, name, asset, ownership); if (nft.error) { const error = document.createElement("em"); error.textContent = "Media unavailable"; identity.append(error); } card.append(identity); card.addEventListener("click", () => openNftDetails(nft)); list.append(card); }); } function appendNftDetailField( container: HTMLElement, label: string, value: string ): void { const row = document.createElement("div"); const term = document.createElement("dt"); term.textContent = label; const description = document.createElement("dd"); description.textContent = value || "--"; row.append(term, description); container.append(row); } function renderNftDetails(nft: OwnedNft): void { selectedNft = nft; const details = nft.details; const metadata = nftMetadata(nft); const kind = nftKind(nft); const name = nftDisplayName(nft); byId("nft-detail-title").textContent = name; byId("nft-detail-kind").textContent = `${kind} details`; byId("nft-detail-badge").textContent = kind; byId("nft-detail-name").textContent = name; byId("nft-detail-ownership").textContent = nftOwnership(nft); const image = byId("nft-detail-image"); image.src = details?.image_data_url ?? ""; image.alt = name; image.hidden = !details?.image_data_url; const description = metadata.description ?? metadata.summary; byId("nft-detail-description").textContent = typeof description === "string" && description.trim() ? description.trim() : details ? "No description was included in the NFT metadata." : "Loading NFT metadata and provenance..."; const attributes = byId("nft-detail-attributes"); attributes.replaceChildren(); const metadataAttributes = Array.isArray(metadata.attributes) ? metadata.attributes.slice(0, 12) : []; metadataAttributes.forEach((attribute) => { if (!attribute || typeof attribute !== "object") return; const values = attribute as Record; const item = document.createElement("div"); const label = document.createElement("span"); label.textContent = String(values.trait_type ?? values.type ?? "Attribute"); const value = document.createElement("strong"); value.textContent = String(values.value ?? ""); item.append(label, value); attributes.append(item); }); attributes.hidden = attributes.childElementCount === 0; const fields = byId("nft-detail-fields"); fields.replaceChildren(); appendNftDetailField(fields, "Asset", nftAssetLabel(nft)); appendNftDetailField(fields, "Creator", details?.creator ?? "--"); appendNftDetailField(fields, "IPFS CID", details?.item_ipfs ?? "--"); appendNftDetailField(fields, "Origin transaction", details?.genesis_txid ?? nft.origin_txid); const history = byId("nft-detail-history"); history.replaceChildren(); if (!details) { const loading = document.createElement("p"); loading.textContent = "Loading provenance..."; history.append(loading); } else if (!details.history.length) { const empty = document.createElement("p"); empty.textContent = "No provenance records were returned."; history.append(empty); } else { details.history.slice().reverse().forEach((entry) => { const row = document.createElement("button"); row.type = "button"; row.className = "nft-history-row"; row.title = `View full transaction ${entry.txid}`; const action = document.createElement("strong"); action.textContent = entry.action; const block = document.createElement("span"); block.textContent = `Block ${Number(entry.block).toLocaleString()}`; const transaction = document.createElement("small"); transaction.textContent = shortenNftValue(entry.txid); row.append(action, block, transaction); row.addEventListener("click", () => { void openTransaction( { txid: entry.txid, block_height: Number(entry.block), transaction_hex: "" }, "nft" ); }); history.append(row); }); } } function openNftDetails(nft: OwnedNft): void { renderNftDetails(nft); showScreen("nft-detail-screen"); } async function hydrateVisibleNfts(loadId: number): Promise { const queue = visibleNfts().filter((nft) => !nft.details && !nft.loading); let cursor = 0; const worker = async (): Promise => { while (cursor < queue.length && loadId === nftLoadId) { const nft = queue[cursor++]; nft.loading = true; renderNftGallery(); try { nft.details = await send("wallet-nft-details", { name: nft.name, series: nft.series }) as NftDetails; nft.error = nft.details.media_error; } catch (error) { nft.error = errorText(error); } finally { nft.loading = false; } if (loadId !== nftLoadId) return; renderNftGallery(); if (selectedNft === nft) renderNftDetails(nft); } }; await Promise.all([worker(), worker()]); } async function loadOwnedNfts(force = false): Promise { const loadId = ++nftLoadId; byId("nfts-count").textContent = "Loading ownership..."; if (!walletBalances || force) { await loadWalletBalances(); } if (loadId !== nftLoadId || !walletBalances) return; const previous = force ? new Map() : new Map(ownedNfts.map((nft) => [nftKey(nft.name, nft.series), nft])); ownedNfts = walletBalances.nfts .map((catalog): OwnedNft | null => { const balance = walletBalances?.balances.find( (entry) => nftKey(entry.asset, Number(entry.nft_series || 0)) === nftKey(catalog.name, Number(catalog.series || 0)) ); if (!balance || !positiveDecimal(balance.balance)) return null; const existing = previous.get(nftKey(catalog.name, catalog.series)); return { name: catalog.name.trim(), series: Number(catalog.series || 0), origin_txid: catalog.origin_txid, ownership_type: Number(catalog.ownership_type || 0), supply: catalog.supply, balance: balance.balance, details: existing?.details, error: existing?.error }; }) .filter((nft): nft is OwnedNft => nft !== null) .sort((left, right) => left.name.localeCompare(right.name) || left.series - right.series ); const totalPages = Math.max(1, Math.ceil(ownedNfts.length / NFT_PAGE_SIZE)); nftPage = Math.min(nftPage, totalPages); renderNftGallery(); await hydrateVisibleNfts(loadId); } function renderKnownAddresses(status: WalletStatus): void { const container = byId("known-addresses"); container.replaceChildren(); status.wallets.forEach((wallet) => { const row = document.createElement("div"); row.className = `known-address${wallet.active ? " active-address" : ""}`; const select = document.createElement("button"); select.type = "button"; select.className = "address-select"; select.dataset.walletId = wallet.id; const avatar = document.createElement("span"); avatar.className = "address-avatar"; const logo = document.createElement("img"); logo.src = chrome.runtime.getURL("contractless-logo.png"); logo.alt = ""; avatar.append(logo); const details = document.createElement("span"); details.className = "address-details"; const label = document.createElement("strong"); label.textContent = wallet.label; const address = document.createElement("small"); address.textContent = wallet.address; details.append(label, address); const selected = document.createElement("span"); selected.className = "selected-mark"; selected.textContent = wallet.active ? "✓" : ""; select.append(avatar, details, selected); select.addEventListener("click", () => { if (wallet.active) { setAccountDrawer(false); return; } void action(async () => { setAccountDrawer(false); currentPassword = ""; backupImageBase64 = ""; showStatus(await send("switch-wallet", { walletId: wallet.id }) as WalletStatus); }); }); const rename = document.createElement("button"); rename.type = "button"; rename.className = "rename-address"; rename.title = "Rename wallet"; rename.setAttribute("aria-label", `Rename ${wallet.label}`); rename.textContent = "✎"; rename.addEventListener("click", () => { const nextLabel = window.prompt("Wallet label", wallet.label); if (nextLabel === null || nextLabel.trim() === wallet.label) return; void action(async () => { const nextStatus = await send("rename-wallet", { walletId: wallet.id, label: nextLabel }) as WalletStatus; showStatus(nextStatus); setAccountDrawer(true); }); }); row.append(select, rename); container.append(row); }); } function setAccountDrawer(open: boolean): void { const drawer = byId("account-drawer"); const backdrop = byId("drawer-backdrop"); const trigger = byId("account-menu-button"); drawer.classList.toggle("open", open); drawer.setAttribute("aria-hidden", String(!open)); trigger.setAttribute("aria-expanded", String(open)); backdrop.hidden = !open; } function clearRevealedPrivateKey(): void { byId("revealed-private-key").value = ""; byId("private-key-result").hidden = true; } function setBackupKeyModal(open: boolean): void { byId("backup-key-modal").hidden = !open; byId("backup-key-backdrop").hidden = !open; if (open) { const input = byId("backup-key-input"); input.value = ""; input.focus(); } else { byId("backup-key-input").value = ""; pendingBackupAction = undefined; } } async function openReceiveScreen(): Promise { const result = await send("wallet-receive-address"); const address = String(result.receiveAddress ?? ""); if (!address) throw new Error("No active wallet address is available."); byId("receive-address-label").textContent = result.vanity ? "Vanity address" : "Wallet address"; byId("receive-address-value").textContent = address; const canvas = byId("receive-qr"); await QRCode.toCanvas(canvas, address, { width: 220, margin: 1, errorCorrectionLevel: "M", color: { dark: "#071016", light: "#ffffff" } }); showScreen("receive-screen"); } function decimalToAtomic(value: string, label: string): bigint { const normalized = value.trim(); if (!/^(?:0|[1-9]\d*)(?:\.\d{1,8})?$/.test(normalized)) { throw new Error(`${label} must be a positive number with no more than 8 decimal places.`); } const [whole, fraction = ""] = normalized.split("."); return BigInt(whole) * 100_000_000n + BigInt(fraction.padEnd(8, "0")); } function atomicToDecimal(value: bigint): string { const whole = value / 100_000_000n; const fraction = (value % 100_000_000n).toString().padStart(8, "0"); return `${whole}.${fraction}`; } function selectedSendBalance(): string { if (sendNft) return sendNft.balance; const asset = byId("send-asset").value; const balance = walletBalances?.balances.find( (entry) => entry.nft_series === 0 && entry.asset.trim().toUpperCase() === asset ); return balance?.balance ?? "0.00000000"; } function minimumSendFeeAtomic(): bigint { const asset = byId("send-asset").value; const baseSymbol = walletStatus?.networkSymbol ?? "CLTC"; if (asset !== baseSymbol) return 100_000_000n; const amount = byId("send-amount").value.trim(); if (!amount) return 0n; try { const atomic = decimalToAtomic(amount, "Amount"); return (atomic + 99n) / 100n; } catch { return 0n; } } function updateSendForm(): void { const asset = sendNft?.name ?? byId("send-asset").value; const baseSymbol = walletStatus?.networkSymbol ?? "CLTC"; const assetLabel = sendNft ? nftAssetLabel(sendNft) : asset; byId("send-amount-symbol").textContent = assetLabel; byId("send-available").textContent = `Available: ${selectedSendBalance()} ${assetLabel}`; const minimumFee = minimumSendFeeAtomic(); byId("send-fee").value = atomicToDecimal(minimumFee); byId("send-fee-note").textContent = !sendNft && asset === baseSymbol ? "Minimum fee: 1% of the transfer amount." : `Minimum fee: 1 ${baseSymbol} for asset transfers.`; } function populateSendAssets(): void { const select = byId("send-asset"); select.replaceChildren(); const baseSymbol = walletStatus?.networkSymbol ?? "CLTC"; const assets = new Set([baseSymbol]); walletBalances?.tokens.forEach((token) => { const symbol = token.token.trim().toUpperCase(); const balance = walletBalances?.balances.find( (entry) => entry.nft_series === 0 && entry.asset.trim().toUpperCase() === symbol && decimalToAtomic(entry.balance, "Balance") > 0n ); if (balance) assets.add(symbol); }); assets.forEach((asset) => { const option = document.createElement("option"); option.value = asset; option.textContent = asset; select.append(option); }); updateSendForm(); } function openSendScreen(): void { if (!walletBalances) throw new Error("Wallet balances are still loading."); sendNft = undefined; pendingTransfer = undefined; byId("send-form").reset(); byId("send-asset").disabled = false; byId("send-amount").disabled = false; populateSendAssets(); showScreen("send-screen"); } function openNftSendScreen(nft: OwnedNft): void { if (!walletBalances) throw new Error("Wallet balances are still loading."); sendNft = nft; pendingTransfer = undefined; byId("send-form").reset(); const select = byId("send-asset"); select.replaceChildren(); const option = document.createElement("option"); option.value = nft.name; option.textContent = nftAssetLabel(nft); select.append(option); select.disabled = true; const amount = byId("send-amount"); if (nft.ownership_type === 0) { amount.value = "1.00000000"; amount.disabled = true; } else { amount.value = ""; amount.disabled = false; } updateSendForm(); showScreen("send-screen"); } async function prepareTransfer(): Promise { if (!walletStatus?.address) throw new Error("No active wallet is available."); const asset = sendNft?.name ?? byId("send-asset").value; const assetLabel = sendNft ? nftAssetLabel(sendNft) : asset; const receiver = requireValue( byId("send-recipient").value, "Enter a receiving address." ); const amount = requireValue( byId("send-amount").value, "Enter an amount to send." ); const fee = requireValue( byId("send-fee").value, "Enter a transaction fee." ); const amountAtomic = decimalToAtomic(amount, "Amount"); const feeAtomic = decimalToAtomic(fee, "Transaction fee"); if (amountAtomic === 0n) throw new Error("The transfer amount must be greater than zero."); if (sendNft?.ownership_type === 0 && amountAtomic !== 100_000_000n) { throw new Error("An indivisible NFT must be transferred as one complete asset."); } if (sendNft?.ownership_type === 1 && amountAtomic > 100_000_000n) { throw new Error("A fractional NFT/RWA transfer cannot exceed one complete asset."); } const minimumFee = minimumSendFeeAtomic(); if (feeAtomic < minimumFee) { throw new Error(`The minimum transaction fee is ${atomicToDecimal(minimumFee)}.`); } const assetBalance = decimalToAtomic(selectedSendBalance(), "Available balance"); if (amountAtomic > assetBalance) throw new Error(`Insufficient ${asset} balance.`); const baseSymbol = walletStatus.networkSymbol; const baseBalanceText = walletBalances?.balances.find( (entry) => entry.nft_series === 0 && entry.asset.trim().toUpperCase() === baseSymbol )?.balance ?? "0.00000000"; const baseBalance = decimalToAtomic(baseBalanceText, "Base balance"); const baseRequired = asset === baseSymbol ? amountAtomic + feeAtomic : feeAtomic; if (baseRequired > baseBalance) throw new Error(`Insufficient ${baseSymbol} balance including the fee.`); const result = await send("wallet-prepare-transfer", { receiver, asset, nftSeries: sendNft?.series ?? 0, valueAtomic: amountAtomic.toString(), feeAtomic: feeAtomic.toString() }) as { transaction: Record; review: Record; receiverInput: string; }; const total = asset === baseSymbol ? `${atomicToDecimal(amountAtomic + feeAtomic)} ${baseSymbol}` : `${atomicToDecimal(amountAtomic)} ${assetLabel} + ${atomicToDecimal(feeAtomic)} ${baseSymbol}`; pendingTransfer = { transaction: result.transaction, receiverInput: result.receiverInput, asset, amount: atomicToDecimal(amountAtomic), fee: atomicToDecimal(feeAtomic), total }; byId("review-amount").textContent = pendingTransfer.amount; byId("review-asset").textContent = assetLabel; byId("review-sender").textContent = walletStatus.address; byId("review-recipient").textContent = result.receiverInput; byId("review-resolved-recipient").textContent = String(result.review.receiver ?? ""); byId("review-fee").textContent = pendingTransfer.fee; byId("review-total").textContent = total; showScreen("send-review-screen"); } function transactionTimestamp(transactionHex: string): Date | undefined { if (!/^[0-9a-f]+$/i.test(transactionHex) || transactionHex.length < 10) return undefined; const bytes = transactionHex.slice(2, 10).match(/../g); if (!bytes || bytes.length !== 4) return undefined; const seconds = bytes.reduce( (value, byte, index) => value + (Number.parseInt(byte, 16) * (2 ** (index * 8))), 0 ); const date = new Date(seconds * 1_000); return Number.isNaN(date.getTime()) ? undefined : date; } function fieldLabel(name: string): string { return name .split("_") .map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part) .join(" "); } function formatAtomicDisplay(value: unknown): string { try { const decimal = atomicToDecimal(BigInt(String(value ?? "0"))); const [whole, fraction] = decimal.split("."); const trimmed = fraction.replace(/0+$/, ""); return trimmed ? `${whole}.${trimmed}` : `${whole}.0`; } catch { return String(value ?? ""); } } function isAtomicTransactionField(txtype: number, name: string): boolean { if (name.startsWith("txfee")) return true; const fieldsByType: Record> = { 1: new Set(["value"]), 2: new Set(["value"]), 3: new Set(["number"]), 6: new Set(["value1", "value2", "tip1", "tip2"]), 7: new Set([ "loan_amount", "collateral_amount", "payment_amount", "max_late_value" ]), 8: new Set(["payback_amount", "tip"]), 10: new Set(["value"]), 11: new Set(["number"]) }; return fieldsByType[txtype]?.has(name) ?? false; } function transactionFieldDisplay(txtype: number, name: string, value: unknown): string { if (typeof value === "boolean") return value ? "True" : "False"; if (isAtomicTransactionField(txtype, name)) return formatAtomicDisplay(value); if (name === "time" || name === "timestamp" || name === "offer_expiration") { const timestamp = Number(value); if (Number.isFinite(timestamp)) { return `${new Date(timestamp * 1_000).toLocaleString()} (${timestamp})`; } } if (name === "payment_period") { const periods: Record = { d: "Daily", w: "Weekly", m: "Monthly" }; return periods[String(value).trim().toLowerCase()] ?? String(value ?? ""); } if (txtype === 3 && name === "hard_limit") { return Number(value) === 1 ? "Yes" : "No"; } if (txtype === 4 && name === "series") { return Number(value) === 1 ? "Series" : "Single NFT/RWA"; } if (txtype === 4 && name === "ownership_type") { return Number(value) === 1 ? "Fractional ownership" : "Complete ownership"; } if ((txtype === 201 || txtype === 202) && name === "vote") { return Number(value) === 1 ? "Approve" : "Reject"; } if ((name === "previous" || name.endsWith("_hash")) && /^0+$/.test(String(value))) { return "None"; } if (txtype === 5 && (name === "impression_value" || name === "click_value")) { return (Number(value) / 100).toFixed(2); } return typeof value === "string" ? value.trim() : String(value ?? ""); } function appendTransactionField( container: HTMLElement, name: string, value: unknown, technical = false, txtype = 0 ): void { const row = document.createElement("div"); row.className = `transaction-field${technical ? " technical-field" : ""}`; const label = document.createElement("span"); label.textContent = fieldLabel(name); const contents = document.createElement("strong"); contents.textContent = technical ? String(value ?? "") : transactionFieldDisplay(txtype, name, value); row.append(label, contents); container.append(row); } function transactionConfirmations(blockHeight: number, currentHeight: number): number { if (!blockHeight || !currentHeight || currentHeight < blockHeight) return 0; return currentHeight - blockHeight; } function confirmationLabel(blockHeight: number, currentHeight: number): string { const confirmations = transactionConfirmations(blockHeight, currentHeight); if (!blockHeight) return `Pending · 0/${CONFIRMED_AFTER_BLOCKS} confirmations`; if (confirmations >= CONFIRMED_AFTER_BLOCKS) { return `${confirmations} confirmations`; } return `${confirmations}/${CONFIRMED_AFTER_BLOCKS} confirmations`; } async function openTransaction( record: ActivityRecord, returnScreen: "activity" | "nft" = "activity" ): Promise { selectedTransaction = record; transactionReturnScreen = returnScreen; const back = byId("close-transaction"); back.title = returnScreen === "nft" ? "Back to NFT/RWA details" : "Back to activity"; back.setAttribute( "aria-label", returnScreen === "nft" ? "Back to NFT/RWA details" : "Back to activity" ); showScreen("transaction-screen"); byId("transaction-title").textContent = "Loading transaction"; byId("transaction-header-status").textContent = ""; byId("transaction-full-txid").textContent = record.txid; byId("transaction-overview").replaceChildren(); byId("transaction-fields").replaceChildren(); byId("transaction-technical-fields").replaceChildren(); byId("transaction-miner-earnings").replaceChildren(); byId("miner-earnings-section").hidden = true; try { const lookup = await send("wallet-transaction", { txid: record.txid }) as { txid: string; block_height: number; current_height: number; transaction_hex: string; }; const wasm = await core(walletStatus?.selectedNetwork ?? "testnet"); const decoded = JSON.parse( wasm.decode_transaction(lookup.transaction_hex) ) as DecodedTransaction; const confirmations = transactionConfirmations( lookup.block_height, lookup.current_height ); const pending = confirmations < CONFIRMED_AFTER_BLOCKS; const timestamp = transactionTimestamp(lookup.transaction_hex); byId("transaction-title").textContent = decoded.transaction_name; byId("transaction-header-status").textContent = pending ? "Pending" : "Confirmed"; byId("transaction-header-status").className = pending ? "transaction-pending" : "transaction-confirmed"; byId("transaction-full-txid").textContent = lookup.txid; const overview = byId("transaction-overview"); appendTransactionField(overview, "status", pending ? "Pending" : "Confirmed"); appendTransactionField( overview, "block_height", lookup.block_height ? lookup.block_height.toLocaleString() : "Mempool" ); appendTransactionField( overview, "confirmations", confirmationLabel(lookup.block_height, lookup.current_height) ); if (timestamp) appendTransactionField(overview, "date", timestamp.toLocaleString()); appendTransactionField(overview, "transaction_type", decoded.txtype); const fields = byId("transaction-fields"); Object.entries(decoded.fields).forEach(([name, value]) => { appendTransactionField(fields, name, value, false, decoded.txtype); }); const earnings = record.miner_earnings ?? []; if (earnings.length > 0) { const earningsContainer = byId("transaction-miner-earnings"); earnings.forEach((earning, index) => { appendTransactionField(earningsContainer, `earning_${index + 1}_type`, earning.type); appendTransactionField(earningsContainer, `earning_${index + 1}_asset`, earning.asset); appendTransactionField( earningsContainer, `earning_${index + 1}_nft_series`, earning.nft_series ); appendTransactionField( earningsContainer, `earning_${index + 1}_amount`, `${formatAtomicDisplay(earning.amount_atomic)} ${earning.asset.trim()}` ); }); byId("miner-earnings-section").hidden = false; } const technical = byId("transaction-technical-fields"); if (decoded.stored_hash) { appendTransactionField(technical, "stored_hash", decoded.stored_hash, true); } if (decoded.signature) { appendTransactionField(technical, "signature", decoded.signature, true); } if (decoded.signature1) { appendTransactionField(technical, "signature_1", decoded.signature1, true); } if (decoded.signature2) { appendTransactionField(technical, "signature_2", decoded.signature2, true); } appendTransactionField(technical, "raw_transaction_bytes", lookup.transaction_hex, true); } catch (error) { byId("transaction-title").textContent = "Transaction unavailable"; const fields = byId("transaction-fields"); fields.replaceChildren(); appendTransactionField(fields, "error", errorText(error)); } } type ActivitySummary = { title: string; subtitle: string; amount: string; secondary?: string; direction: "incoming" | "outgoing" | "neutral"; }; function shortActivityAddress(value: unknown): string { const address = String(value ?? "").trim(); return address.length > 18 ? `${address.slice(0, 8)}...${address.slice(-7)}` : address; } function activitySummary(decoded: DecodedTransaction, address: string): ActivitySummary { const fields = decoded.fields; const active = address.trim().toLowerCase(); const baseSymbol = walletStatus?.networkSymbol ?? "CLTC"; const asset = (name: string) => String(fields[name] ?? "").trim(); const amount = (name: string, symbol: string, sign = "") => `${sign}${formatAtomicDisplay(fields[name])} ${symbol}`; const fee = () => `-${formatAtomicDisplay(fields.txfee ?? 0)} ${baseSymbol}`; switch (decoded.txtype) { case 1: return { title: "Mining reward", subtitle: "From network", amount: amount("value", baseSymbol, "+"), direction: "incoming" }; case 2: { const symbol = asset("coin"); const sent = String(fields.sender ?? "").trim().toLowerCase() === active; return { title: sent ? `Sent ${symbol}` : `Received ${symbol}`, subtitle: sent ? `To: ${shortActivityAddress(fields.receiver)}` : `From: ${shortActivityAddress(fields.sender)}`, amount: amount("value", symbol, sent ? "-" : "+"), direction: sent ? "outgoing" : "incoming" }; } case 3: { const symbol = asset("ticker"); return { title: `Created ${symbol}`, subtitle: "Token creation", amount: amount("number", symbol, "+"), secondary: fee(), direction: "incoming" }; } case 4: { const count = String(fields.count ?? "1"); return { title: `Created ${asset("nft_name")}`, subtitle: Number(fields.series ?? 0) === 0 ? "NFT/RWA" : "NFT/RWA collection", amount: `+${count} ${Number(count) === 1 ? "item" : "items"}`, secondary: fee(), direction: "incoming" }; } case 6: { const first = String(fields.sender1 ?? "").trim().toLowerCase() === active; const sentAsset = asset(first ? "ticker1" : "ticker2"); const receivedAsset = asset(first ? "ticker2" : "ticker1"); return { title: "Completed swap", subtitle: `With: ${shortActivityAddress(fields[first ? "sender2" : "sender1"])}`, amount: amount(first ? "value1" : "value2", sentAsset, "-"), secondary: amount(first ? "value2" : "value1", receivedAsset, "+"), direction: "neutral" }; } case 7: { const lender = String(fields.lender ?? "").trim().toLowerCase() === active; return { title: lender ? "Loan funded" : "Loan received", subtitle: lender ? `To: ${shortActivityAddress(fields.borrower)}` : `From: ${shortActivityAddress(fields.lender)}`, amount: amount("loan_amount", asset("loan_coin"), lender ? "-" : "+"), direction: lender ? "outgoing" : "incoming" }; } case 8: return { title: "Loan payment", subtitle: `Contract: ${shortActivityAddress(fields.contract_hash)}`, amount: `-${formatAtomicDisplay(fields.payback_amount)}`, direction: "outgoing" }; case 10: return { title: `Burned ${asset("coin")}`, subtitle: "Removed from supply", amount: amount("value", asset("coin"), "-"), direction: "outgoing" }; case 11: { const symbol = asset("ticker"); return { title: `Issued ${symbol}`, subtitle: "Additional token supply", amount: amount("number", symbol, "+"), secondary: fee(), direction: "incoming" }; } case 12: return { title: "Created vanity address", subtitle: String(fields.vanity_address ?? "").trim(), amount: fee(), direction: "outgoing" }; default: return { title: decoded.transaction_name, subtitle: "Contractless transaction", amount: fee(), direction: "outgoing" }; } } async function renderActivity( records: ActivityRecord[], currentHeight: number ): Promise { const list = byId("activity-list"); list.replaceChildren(); byId("activity-count").textContent = `${records.length} shown`; if (records.length === 0) { const empty = document.createElement("div"); empty.className = "activity-empty"; const title = document.createElement("strong"); title.textContent = "No wallet activity found"; const description = document.createElement("span"); description.textContent = "New transactions will appear here after they reach the selected API."; empty.append(title, description); list.append(empty); return; } const wasm = await core(walletStatus?.selectedNetwork ?? "testnet"); records.forEach((record) => { let decoded: DecodedTransaction; try { decoded = JSON.parse(wasm.decode_transaction(record.transaction_hex)) as DecodedTransaction; } catch { const txtype = Number.parseInt(record.transaction_hex.slice(0, 2), 16); decoded = { txtype, transaction_name: TRANSACTION_NAMES[txtype] ?? `Transaction Type ${txtype}`, fields: {} }; } const summary = activitySummary(decoded, walletStatus?.address ?? ""); const date = transactionTimestamp(record.transaction_hex); const confirmations = transactionConfirmations(record.block_height, currentHeight); const pending = confirmations < CONFIRMED_AFTER_BLOCKS; const row = document.createElement("button"); row.type = "button"; row.className = "activity-row"; const marker = document.createElement("span"); marker.className = `activity-marker ${summary.direction}`; marker.textContent = pending ? "..." : summary.direction === "incoming" ? "\u2191" : summary.direction === "outgoing" ? "\u2193" : "\u2194"; const details = document.createElement("div"); details.className = "activity-details"; const name = document.createElement("strong"); name.textContent = summary.title; const counterpart = document.createElement("span"); counterpart.textContent = pending ? `Pending · ${summary.subtitle}` : summary.subtitle; const timestamp = document.createElement("small"); timestamp.textContent = date ? date.toLocaleString() : "Transaction time unavailable"; details.append(name, counterpart, timestamp); const status = document.createElement("div"); status.className = "activity-status"; const transactionAmount = document.createElement("strong"); transactionAmount.className = summary.direction; transactionAmount.textContent = summary.amount; status.append(transactionAmount); if (summary.secondary) { const secondary = document.createElement("span"); secondary.textContent = summary.secondary; status.append(secondary); } const confirmation = document.createElement("span"); confirmation.className = pending ? "pending" : "confirmed"; confirmation.textContent = confirmationLabel(record.block_height, currentHeight); status.append(confirmation); row.append(marker, details, status); row.addEventListener("click", () => { void openTransaction(record); }); list.append(row); }); } async function loadActivity(): Promise { if (!walletStatus?.unlocked) return; byId("activity-count").textContent = "Loading"; const list = byId("activity-list"); list.replaceChildren(); const loading = document.createElement("div"); loading.className = "activity-empty"; loading.textContent = "Loading recent activity..."; list.append(loading); try { const result = await send("wallet-activity") as { current_height?: number; transactions?: ActivityRecord[]; }; await renderActivity( Array.isArray(result.transactions) ? result.transactions : [], Number(result.current_height ?? 0) ); } catch (error) { list.replaceChildren(); const failed = document.createElement("div"); failed.className = "activity-empty activity-error"; failed.textContent = errorText(error); list.append(failed); byId("activity-count").textContent = "Unavailable"; } } function errorText(error: unknown): string { if (error instanceof Error) return error.message; return String(error).replace(/^JsValue\((.*)\)$/, "$1"); } async function action(run: () => Promise, loading = false): Promise { notice.textContent = ""; if (loading) showScreen("loading"); try { await run(); } catch (error) { notice.textContent = errorText(error); throw error; } } function requireValue(value: string, message: string): string { if (!value.trim()) throw new Error(message); return value; } function bytesToBase64(bytes: Uint8Array): string { let binary = ""; const chunkSize = 0x8000; for (let offset = 0; offset < bytes.length; offset += chunkSize) { binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); } return btoa(binary); } function downloadBase64Png(contents: string, filename: string): void { const binary = atob(contents); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" })); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1_000); } async function openBackupScreen(status: WalletStatus, password: string): Promise { walletStatus = status; currentPassword = password; backupImageBase64 = String(await send("export-private-key-image", { password, imagePassword: password })); byId("wallet-image-preview").src = `data:image/png;base64,${backupImageBase64}`; const confirmation = byId("backup-confirmed"); confirmation.disabled = true; confirmation.checked = false; byId("open-wallet").disabled = true; showScreen("backup-screen"); } document.querySelectorAll(".navigation").forEach((button) => { button.addEventListener("click", () => { const destination = String(button.dataset.screen); if (destination === "image-screen" && firefoxToolbarPopup) { void chrome.tabs.create({ url: chrome.runtime.getURL("import-wallet.html"), active: true }); return; } showScreen(destination); }); }); byId("account-menu-button").addEventListener("click", () => setAccountDrawer(true)); byId("close-account-drawer").addEventListener("click", () => setAccountDrawer(false)); byId("drawer-backdrop").addEventListener("click", () => setAccountDrawer(false)); function openAddWalletFlow(screen: "create-screen" | "image-screen"): void { setAccountDrawer(false); byId("cancel-wallet-add").hidden = false; showScreen(screen); } byId("menu-create-wallet").addEventListener("click", () => { openAddWalletFlow("create-screen"); }); byId("menu-import-wallet").addEventListener("click", () => { setAccountDrawer(false); if (firefoxToolbarPopup) { void chrome.tabs.create({ url: chrome.runtime.getURL("import-wallet.html"), active: true }); } else { openAddWalletFlow("image-screen"); } }); byId("cancel-wallet-add").addEventListener("click", () => { byId("cancel-wallet-add").hidden = true; if (walletStatus) showStatus(walletStatus); }); byId("menu-settings").addEventListener("click", () => { setAccountDrawer(false); clearRevealedPrivateKey(); if (walletStatus) { byId("settings-testnet-api-url").value = walletStatus.testnetApiUrl; byId("settings-mainnet-api-url").value = walletStatus.mainnetApiUrl; byId("settings-lock-timeout").value = String(walletStatus.autoLockMinutes); } showScreen("settings-screen"); }); byId("close-settings").addEventListener("click", () => { clearRevealedPrivateKey(); if (walletStatus) showStatus(walletStatus); }); byId("drawer-lock-wallet").addEventListener("click", () => { void action(async () => { setAccountDrawer(false); currentPassword = ""; backupImageBase64 = ""; showStatus(await send("lock") as WalletStatus); }); }); byId("open-receive").addEventListener("click", () => { void action(openReceiveScreen); }); byId("open-send").addEventListener("click", () => { void action(async () => openSendScreen()); }); byId("close-send").addEventListener("click", () => { pendingTransfer = undefined; if (sendNft) { renderNftDetails(sendNft); showScreen("nft-detail-screen"); } else if (walletStatus) { showStatus(walletStatus); } }); byId("send-asset").addEventListener("change", updateSendForm); byId("send-amount").addEventListener("input", updateSendForm); byId("send-form").addEventListener("submit", (event) => { event.preventDefault(); void action(prepareTransfer); }); byId("send-form").addEventListener("keydown", (event) => { if (!(event instanceof KeyboardEvent) || event.key !== "Enter") return; if (!(event.target instanceof HTMLInputElement)) return; event.preventDefault(); byId("send-form").requestSubmit(); }); byId("edit-send").addEventListener("click", () => { showScreen("send-screen"); }); byId("sign-broadcast-transfer").addEventListener("click", () => { void action(async () => { if (!pendingTransfer) throw new Error("The reviewed transfer is unavailable."); const result = await send("wallet-broadcast-transfer", { transaction: pendingTransfer.transaction }) as { txid: string }; lastSentTxid = result.txid; byId("send-result-txid").textContent = lastSentTxid; pendingTransfer = undefined; showScreen("send-success-screen"); }); }); byId("copy-send-txid").addEventListener("click", () => { if (!lastSentTxid) return; void navigator.clipboard.writeText(lastSentTxid) .then(() => { notice.textContent = "Transaction ID copied."; }) .catch((error) => { notice.textContent = errorText(error); }); }); byId("finish-send").addEventListener("click", () => { lastSentTxid = ""; const nftTransfer = Boolean(sendNft); sendNft = undefined; if (walletStatus) { void loadWalletBalances(); if (nftTransfer) { selectedNft = undefined; showScreen("nfts-screen"); void loadOwnedNfts(true); } else { showStatus(walletStatus); } } }); byId("open-activity").addEventListener("click", () => { showScreen("activity-screen"); void loadActivity(); }); byId("open-nfts").addEventListener("click", () => { nftPage = 1; selectedNft = undefined; showScreen("nfts-screen"); void loadOwnedNfts(true); }); byId("close-nfts").addEventListener("click", () => { nftLoadId += 1; if (walletStatus) showStatus(walletStatus); }); byId("refresh-nfts").addEventListener("click", () => { void loadOwnedNfts(true); }); byId("close-nft-detail").addEventListener("click", () => { selectedNft = undefined; showScreen("nfts-screen"); }); byId("transfer-nft").addEventListener("click", () => { void action(async () => { if (!selectedNft) throw new Error("No NFT or RWA is selected."); openNftSendScreen(selectedNft); }); }); byId("nfts-previous").addEventListener("click", () => { if (nftPage <= 1) return; nftPage -= 1; const loadId = ++nftLoadId; renderNftGallery(); void hydrateVisibleNfts(loadId); }); byId("nfts-next").addEventListener("click", () => { const totalPages = Math.max(1, Math.ceil(ownedNfts.length / NFT_PAGE_SIZE)); if (nftPage >= totalPages) return; nftPage += 1; const loadId = ++nftLoadId; renderNftGallery(); void hydrateVisibleNfts(loadId); }); byId("close-activity").addEventListener("click", () => { if (walletStatus) showStatus(walletStatus); }); byId("close-transaction").addEventListener("click", () => { if (transactionReturnScreen === "nft" && selectedNft) { renderNftDetails(selectedNft); showScreen("nft-detail-screen"); } else { showScreen("activity-screen"); } }); byId("copy-transaction-txid").addEventListener("click", () => { const txid = selectedTransaction?.txid; if (!txid) return; void navigator.clipboard.writeText(txid) .then(() => { notice.textContent = "Transaction ID copied."; }) .catch((error) => { notice.textContent = errorText(error); }); }); byId("refresh-activity").addEventListener("click", () => { void loadActivity(); }); byId("refresh-balances").addEventListener("click", () => { void loadWalletBalances(); }); document.querySelectorAll("[data-token-filter]").forEach((button) => { button.addEventListener("click", () => { tokenFilter = button.dataset.tokenFilter === "all" ? "all" : "held"; document.querySelectorAll("[data-token-filter]").forEach((candidate) => { candidate.classList.toggle("active", candidate === button); }); renderTokenBalances(); }); }); byId("close-receive").addEventListener("click", () => { if (walletStatus) showStatus(walletStatus); }); byId("copy-receive-address").addEventListener("click", () => { const address = byId("receive-address-value").textContent?.trim(); if (!address) { notice.textContent = "No active wallet address is available."; return; } void navigator.clipboard.writeText(address) .then(() => { notice.textContent = "Wallet address copied."; }) .catch((error) => { notice.textContent = errorText(error); }); }); byId("save-wallet-settings").addEventListener("click", () => { void action(async () => { const testnetApiUrl = byId("settings-testnet-api-url").value.replace(/\/+$/, ""); const mainnetApiUrl = byId("settings-mainnet-api-url").value.replace(/\/+$/, ""); if (!testnetApiUrl) throw new Error("A testnet API URL is required."); for (const apiUrl of [testnetApiUrl, mainnetApiUrl].filter(Boolean)) { const parsed = new URL(apiUrl); if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") { throw new Error("Contractless API URLs must use HTTPS."); } const originPermission = `${parsed.origin}/*`; const alreadyGranted = await chrome.permissions.contains({ origins: [originPermission] }); if (!alreadyGranted) { const granted = await chrome.permissions.request({ origins: [originPermission] }); if (!granted) throw new Error("Permission to contact this API endpoint was denied."); } } walletStatus = await send("save-settings", { testnetApiUrl, mainnetApiUrl, autoLockMinutes: byId("settings-lock-timeout").value }) as WalletStatus; notice.textContent = "Settings saved."; }); }); document.querySelectorAll("[data-select-network]").forEach((button) => { button.addEventListener("click", () => { const network = button.dataset.selectNetwork; if (!network || network === walletStatus?.selectedNetwork) return; void action(async () => { setAccountDrawer(false); showStatus(await send("switch-network", { network }) as WalletStatus); }); }); }); document.querySelectorAll("[data-backup-action]").forEach((button) => { button.addEventListener("click", () => { pendingBackupAction = button.dataset.backupAction as "image" | "private" | "print"; setBackupKeyModal(true); }); }); byId("close-backup-key").addEventListener("click", () => setBackupKeyModal(false)); byId("backup-key-backdrop").addEventListener("click", () => setBackupKeyModal(false)); byId("confirm-backup-key").addEventListener("click", () => { const actionName = pendingBackupAction; const password = byId("backup-key-input").value; if (!actionName) return; if (!password) { notice.textContent = "Enter the active wallet encryption key."; return; } void action(async () => { if (actionName === "image") { const image = String(await send("export-private-key-image", { password, imagePassword: password })); const address = walletStatus?.address?.split(".")[0] ?? "contractless"; downloadBase64Png(image, `${address}.wallet.png`); setBackupKeyModal(false); notice.textContent = "Wallet image downloaded."; return; } const backup = await send("reveal-private-key", { password }) as { label: string; address: string; privateKey: string; }; setBackupKeyModal(false); if (actionName === "private") { byId("revealed-private-key").value = backup.privateKey; byId("private-key-result").hidden = false; byId("private-key-result").scrollIntoView({ block: "nearest" }); return; } const printId = crypto.randomUUID(); await chrome.storage.session.set({ [`print-backup:${printId}`]: { label: backup.label, address: backup.address, privateKey: backup.privateKey, encryptionKey: password } }); await chrome.windows.create({ url: chrome.runtime.getURL(`print.html?id=${encodeURIComponent(printId)}`), type: "popup", width: 820, height: 760 }); }); }); byId("clear-private-key").addEventListener("click", clearRevealedPrivateKey); byId("copy-private-key").addEventListener("click", () => { const privateKey = byId("revealed-private-key").value; if (!privateKey) return; void navigator.clipboard.writeText(privateKey) .then(() => { notice.textContent = "Private key copied."; }) .catch((error) => { notice.textContent = errorText(error); }); }); document.querySelectorAll(".reveal").forEach((button) => { button.addEventListener("click", () => { const input = byId(String(button.dataset.input)); const showing = input.type === "text"; input.type = showing ? "password" : "text"; button.textContent = showing ? "Show" : "Hide"; }); }); byId("continue-create").addEventListener("click", () => { try { pendingPassword = requireValue( byId("new-password").value, "Enter an encryption key." ); byId("verify-password").value = ""; showScreen("verify-screen"); } catch (error) { notice.textContent = errorText(error); } }); byId("create-wallet").addEventListener("click", () => { const verification = byId("verify-password").value; if (verification !== pendingPassword) { notice.textContent = "The encryption keys do not match."; return; } void action(async () => { const password = pendingPassword; const status = await send("create-wallet", { password }) as WalletStatus; pendingPassword = ""; byId("new-password").value = ""; byId("verify-password").value = ""; await openBackupScreen(status, password); }, true).catch(() => showScreen("verify-screen", false)); }); const walletImage = byId("wallet-image"); walletImage.addEventListener("change", () => { const file = walletImage.files?.[0]; selectedImageBase64 = ""; byId("image-file-name").textContent = file?.name ?? "Choose wallet image"; if (!file) return; void file.arrayBuffer() .then((buffer) => { selectedImageBase64 = bytesToBase64(new Uint8Array(buffer)); }) .catch((error) => { notice.textContent = errorText(error); }); }); byId("import-image-form").addEventListener("submit", (event) => { event.preventDefault(); const password = byId("image-password").value; try { requireValue(password, "Enter the wallet image encryption key."); requireValue(selectedImageBase64, "Choose a wallet image."); } catch (error) { notice.textContent = errorText(error); return; } void action(async () => { const status = await send("import-wallet-image", { imageBase64: selectedImageBase64, imagePassword: password, password }) as WalletStatus; currentPassword = password; selectedImageBase64 = ""; walletImage.value = ""; byId("image-password").value = ""; showStatus(status); }, true).catch(() => showScreen("image-screen", false)); }); byId("import-private").addEventListener("click", () => { const privateKey = byId("private-key").value.trim(); const password = byId("private-password").value; try { requireValue(privateKey, "Paste the private key."); requireValue(password, "Enter a new encryption key."); } catch (error) { notice.textContent = errorText(error); return; } void action(async () => { const status = await send("import-private-key", { privateKey, password }) as WalletStatus; byId("private-key").value = ""; byId("private-password").value = ""; await openBackupScreen(status, password); }, true).catch(() => showScreen("private-screen", false)); }); byId("save-image").addEventListener("click", () => { void action(async () => { if (!backupImageBase64) throw new Error("The wallet image is not ready."); const address = walletStatus?.address?.split(".")[0] ?? "contractless"; downloadBase64Png(backupImageBase64, `${address}.wallet.png`); const confirmation = byId("backup-confirmed"); confirmation.disabled = false; confirmation.checked = false; byId("open-wallet").disabled = true; }); }); byId("backup-confirmed").addEventListener("change", (event) => { byId("open-wallet").disabled = !(event.currentTarget as HTMLInputElement).checked; }); byId("open-wallet").addEventListener("click", () => { currentPassword = ""; backupImageBase64 = ""; byId("wallet-image-preview").removeAttribute("src"); if (walletStatus) showStatus(walletStatus); }); byId("unlock-form").addEventListener("submit", (event) => { event.preventDefault(); const password = byId("unlock-password").value; try { requireValue(password, "Enter your encryption key."); } catch (error) { notice.textContent = errorText(error); return; } void action(async () => { const status = await send("unlock", { password }) as WalletStatus; currentPassword = password; byId("unlock-password").value = ""; showStatus(status); }, true).catch(() => showScreen("locked-screen", false)); }); byId("approve-provider-approval").addEventListener("click", () => { if (!pendingProviderApprovalId) return; setApprovalProcessing("approve"); void action(async () => { const result = await send("resolve-provider-approval", { id: pendingProviderApprovalId, approved: true }) as { remaining?: number }; pendingProviderApprovalId = ""; await showNextProviderApproval(result.remaining ?? 0, true); }).catch(() => setApprovalProcessing(null)); }); byId("reject-provider-approval").addEventListener("click", () => { if (!pendingProviderApprovalId) return; setApprovalProcessing("reject"); void action(async () => { const result = await send("resolve-provider-approval", { id: pendingProviderApprovalId, approved: false }) as { remaining?: number }; pendingProviderApprovalId = ""; await showNextProviderApproval(result.remaining ?? 0, false); }).catch(() => setApprovalProcessing(null)); }); send("status") .then((status) => showStatus(status as WalletStatus)) .catch((error) => { showScreen("create-screen"); notice.textContent = errorText(error); });