Contractless-Web3-Wallets/extension/src/popup.ts

2349 lines
77 KiB
TypeScript
Raw Normal View History

import type { WalletStatus } from "./types";
import QRCode from "qrcode";
import { core } from "./core";
const byId = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const screens = Array.from(document.querySelectorAll<HTMLElement>(".screen"));
const notice = byId<HTMLParagraphElement>("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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
receiverInput: string;
asset: string;
amount: string;
fee: string;
total: string;
}
| undefined;
let lastSentTxid = "";
let pendingProviderApprovalId = "";
const CONFIRMED_AFTER_BLOCKS = 20;
2026-08-03 05:31:44 +00:00
const BALANCE_REFRESH_INTERVAL_MS = 15_000;
let balanceLoadInFlight = false;
const TRANSACTION_NAMES: Record<number, string> = {
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<string, unknown> = {}) {
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<HTMLElement>("[data-network-name]").forEach((element) => {
element.textContent = networkName;
});
document.querySelectorAll<HTMLElement>("[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<HTMLInputElement>("settings-testnet-api-url").value = status.testnetApiUrl;
byId<HTMLInputElement>("settings-mainnet-api-url").value = status.mainnetApiUrl;
document.querySelectorAll<HTMLButtonElement>("[data-select-network]").forEach((button) => {
const active = button.dataset.selectNetwork === status.selectedNetwork;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
byId<HTMLSelectElement>("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, unknown>): 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<string, unknown>,
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<string, unknown>,
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<string, unknown>
): 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<HTMLButtonElement>("approve-provider-approval");
const reject = byId<HTMLButtonElement>("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<boolean> {
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<string, unknown>;
};
} | 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<string, unknown>;
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<void> {
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);
});
}
2026-08-03 05:31:44 +00:00
async function loadWalletBalances(
options: { showLoading?: boolean; reportErrors?: boolean } = {}
): Promise<void> {
if (!walletStatus?.unlocked || balanceLoadInFlight) return;
const requestedAddress = walletStatus.address;
const requestedNetwork = walletStatus.selectedNetwork;
const requestedApiUrl = walletStatus.apiUrl;
const hadBalances = walletBalances !== undefined;
const showLoading = options.showLoading ?? !hadBalances;
const reportErrors = options.reportErrors ?? true;
balanceLoadInFlight = true;
if (showLoading) {
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;
};
2026-08-03 05:31:44 +00:00
if (
!walletStatus?.unlocked
|| walletStatus.address !== requestedAddress
|| walletStatus.selectedNetwork !== requestedNetwork
|| walletStatus.apiUrl !== requestedApiUrl
) {
return;
}
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) {
2026-08-03 05:31:44 +00:00
if (!hadBalances) {
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);
}
if (reportErrors) notice.textContent = errorText(error);
} finally {
balanceLoadInFlight = false;
}
}
function refreshVisibleDashboard(): void {
if (
document.hidden
|| byId("dashboard-screen").hidden
|| !walletStatus?.unlocked
) {
return;
}
2026-08-03 05:31:44 +00:00
void loadWalletBalances({ showLoading: false, reportErrors: false });
}
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<string, unknown> {
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<string, unknown>).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<HTMLButtonElement>("nfts-previous").disabled = nftPage <= 1;
byId<HTMLButtonElement>("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<HTMLImageElement>("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<string, unknown>;
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<void> {
const queue = visibleNfts().filter((nft) => !nft.details && !nft.loading);
let cursor = 0;
const worker = async (): Promise<void> => {
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<void> {
const loadId = ++nftLoadId;
byId("nfts-count").textContent = "Loading ownership...";
if (!walletBalances || force) {
await loadWalletBalances();
}
if (loadId !== nftLoadId || !walletBalances) return;
const previous = force
? new Map<string, OwnedNft>()
: 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");
2026-08-03 06:05:16 +00:00
const trigger = byId<HTMLButtonElement>("account-menu-button");
if (open) {
drawer.inert = false;
drawer.setAttribute("aria-hidden", "false");
drawer.classList.add("open");
byId<HTMLButtonElement>("close-account-drawer").focus();
} else {
if (drawer.contains(document.activeElement)) trigger.focus();
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
drawer.inert = true;
}
trigger.setAttribute("aria-expanded", String(open));
backdrop.hidden = !open;
}
function clearRevealedPrivateKey(): void {
byId<HTMLTextAreaElement>("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<HTMLInputElement>("backup-key-input");
input.value = "";
input.focus();
} else {
byId<HTMLInputElement>("backup-key-input").value = "";
pendingBackupAction = undefined;
}
}
async function openReceiveScreen(): Promise<void> {
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<HTMLCanvasElement>("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<HTMLSelectElement>("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<HTMLSelectElement>("send-asset").value;
const baseSymbol = walletStatus?.networkSymbol ?? "CLTC";
if (asset !== baseSymbol) return 100_000_000n;
const amount = byId<HTMLInputElement>("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<HTMLSelectElement>("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<HTMLInputElement>("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<HTMLSelectElement>("send-asset");
select.replaceChildren();
const baseSymbol = walletStatus?.networkSymbol ?? "CLTC";
const assets = new Set<string>([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<HTMLFormElement>("send-form").reset();
byId<HTMLSelectElement>("send-asset").disabled = false;
byId<HTMLInputElement>("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<HTMLFormElement>("send-form").reset();
const select = byId<HTMLSelectElement>("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<HTMLInputElement>("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<void> {
if (!walletStatus?.address) throw new Error("No active wallet is available.");
const asset = sendNft?.name ?? byId<HTMLSelectElement>("send-asset").value;
const assetLabel = sendNft ? nftAssetLabel(sendNft) : asset;
const receiver = requireValue(
byId<HTMLInputElement>("send-recipient").value,
"Enter a receiving address."
);
const amount = requireValue(
byId<HTMLInputElement>("send-amount").value,
"Enter an amount to send."
);
const fee = requireValue(
byId<HTMLInputElement>("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<string, unknown>;
review: Record<string, unknown>;
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<number, Set<string>> = {
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<string, string> = { 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<void> {
selectedTransaction = record;
transactionReturnScreen = returnScreen;
const back = byId<HTMLButtonElement>("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<void> {
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<void> {
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<void>, loading = false): Promise<void> {
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<void> {
walletStatus = status;
currentPassword = password;
backupImageBase64 = String(await send("export-private-key-image", {
password,
imagePassword: password
}));
byId<HTMLImageElement>("wallet-image-preview").src =
`data:image/png;base64,${backupImageBase64}`;
const confirmation = byId<HTMLInputElement>("backup-confirmed");
confirmation.disabled = true;
confirmation.checked = false;
byId<HTMLButtonElement>("open-wallet").disabled = true;
showScreen("backup-screen");
}
document.querySelectorAll<HTMLButtonElement>(".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<HTMLInputElement>("settings-testnet-api-url").value = walletStatus.testnetApiUrl;
byId<HTMLInputElement>("settings-mainnet-api-url").value = walletStatus.mainnetApiUrl;
byId<HTMLSelectElement>("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<HTMLFormElement>("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<HTMLButtonElement>("[data-token-filter]").forEach((button) => {
button.addEventListener("click", () => {
tokenFilter = button.dataset.tokenFilter === "all" ? "all" : "held";
document.querySelectorAll<HTMLButtonElement>("[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<HTMLInputElement>("settings-testnet-api-url").value.replace(/\/+$/, "");
const mainnetApiUrl = byId<HTMLInputElement>("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<HTMLSelectElement>("settings-lock-timeout").value
}) as WalletStatus;
notice.textContent = "Settings saved.";
});
});
document.querySelectorAll<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLInputElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLButtonElement>(".reveal").forEach((button) => {
button.addEventListener("click", () => {
const input = byId<HTMLInputElement>(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<HTMLInputElement>("new-password").value,
"Enter an encryption key."
);
byId<HTMLInputElement>("verify-password").value = "";
showScreen("verify-screen");
} catch (error) {
notice.textContent = errorText(error);
}
});
byId("create-wallet").addEventListener("click", () => {
const verification = byId<HTMLInputElement>("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<HTMLInputElement>("new-password").value = "";
byId<HTMLInputElement>("verify-password").value = "";
await openBackupScreen(status, password);
}, true).catch(() => showScreen("verify-screen", false));
});
const walletImage = byId<HTMLInputElement>("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<HTMLInputElement>("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<HTMLInputElement>("image-password").value = "";
showStatus(status);
}, true).catch(() => showScreen("image-screen", false));
});
byId("import-private").addEventListener("click", () => {
const privateKey = byId<HTMLTextAreaElement>("private-key").value.trim();
const password = byId<HTMLInputElement>("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<HTMLTextAreaElement>("private-key").value = "";
byId<HTMLInputElement>("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<HTMLInputElement>("backup-confirmed");
confirmation.disabled = false;
confirmation.checked = false;
byId<HTMLButtonElement>("open-wallet").disabled = true;
});
});
byId<HTMLInputElement>("backup-confirmed").addEventListener("change", (event) => {
byId<HTMLButtonElement>("open-wallet").disabled =
!(event.currentTarget as HTMLInputElement).checked;
});
byId("open-wallet").addEventListener("click", () => {
currentPassword = "";
backupImageBase64 = "";
byId<HTMLImageElement>("wallet-image-preview").removeAttribute("src");
if (walletStatus) showStatus(walletStatus);
});
byId("unlock-form").addEventListener("submit", (event) => {
event.preventDefault();
const password = byId<HTMLInputElement>("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<HTMLInputElement>("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));
});
2026-08-03 05:31:44 +00:00
window.setInterval(refreshVisibleDashboard, BALANCE_REFRESH_INTERVAL_MS);
document.addEventListener("visibilitychange", refreshVisibleDashboard);
send("status")
.then((status) => showStatus(status as WalletStatus))
.catch((error) => {
showScreen("create-screen");
notice.textContent = errorText(error);
});