2026-08-02 21:08:45 +00:00
|
|
|
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;
|
2026-08-02 21:08:45 +00:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 21:08:45 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 21:08:45 +00:00
|
|
|
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-02 21:08:45 +00:00
|
|
|
}
|
2026-08-03 05:31:44 +00:00
|
|
|
void loadWalletBalances({ showLoading: false, reportErrors: false });
|
2026-08-02 21:08:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
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<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);
|
|
|
|
|
|
2026-08-02 21:08:45 +00:00
|
|
|
send("status")
|
|
|
|
|
.then((status) => showStatus(status as WalletStatus))
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
showScreen("create-screen");
|
|
|
|
|
notice.textContent = errorText(error);
|
|
|
|
|
});
|