Initial Contractless GUI wallet commit

This commit is contained in:
viraladmin 2026-07-01 10:01:30 -06:00
commit ad64c14a04
56 changed files with 35843 additions and 0 deletions

6682
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "contractless-wallet"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
blockchain = { path = ".." }
base64 = "0.22"
native-dialog = "0.6"
reqwest = { version = "0.12", features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
clipboard-win = "5.4"

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# Contractless Wallet GUI
This folder contains the early Contractless desktop wallet prototype.
## Required Location
This wallet crate must be placed directly inside the Contractless blockchain repository root.
Expected layout:
```text
Contractless/
Cargo.toml
src/
gui_wallet/
Cargo.toml
tauri.conf.json
frontend/
src/
```
The wallet depends on the blockchain crate with:
```toml
blockchain = { path = ".." }
```
Because of that, `gui wallet/` must be in the same folder that contains the Contractless `src/` folder and root `Cargo.toml`. If the wallet folder is moved somewhere else, the parent `..` path will not point at the blockchain crate and the wallet will not build.
## Layout
- `frontend/` contains the static HTML, CSS, and JavaScript wallet UI.
- `src/` contains the Rust/Tauri desktop app code.
- `icons/`, `capabilities/`, `tauri.conf.json`, `build.rs`, `Cargo.toml`, and `Cargo.lock` are the Tauri project files.
## Build Check
From `gui wallet/`:
```bash
cargo check
cargo build
```
## Linux Prerequisites
Tauri depends on native Linux desktop libraries. On Ubuntu/Debian, install:
```bash
sudo apt update
sudo apt install \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libdbus-1-dev \
pkg-config
```
Some older distributions may use `libwebkit2gtk-4.0-dev` instead of `libwebkit2gtk-4.1-dev`.
Fedora-style systems generally need the equivalent WebKitGTK, GTK, AppIndicator/Ayatana, librsvg, DBus, and pkg-config development packages.
## Icons
Tauri expects platform icon assets in `icons/`.
- Windows uses `icon.ico`.
- Linux expects `icon.png`.
The current icons are placeholders and should be replaced before release packaging.

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default wallet window permissions",
"windows": ["main"],
"permissions": ["core:default"]
}

1
frontend/app.js Normal file
View File

@ -0,0 +1 @@
// Contractless Wallet frontend is split by feature in frontend/js/*.js.

BIN
frontend/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
frontend/images/iconw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1568
frontend/index.html Normal file

File diff suppressed because it is too large Load Diff

655
frontend/js/address_book.js Normal file
View File

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

899
frontend/js/app_state.js Normal file
View File

@ -0,0 +1,899 @@
const titles = {
dashboard: ["Dashboard", "Overview for the active address."],
send: ["Send", "Create and preview transaction forms."],
receive: ["Receive", "Show wallet address and payment request details."],
history: ["History", "Full local transaction history for the active address."],
transactions: ["Transactions", "Review local and confirmed transaction activity."],
assets: ["Assets", "View CLC, tokens, and balances."],
nfts: ["NFTs", "View and manage non-fungible token holdings."],
loans: ["Loans", "Create loans, payments, and collateral claims."],
marketing: ["Marketing", "Review campaign activity from agency wallets."],
addressbook: ["Address Book", "Manage known addresses and labels."],
settings: ["Settings", "Manage wallets, nodes, security, and app preferences."]
};
let currentTransactionType = "";
function setScopedTransactionMessage(formType, element, message, type = "") {
if (!element) {
return;
}
if (message && (currentView !== "send" || currentTransactionType !== formType)) {
return;
}
element.textContent = message;
element.classList.toggle("hidden", !message);
element.classList.toggle("error", type === "error");
element.classList.toggle("success", type === "success");
}
const navButtons = document.querySelectorAll("[data-view]");
const dashboardView = document.querySelector("#dashboard-view");
const transactionView = document.querySelector("#transaction-view");
const receiveView = document.querySelector("#receive-view");
const historyView = document.querySelector("#history-view");
const assetsView = document.querySelector("#assets-view");
const nftsView = document.querySelector("#nfts-view");
const loansView = document.querySelector("#loans-view");
const marketingView = document.querySelector("#marketing-view");
const addressbookView = document.querySelector("#addressbook-view");
const settingsView = document.querySelector("#settings-view");
const viewTitle = document.querySelector("#view-title");
const viewSubtitle = document.querySelector("#view-subtitle");
const transactionTitle = document.querySelector("#transaction-panel-title");
const transactionCopy = document.querySelector("#transaction-panel-copy");
const transactionList = document.querySelector("[data-transaction-list]");
const formPanel = document.querySelector("[data-form-panel]");
const reviewPanel = document.querySelector("[data-review-panel]");
const unbroadcastListMessage = document.querySelector("[data-unbroadcast-list-message]");
const unbroadcastReviewGrid = document.querySelector("[data-unbroadcast-review-grid]");
const unbroadcastJsonPreview = document.querySelector("[data-unbroadcast-json]");
const unbroadcastReviewMessage = document.querySelector("[data-unbroadcast-review-message]");
const unbroadcastBackButton = document.querySelector("[data-unbroadcast-back]");
const unbroadcastDeleteButton = document.querySelector("[data-unbroadcast-delete]");
const unbroadcastSignButton = document.querySelector("[data-unbroadcast-sign]");
const unbroadcastBroadcastButton = document.querySelector("[data-unbroadcast-broadcast]");
const transferAssetSelect = document.querySelector("[data-transfer-asset]");
const transferReceiverInput = document.querySelector("[data-transfer-receiver]");
const transferAmountInput = document.querySelector("[data-transfer-amount]");
const transferFeeInput = document.querySelector("[data-transfer-fee]");
const transferCoin = document.querySelector("[data-transfer-coin]");
const transferNftSeries = document.querySelector("[data-transfer-nft-series]");
const transferBalance = document.querySelector("[data-transfer-balance]");
const transferSender = document.querySelector("[data-transfer-sender]");
const transferMessage = document.querySelector("[data-transfer-message]");
const transferActions = document.querySelector("[data-transfer-actions]");
const marketingCampaignForm = document.querySelector("[data-marketing-campaign-form]");
const marketingCampaignAdvertiserInput = document.querySelector("[data-marketing-campaign-advertiser]");
const marketingCampaignIdInput = document.querySelector("[data-marketing-campaign-id]");
const marketingCampaignLoadButton = document.querySelector("[data-marketing-campaign-load]");
const marketingCampaignRefreshButton = document.querySelector("[data-marketing-campaign-refresh]");
const marketingCampaignRows = document.querySelector("[data-marketing-campaign-rows]");
const marketingCampaignCards = document.querySelector("[data-marketing-campaign-cards]");
const marketingCampaignState = document.querySelector("[data-marketing-campaign-state]");
const marketingCampaignCount = document.querySelector("[data-marketing-campaign-count]");
const marketingCampaignPagination = document.querySelector("[data-marketing-campaign-pagination]");
const marketingCampaignPageInput = document.querySelector("[data-marketing-campaign-page-input]");
const marketingCampaignPageTotal = document.querySelector("[data-marketing-campaign-page-total]");
const marketingTotalRecords = document.querySelector("[data-marketing-total-records]");
const marketingTotalImpressions = document.querySelector("[data-marketing-total-impressions]");
const marketingTotalClicks = document.querySelector("[data-marketing-total-clicks]");
const marketingAvgImpressionValue = document.querySelector("[data-marketing-avg-impression-value]");
const marketingAvgClickValue = document.querySelector("[data-marketing-avg-click-value]");
const marketingTotalValue = document.querySelector("[data-marketing-total-value]");
const createTokenForm = document.querySelector("[data-create-token-form]");
const createTokenActions = document.querySelector("[data-create-token-actions]");
const createTokenTickerInput = document.querySelector("[data-create-token-ticker]");
const createTokenNumberInput = document.querySelector("[data-create-token-number]");
const createTokenFeeInput = document.querySelector("[data-create-token-fee]");
const createTokenAllowMoreInput = document.querySelector("[data-create-token-allow-more]");
const createTokenCap = document.querySelector("[data-create-token-cap]");
const createTokenCreator = document.querySelector("[data-create-token-creator]");
const createTokenFeeLabel = document.querySelector("[data-create-token-fee-label]");
const createTokenMessage = document.querySelector("[data-create-token-message]");
const issueTokenForm = document.querySelector("[data-issue-token-form]");
const issueTokenActions = document.querySelector("[data-issue-token-actions]");
const issueTokenTickerSelect = document.querySelector("[data-issue-token-ticker]");
const issueTokenNumberInput = document.querySelector("[data-issue-token-number]");
const issueTokenFeeInput = document.querySelector("[data-issue-token-fee]");
const issueTokenCreator = document.querySelector("[data-issue-token-creator]");
const issueTokenPolicy = document.querySelector("[data-issue-token-policy]");
const issueTokenFeeLabel = document.querySelector("[data-issue-token-fee-label]");
const issueTokenMessage = document.querySelector("[data-issue-token-message]");
const createNftForm = document.querySelector("[data-create-nft-form]");
const createNftActions = document.querySelector("[data-create-nft-actions]");
const createNftSeriesSelect = document.querySelector("[data-create-nft-series]");
const createNftNameInput = document.querySelector("[data-create-nft-name]");
const createNftIpfsInput = document.querySelector("[data-create-nft-ipfs]");
const createNftCountInput = document.querySelector("[data-create-nft-count]");
const createNftDescriptionInput = document.querySelector("[data-create-nft-description]");
const createNftFeeInput = document.querySelector("[data-create-nft-fee]");
const createNftCreator = document.querySelector("[data-create-nft-creator]");
const createNftTypeLabel = document.querySelector("[data-create-nft-type-label]");
const createNftCountLabel = document.querySelector("[data-create-nft-count-label]");
const createNftFeeLabel = document.querySelector("[data-create-nft-fee-label]");
const createNftMessage = document.querySelector("[data-create-nft-message]");
const swapOfferForm = document.querySelector("[data-swap-offer-form]");
const swapOfferActions = document.querySelector("[data-swap-offer-actions]");
const swapOfferAssetSelect = document.querySelector("[data-swap-offer-asset]");
const swapOfferAmountInput = document.querySelector("[data-swap-offer-amount]");
const swapOfferTipInput = document.querySelector("[data-swap-offer-tip]");
const swapRequestAssetSelect = document.querySelector("[data-swap-request-asset]");
const swapRequestAmountInput = document.querySelector("[data-swap-request-amount]");
const swapRequestTipInput = document.querySelector("[data-swap-request-tip]");
const swapCounterpartyInput = document.querySelector("[data-swap-counterparty]");
const swapExpirationInput = document.querySelector("[data-swap-expiration]");
const swapSender = document.querySelector("[data-swap-sender]");
const swapAvailable = document.querySelector("[data-swap-available]");
const swapFees = document.querySelector("[data-swap-fees]");
const swapSavedPathPanel = document.querySelector("[data-swap-saved-path-panel]");
const swapSavedPathInput = document.querySelector("[data-swap-saved-path]");
const swapCopyPathButton = document.querySelector("[data-swap-copy-path]");
const swapOfferMessage = document.querySelector("[data-swap-offer-message]");
const loanOfferForm = document.querySelector("[data-loan-offer-form]");
const loanOfferActions = document.querySelector("[data-loan-offer-actions]");
const loanAssetSelect = document.querySelector("[data-loan-asset]");
const loanAmountInput = document.querySelector("[data-loan-amount]");
const loanBorrowerInput = document.querySelector("[data-loan-borrower]");
const loanStartInput = document.querySelector("[data-loan-start]");
const loanCollateralSelect = document.querySelector("[data-loan-collateral]");
const loanCollateralAmountInput = document.querySelector("[data-loan-collateral-amount]");
const loanPaymentPeriodSelect = document.querySelector("[data-loan-payment-period]");
const loanPaymentNumberInput = document.querySelector("[data-loan-payment-number]");
const loanPaymentAmountInput = document.querySelector("[data-loan-payment-amount]");
const loanGracePeriodInput = document.querySelector("[data-loan-grace-period]");
const loanMaxLateValueInput = document.querySelector("[data-loan-max-late-value]");
const loanFeeInput = document.querySelector("[data-loan-fee]");
const loanLender = document.querySelector("[data-loan-lender]");
const loanAvailable = document.querySelector("[data-loan-available]");
const loanTotalRepayment = document.querySelector("[data-loan-total-repayment]");
const loanSavedPathPanel = document.querySelector("[data-loan-saved-path-panel]");
const loanSavedPathInput = document.querySelector("[data-loan-saved-path]");
const loanCopyPathButton = document.querySelector("[data-loan-copy-path]");
const loanOfferMessage = document.querySelector("[data-loan-offer-message]");
const loanPaymentForm = document.querySelector("[data-loan-payment-form]");
const loanPaymentActions = document.querySelector("[data-loan-payment-actions]");
const loanPaymentContractSelect = document.querySelector("[data-loan-payment-contract]");
const loanPaymentValueInput = document.querySelector("[data-loan-payment-value]");
const loanPaymentTipInput = document.querySelector("[data-loan-payment-tip]");
const loanPaymentFeeInput = document.querySelector("[data-loan-payment-fee]");
const loanPaymentLender = document.querySelector("[data-loan-payment-lender]");
const loanPaymentAsset = document.querySelector("[data-loan-payment-asset]");
const loanPaymentScheduled = document.querySelector("[data-loan-payment-scheduled]");
const loanPaymentConfirmed = document.querySelector("[data-loan-payment-confirmed]");
const loanPaymentPending = document.querySelector("[data-loan-payment-pending]");
const loanPaymentRemaining = document.querySelector("[data-loan-payment-remaining]");
const loanPaymentWarning = document.querySelector("[data-loan-payment-warning]");
const loanPaymentMessage = document.querySelector("[data-loan-payment-message]");
const collateralClaimForm = document.querySelector("[data-collateral-claim-form]");
const collateralClaimActions = document.querySelector("[data-collateral-claim-actions]");
const collateralClaimContractSelect = document.querySelector("[data-collateral-claim-contract]");
const collateralClaimFeeInput = document.querySelector("[data-collateral-claim-fee]");
const collateralClaimRole = document.querySelector("[data-collateral-claim-role]");
const collateralClaimReason = document.querySelector("[data-collateral-claim-reason]");
const collateralClaimLender = document.querySelector("[data-collateral-claim-lender]");
const collateralClaimBorrower = document.querySelector("[data-collateral-claim-borrower]");
const collateralClaimLoan = document.querySelector("[data-collateral-claim-loan]");
const collateralClaimCollateral = document.querySelector("[data-collateral-claim-collateral]");
const collateralClaimConfirmed = document.querySelector("[data-collateral-claim-confirmed]");
const collateralClaimRemaining = document.querySelector("[data-collateral-claim-remaining]");
const collateralClaimMessage = document.querySelector("[data-collateral-claim-message]");
const burnForm = document.querySelector("[data-burn-form]");
const burnActions = document.querySelector("[data-burn-actions]");
const burnAssetSelect = document.querySelector("[data-burn-asset]");
const burnAmountInput = document.querySelector("[data-burn-amount]");
const burnFeeInput = document.querySelector("[data-burn-fee]");
const burnAddress = document.querySelector("[data-burn-address]");
const burnAvailable = document.querySelector("[data-burn-available]");
const burnType = document.querySelector("[data-burn-type]");
const burnFeeLabel = document.querySelector("[data-burn-fee-label]");
const burnMessage = document.querySelector("[data-burn-message]");
const marketingForm = document.querySelector("[data-marketing-form]");
const marketingActions = document.querySelector("[data-marketing-actions]");
const marketingCampaignInput = document.querySelector("[data-marketing-campaign]");
const marketingAdTypeSelect = document.querySelector("[data-marketing-ad-type]");
const marketingKeywordInput = document.querySelector("[data-marketing-keyword]");
const marketingDisplayedInput = document.querySelector("[data-marketing-displayed]");
const marketingImpressionInput = document.querySelector("[data-marketing-impression]");
const marketingClickInput = document.querySelector("[data-marketing-click]");
const marketingImpressionValueInput = document.querySelector("[data-marketing-impression-value]");
const marketingClickValueInput = document.querySelector("[data-marketing-click-value]");
const marketingFeeInput = document.querySelector("[data-marketing-fee]");
const marketingAdvertiser = document.querySelector("[data-marketing-advertiser]");
const marketingFeeLabel = document.querySelector("[data-marketing-fee-label]");
const marketingMessage = document.querySelector("[data-marketing-message]");
const vanityForm = document.querySelector("[data-vanity-form]");
const vanityActions = document.querySelector("[data-vanity-actions]");
const vanityNameInput = document.querySelector("[data-vanity-name]");
const vanityFeeInput = document.querySelector("[data-vanity-fee]");
const vanityOwner = document.querySelector("[data-vanity-owner]");
const vanityPreview = document.querySelector("[data-vanity-preview]");
const vanityFeeLabel = document.querySelector("[data-vanity-fee-label]");
const vanityMessage = document.querySelector("[data-vanity-message]");
const bridgeStatus = document.querySelector("[data-bridge-status]");
const createWalletForm = document.querySelector("[data-create-wallet-form]");
const loadWalletForm = document.querySelector("[data-load-wallet-form]");
const importKeyForm = document.querySelector("[data-import-key-form]");
const importImageForm = document.querySelector("[data-import-image-form]");
const walletNameInput = document.querySelector("[data-wallet-name]");
const walletKeyInput = document.querySelector("[data-wallet-key]");
const walletKeyConfirmInput = document.querySelector("[data-wallet-key-confirm]");
const createWalletSubmit = document.querySelector("[data-create-wallet-submit]");
const startupMessage = document.querySelector("[data-startup-message]");
const walletListSelect = document.querySelector("[data-wallet-list]");
const loadWalletKeyInput = document.querySelector("[data-load-wallet-key]");
const loadWalletSubmit = document.querySelector("[data-load-wallet-submit]");
const loadMessage = document.querySelector("[data-load-message]");
const importWalletNameInput = document.querySelector("[data-import-wallet-name]");
const importPrivateKeyInput = document.querySelector("[data-import-private-key]");
const importWalletKeyInput = document.querySelector("[data-import-wallet-key]");
const importWalletKeyConfirmInput = document.querySelector("[data-import-wallet-key-confirm]");
const importKeySubmit = document.querySelector("[data-import-key-submit]");
const importMessage = document.querySelector("[data-import-message]");
const importImageWalletNameInput = document.querySelector("[data-import-image-wallet-name]");
const importImagePathInput = document.querySelector("[data-import-image-path]");
const importImageWalletKeyInput = document.querySelector("[data-import-image-wallet-key]");
const importImageWalletKeyConfirmInput = document.querySelector("[data-import-image-wallet-key-confirm]");
const importImageSubmit = document.querySelector("[data-import-image-submit]");
const importImageMessage = document.querySelector("[data-import-image-message]");
const lockWalletButton = document.querySelector("[data-lock-wallet]");
const browseWalletButton = document.querySelector("[data-browse-wallet]");
const browsePrivateKeyImageButton = document.querySelector("[data-browse-private-key-image]");
const networkButtons = document.querySelectorAll("[data-network-toggle]");
const networkLabels = document.querySelectorAll("[data-network-label]");
const clcBalance = document.querySelector("[data-clc-balance]");
const baseBalanceLabel = document.querySelector("[data-base-balance-label]");
const balanceStatus = document.querySelector("[data-balance-status]");
const tokenBalances = document.querySelector("[data-token-balances]");
const broadcastNodeButton = document.querySelector("[data-broadcast-node]");
const registrationBadge = document.querySelector(".registration-badge");
const registrationAction = document.querySelector("[data-registration-action]");
const registrationNotice = document.querySelector("[data-registration-notice]");
const recentTransactionsBody = document.querySelector("[data-recent-transactions]");
const recentTransactionsState = document.querySelector("[data-recent-transactions-state]");
const historyTransactionsBody = document.querySelector("[data-history-transactions]");
const historyCards = document.querySelector("[data-history-cards]");
const historyState = document.querySelector("[data-history-state]");
const historyCount = document.querySelector("[data-history-count]");
const historyPagination = document.querySelector("[data-history-pagination]");
const historyPageInput = document.querySelector("[data-history-page-input]");
const historyPageTotal = document.querySelector("[data-history-page-total]");
const historySearch = document.querySelector("[data-history-search]");
const historyExportOpen = document.querySelector("[data-history-export-open]");
const historyExportModal = document.querySelector("[data-history-export-modal]");
const historyExportClose = document.querySelector("[data-history-export-close]");
const historyExportMessage = document.querySelector("[data-history-export-message]");
const historyExportFormatButtons = document.querySelectorAll("[data-history-export-format]");
const assetsSearch = document.querySelector("[data-assets-search]");
const assetsSort = document.querySelector("[data-assets-sort]");
const assetsFilterButtons = document.querySelectorAll("[data-assets-filter]");
const assetsCards = document.querySelector("[data-assets-cards]");
const assetsState = document.querySelector("[data-assets-state]");
const loansSearch = document.querySelector("[data-loans-search]");
const loansRoleFilter = document.querySelector("[data-loans-role-filter]");
const loansStatusButtons = document.querySelectorAll("[data-loans-status-filter]");
const loansRefreshButton = document.querySelector("[data-loans-refresh]");
const loansCreateButton = document.querySelector("[data-loans-create]");
const loansList = document.querySelector("[data-loans-list]");
const loansState = document.querySelector("[data-loans-state]");
const loanDetailGrid = document.querySelector("[data-loan-detail-grid]");
const loanPaymentHistory = document.querySelector("[data-loan-payment-history]");
const loanCopyContractButton = document.querySelector("[data-loan-copy-contract]");
const loanMakePaymentButton = document.querySelector("[data-loan-make-payment]");
const loanClaimCollateralButton = document.querySelector("[data-loan-claim-collateral]");
const networkInfoHeight = document.querySelector("[data-network-info-height]");
const networkInfoNetwork = document.querySelector("[data-network-info-network]");
const networkInfoDifficulty = document.querySelector("[data-network-info-difficulty]");
const networkInfoMempool = document.querySelector("[data-network-info-mempool]");
const networkInfoChainTx = document.querySelector("[data-network-info-chain-tx]");
const nodeInfoNode = document.querySelector("[data-node-info-node]");
const nodeInfoHeight = document.querySelector("[data-node-info-height]");
const nodeInfoTime = document.querySelector("[data-node-info-time]");
const nodeInfoLastChecked = document.querySelector("[data-node-info-last-checked]");
const nodeInfoLargestFee = document.querySelector("[data-node-info-largest-fee]");
const paymentAssetSelect = document.querySelector("[data-payment-asset]");
const paymentAmountInput = document.querySelector("[data-payment-amount]");
const paymentLabelInput = document.querySelector("[data-payment-label]");
const paymentUriInput = document.querySelector("[data-payment-uri]");
const topbarPaymentUriForm = document.querySelector("[data-payment-uri-entry]");
const topbarPaymentUriInput = document.querySelector("[data-topbar-payment-uri]");
const paymentQrCanvas = document.querySelector("[data-payment-qr]");
const paymentQrLabel = document.querySelector("[data-payment-qr-label]");
const copyPaymentQrButton = document.querySelector("[data-copy-payment-qr]");
const copyPaymentUriButton = document.querySelector("[data-copy-payment-uri]");
const paymentMessage = document.querySelector("[data-payment-message]");
const receiveRegistration = document.querySelector("[data-receive-registration]");
const addressBookRows = document.querySelector("[data-address-book-rows]");
const addressBookCards = document.querySelector("[data-address-book-cards]");
const addressBookSearch = document.querySelector("[data-address-book-search]");
const addressBookSort = document.querySelector("[data-address-book-sort]");
const addressBookState = document.querySelector("[data-address-book-state]");
const addressBookRefreshVanity = document.querySelector("[data-address-book-refresh-vanity]");
const settingsSaveButton = document.querySelector("[data-settings-save]");
const settingsMessage = document.querySelector("[data-settings-message]");
const settingsWalletPath = document.querySelector("[data-settings-wallet-path]");
const settingsDefaultNode = document.querySelector("[data-settings-default-node]");
const settingsSavedNodes = document.querySelector("[data-settings-saved-nodes]");
const settingsUnbroadcastPath = document.querySelector("[data-settings-unbroadcast-path]");
const settingsAddressBookPath = document.querySelector("[data-settings-address-book-path]");
const settingsIpfsGateway = document.querySelector("[data-settings-ipfs-gateway]");
const settingsAutoLockTimeout = document.querySelector("[data-settings-auto-lock-timeout]");
const settingsClipboardTimeout = document.querySelector("[data-settings-clipboard-timeout]");
const settingsRequireKey = document.querySelector("[data-settings-require-key]");
const settingsBrowseButtons = document.querySelectorAll("[data-settings-browse]");
const settingsTestNodeButton = document.querySelector("[data-settings-test-node]");
const settingsNodeTestStatus = document.querySelector("[data-settings-node-test-status]");
const settingsCacheOpenButton = document.querySelector("[data-settings-cache-open]");
const cacheClearModal = document.querySelector("[data-cache-clear-modal]");
const cacheClearCloseButton = document.querySelector("[data-cache-clear-close]");
const cacheClearMessage = document.querySelector("[data-cache-clear-message]");
const nftsList = document.querySelector("[data-nfts-list]");
const nftsState = document.querySelector("[data-nfts-state]");
const nftsRefreshButton = document.querySelector("[data-nfts-refresh]");
const nftsPagination = document.querySelector("[data-nfts-pagination]");
const nftsPageInput = document.querySelector("[data-nfts-page-input]");
const nftsPageTotal = document.querySelector("[data-nfts-page-total]");
const nftDetailTitle = document.querySelector("[data-nft-detail-title]");
const nftDetailSubtitle = document.querySelector("[data-nft-detail-subtitle]");
const nftDetailName = document.querySelector("[data-nft-detail-name]");
const nftDetailAsset = document.querySelector("[data-nft-detail-asset]");
const nftDetailCreator = document.querySelector("[data-nft-detail-creator]");
const nftDetailGenesis = document.querySelector("[data-nft-detail-genesis]");
const nftDetailMetadata = document.querySelector("[data-nft-detail-metadata]");
const nftMetadataSummary = document.querySelector("[data-nft-metadata-summary]");
const nftHistory = document.querySelector("[data-nft-history]");
let activeNetwork = "testnet";
let currentView = "dashboard";
let walletLoaded = false;
let registrationRegistered = false;
let registrationCheckInFlight = false;
let registrationRequestId = 0;
let dashboardRegisteredLoadId = 0;
let balanceRefreshTimer = null;
let balanceRefreshInFlight = false;
let balanceRefreshRequestId = 0;
let dashboardLastBalanceStatus = "";
let activeWalletBalances = [];
let activeUnbroadcastTransaction = null;
let unbroadcastRefreshInFlight = false;
let unbroadcastReviewInFlight = false;
let networkInfoRefreshTimer = null;
let nodeInfoRefreshTimer = null;
let networkInfoRefreshInFlight = false;
let nodeInfoRefreshInFlight = false;
let recentTransactionsCacheReadId = 0;
let latestTransactionsSyncId = 0;
let latestTransactionsSyncInFlight = false;
let latestDashboardTransactionsTimer = null;
let latestDashboardTxids = [];
let latestDashboardTransactionRecords = new Map();
let backgroundTransactionSyncId = 0;
let historyRefreshTimer = null;
let historyRefreshReadId = 0;
let historyRefreshInFlight = false;
let historyRefreshPending = false;
let historyCurrentPage = 1;
let historyLastRenderSignature = "";
let historySearchDebounceTimer = null;
let transactionCacheMutationChain = Promise.resolve();
let guiRuntimeSettings = {
auto_lock_timeout: "15 minutes",
clipboard_clear_timeout: "60 seconds",
require_key_before_signing: true
};
let autoLockTimer = null;
let clipboardClearTimer = null;
let lastClipboardText = "";
let activeWalletAddressHex = "";
let activeWalletAddressShort = "";
let latestKnownBlockHeight = 0;
const BALANCE_REFRESH_MS = 120000;
const NETWORK_INFO_REFRESH_MS = 60000;
const NODE_INFO_REFRESH_MS = 15000;
const DASHBOARD_RECENT_LIMIT = 5;
const BACKGROUND_HISTORY_PAGE_SIZE = 100;
const BACKGROUND_SYNC_IDLE_MS = 15000;
const DASHBOARD_LATEST_REFRESH_MS = 5000;
const DASHBOARD_INITIAL_TX_FETCH_DELAY_MS = 500;
const DASHBOARD_REFRESH_TX_FETCH_DELAY_MS = 2000;
const BACKGROUND_TX_FETCH_DELAY_MS = 2000;
const HISTORY_REFRESH_MS = 5000;
const HISTORY_PAGE_SIZE = 100;
const WALLET_RPC_RETRY_LIMIT = 3;
const WALLET_RPC_BACKOFF_BASE_MS = 1500;
const WALLET_RPC_BACKOFF_MAX_MS = 15000;
const CONFIRMED_AFTER_BLOCKS = 20;
const CHAIN_STATUS_PENDING = "pending";
const CHAIN_STATUS_FINALIZED = "finalized";
const startupTitle = document.querySelector(".startup-copy h1");
const startupDescription = document.querySelector(".startup-copy p");
const startModeButtons = document.querySelectorAll("[data-start-mode]");
const topWalletListSelect = document.querySelector("[data-top-wallet-list]");
const walletSwitchModal = document.querySelector("[data-wallet-switch-modal]");
const walletSwitchForm = document.querySelector("[data-wallet-switch-form]");
const walletSwitchName = document.querySelector("[data-wallet-switch-name]");
const walletSwitchKeyInput = document.querySelector("[data-wallet-switch-key]");
const walletSwitchSubmit = document.querySelector("[data-wallet-switch-submit]");
const walletSwitchMessage = document.querySelector("[data-wallet-switch-message]");
const walletSwitchCloseButton = document.querySelector("[data-wallet-switch-close]");
const walletSwitchCancelButton = document.querySelector("[data-wallet-switch-cancel]");
const backupKeyModal = document.querySelector("[data-backup-key-modal]");
const backupKeyForm = document.querySelector("[data-backup-key-form]");
const backupKeyTitle = document.querySelector("[data-backup-key-title]");
const backupKeyCopy = document.querySelector("[data-backup-key-copy]");
const backupKeyInput = document.querySelector("[data-backup-key-input]");
const backupKeyMessage = document.querySelector("[data-backup-key-message]");
const backupKeyCloseButton = document.querySelector("[data-backup-key-close]");
const backupKeyCancelButton = document.querySelector("[data-backup-key-cancel]");
const backupKeySubmitButton = document.querySelector("[data-backup-key-submit]");
let activeWalletPath = "";
let pendingWalletSwitch = null;
let pendingBackupKeyRequest = null;
const startupCopy = {
load: ["Open Wallet", "Select a wallet file already on this device."],
create: ["Create New Wallet", "Generate a new encrypted wallet file."],
"import-key": ["Import Private Key", "Restore from a private key value."],
"import-image": ["Import Private Key Image", "Restore from a base64 or image-based key backup."]
};
function setActiveStartMode(mode) {
startModeButtons.forEach((button) => {
button.classList.toggle("primary-start", button.dataset.startMode === mode);
});
const [title, description] = startupCopy[mode] || startupCopy.load;
if (startupTitle) {
startupTitle.textContent = title;
}
if (startupDescription) {
startupDescription.textContent = description;
}
}
function activeBaseCoin() {
return activeNetwork === "mainnet" ? "CLC" : "CLTC";
}
function shortenAddress(address) {
if (!address || address.length <= 17) {
return address || "No wallet";
}
const suffix = address.includes(".") ? address.slice(address.lastIndexOf(".") - 4) : address.slice(-8);
return `${address.slice(0, 4)}...${suffix}`;
}
function privateKeyImageSrc(privateKeyImage) {
const image = (privateKeyImage || "").trim();
if (!image) {
return "";
}
return image.startsWith("data:") ? image : `data:image/png;base64,${image}`;
}
function setWalletImageMarks(privateKeyImage) {
const imageSrc = privateKeyImageSrc(privateKeyImage);
document.querySelectorAll("[data-wallet-image-mark]").forEach((item) => {
if (!imageSrc) {
item.classList.remove("wallet-image");
item.style.backgroundImage = "";
item.textContent = "C";
return;
}
item.classList.add("wallet-image");
item.style.backgroundImage = `url("${imageSrc}")`;
item.textContent = "";
});
}
function formatAtomicBalance(value) {
const atomic = BigInt(value || "0");
const whole = atomic / 100000000n;
const fractionalRaw = atomic % 100000000n;
const fractional = fractionalRaw.toString().padStart(8, "0").replace(/0+$/, "");
return fractional ? `${whole}.${fractional}` : `${whole}`;
}
function formatNumber(value) {
return Number(value || 0).toLocaleString();
}
function formatNetworkTime(timestamp) {
const numeric = Number(timestamp || 0);
if (!numeric) {
return "--";
}
return new Date(numeric * 1000).toLocaleString();
}
function formatLargestFee(value) {
const formatted = formatAtomicBalance(value);
return `${formatted} ${activeBaseCoin()}`;
}
function networkDisplayName(info) {
const network = String(info?.network || activeNetwork || "").trim();
const prefix = String(info?.wallet_prefix || activeBaseCoin()).trim();
const label = network ? network.charAt(0).toUpperCase() + network.slice(1) : "Network";
return prefix ? `${label} / ${prefix}` : label;
}
async function populateTopWalletList() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !topWalletListSelect) {
return;
}
try {
const wallets = await invoke("list_wallets", { network: activeNetwork });
topWalletListSelect.innerHTML = "";
if (!wallets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No wallets found";
topWalletListSelect.appendChild(option);
return;
}
wallets.forEach((wallet) => {
const option = document.createElement("option");
option.value = wallet.wallet_path;
option.textContent = wallet.wallet_name;
if (wallet.wallet_path === activeWalletPath) {
option.selected = true;
}
topWalletListSelect.appendChild(option);
});
} catch (error) {
console.error(error);
}
}
function setBroadcastNodeOptions(nodes) {
if (!broadcastNodeButton) {
return;
}
const previous = activeBroadcastNode();
const normalized = Array.isArray(nodes) ? nodes.filter((node) => node?.address) : [];
broadcastNodeButton.innerHTML = "";
if (!normalized.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No broadcast nodes";
broadcastNodeButton.appendChild(option);
return;
}
normalized.forEach((node) => {
const option = document.createElement("option");
option.value = node.address;
option.textContent = node.address;
option.title = node.label ? `${node.label}: ${node.address}` : node.address;
broadcastNodeButton.appendChild(option);
});
const selected = normalized.some((node) => node.address === previous)
? previous
: normalized[0].address;
broadcastNodeButton.value = selected;
}
async function populateBroadcastNodeList() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !broadcastNodeButton) {
return;
}
try {
const nodes = await invoke("list_broadcast_nodes");
setBroadcastNodeOptions(nodes);
} catch (error) {
console.error(error);
setBroadcastNodeOptions([]);
}
}
function assetInitial(asset) {
return (asset || "?").trim().slice(0, 1).toUpperCase() || "?";
}
function balanceAssetLabel(balance) {
if (balance.nft_series && balance.nft_series > 0) {
return `${balance.asset} #${balance.nft_series}`;
}
return balance.asset;
}
let knownNftBalanceKeys = new Set();
let knownNftBalanceNode = "";
let knownNftBalanceLoadedAt = 0;
let knownNftBalanceLoad = null;
const DASHBOARD_NFT_BALANCE_CACHE_MS = 300_000;
function balanceRegistryKey(balance) {
const asset = String(balance?.asset || "").trim().toLowerCase();
const series = Number(balance?.nft_series || 0);
return `${asset}|${series}`;
}
function isDashboardNftBalance(balance) {
if (Number(balance?.nft_series || 0) > 0) {
return true;
}
return knownNftBalanceKeys.has(balanceRegistryKey(balance));
}
function dashboardNftBalanceKeysReady() {
const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : "";
return (
Boolean(broadcastNode) &&
knownNftBalanceNode === broadcastNode
);
}
function dashboardNftBalanceKeysFresh() {
const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : "";
return (
dashboardNftBalanceKeysReady() &&
Date.now() - knownNftBalanceLoadedAt < DASHBOARD_NFT_BALANCE_CACHE_MS
);
}
function invalidateDashboardNftBalanceKeys() {
knownNftBalanceNode = "";
knownNftBalanceLoadedAt = 0;
}
async function refreshDashboardNftBalanceKeys() {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : "";
if (!invoke || !broadcastNode) {
return false;
}
if (dashboardNftBalanceKeysFresh()) {
return false;
}
if (knownNftBalanceLoad) {
return knownNftBalanceLoad;
}
knownNftBalanceLoad = invoke("nft_list", { broadcastNode })
.then((rows) => {
const nextKeys = new Set();
(Array.isArray(rows) ? rows : []).forEach((nft) => {
const name = String(nft?.nft_name || "").trim().toLowerCase();
if (!name) {
return;
}
nextKeys.add(`${name}|${Number(nft?.series || 0)}`);
});
const previous = Array.from(knownNftBalanceKeys).sort().join(",");
const next = Array.from(nextKeys).sort().join(",");
knownNftBalanceKeys = nextKeys;
knownNftBalanceNode = broadcastNode;
knownNftBalanceLoadedAt = Date.now();
return previous !== next;
})
.catch(() => false)
.finally(() => {
knownNftBalanceLoad = null;
});
return knownNftBalanceLoad;
}
function setBalanceStatus(message, type = "") {
if (!balanceStatus) {
return;
}
balanceStatus.textContent = message;
balanceStatus.classList.toggle("error", type === "error");
balanceStatus.classList.toggle("success", type === "success");
}
function setBridgeStatus(message, type = "") {
if (!bridgeStatus) {
return;
}
bridgeStatus.textContent = message;
bridgeStatus.classList.toggle("error", type === "error");
bridgeStatus.classList.toggle("success", type === "success");
}
function renderBalances(balances) {
const normalized = Array.isArray(balances) ? balances : [];
activeWalletBalances = normalized;
if (typeof refreshTransferAssetOptions === "function") {
refreshTransferAssetOptions();
}
if (typeof refreshActiveTransactionFormAfterWalletData === "function") {
refreshActiveTransactionFormAfterWalletData();
}
const baseCoin = activeBaseCoin();
const baseBalance = normalized.find((balance) => {
return (balance.asset || "").trim().toUpperCase() === baseCoin && !balance.nft_series;
});
const nftKeysReady = dashboardNftBalanceKeysReady();
const tokenRows = normalized
.filter((balance) => {
const isBaseCoin = (balance.asset || "").trim().toUpperCase() === baseCoin && !balance.nft_series;
return !isBaseCoin && !isDashboardNftBalance(balance);
})
.sort((left, right) => {
const leftBalance = BigInt(left.balance || "0");
const rightBalance = BigInt(right.balance || "0");
if (rightBalance !== leftBalance) {
return rightBalance > leftBalance ? 1 : -1;
}
return balanceAssetLabel(left).localeCompare(balanceAssetLabel(right));
});
if (baseBalanceLabel) {
baseBalanceLabel.textContent = `${baseCoin} Balance`;
}
if (clcBalance) {
clcBalance.textContent = baseBalance ? `${formatAtomicBalance(baseBalance.balance)} ${baseCoin}` : `0 ${baseCoin}`;
}
if (!tokenBalances) {
return;
}
const rerenderAfterNftKeyRefresh = () => {
refreshDashboardNftBalanceKeys().then((changed) => {
if (changed && Array.isArray(activeWalletBalances)) {
renderBalances(activeWalletBalances);
}
});
};
tokenBalances.innerHTML = "";
const title = document.createElement("div");
title.className = "token-title";
title.textContent = nftKeysReady ? `Token Balances (${tokenRows.length})` : "Token Balances";
tokenBalances.appendChild(title);
if (!nftKeysReady) {
const pending = document.createElement("div");
pending.className = "empty-balance";
pending.textContent = "Loading token balances...";
tokenBalances.appendChild(pending);
rerenderAfterNftKeyRefresh();
return;
}
if (!tokenRows.length) {
const empty = document.createElement("div");
empty.className = "empty-balance";
empty.textContent = "No token balances found.";
tokenBalances.appendChild(empty);
rerenderAfterNftKeyRefresh();
return;
}
tokenRows.slice(0, 4).forEach((balance) => {
const row = document.createElement("div");
row.className = "token-row";
const mark = document.createElement("span");
mark.className = "asset-mark mini";
mark.textContent = assetInitial(balance.asset);
const asset = document.createElement("span");
asset.textContent = balanceAssetLabel(balance);
const amount = document.createElement("b");
amount.textContent = formatAtomicBalance(balance.balance);
row.append(mark, asset, amount);
tokenBalances.appendChild(row);
});
rerenderAfterNftKeyRefresh();
if (currentView === "assets" && typeof renderAssetsPage === "function") {
renderAssetsPage();
}
}
function resetTransactionForms() {
[
"resetTransferSession",
"resetCreateTokenSession",
"resetIssueTokenSession",
"resetCreateNftSession",
"resetSwapOfferSession",
"resetLoanOfferSession",
"resetLoanPaymentSession",
"resetCollateralClaimSession",
"resetBurnSession",
"resetMarketingSession",
"resetVanityAddressSession"
].forEach((name) => {
if (typeof window[name] === "function") {
window[name]();
}
});
[
"clearTransferForm",
"clearCreateTokenForm",
"clearIssueTokenForm",
"clearCreateNftForm",
"clearSwapOfferForm",
"clearLoanOfferForm",
"clearLoanPaymentForm",
"clearCollateralClaimForm",
"clearBurnForm",
"clearMarketingForm",
"clearVanityForm"
].forEach((name) => {
if (typeof window[name] === "function") {
window[name]();
}
});
[
transferMessage,
createTokenMessage,
issueTokenMessage,
createNftMessage,
swapOfferMessage,
loanOfferMessage,
loanPaymentMessage,
collateralClaimMessage,
burnMessage,
marketingMessage,
vanityMessage
].forEach((element) => {
if (!element) {
return;
}
element.textContent = "";
element.classList.add("hidden");
element.classList.remove("error", "success");
});
loanSavedPathPanel?.classList.add("hidden");
currentTransactionType = "";
}
function setRecentTransactionsState(message, mode = "") {
if (!recentTransactionsState) {
return;
}
recentTransactionsState.textContent = message;
recentTransactionsState.classList.toggle("hidden", !message);
recentTransactionsState.classList.toggle("loading", mode === "loading");
}
function clearRecentTransactionsRows() {
if (recentTransactionsBody) {
recentTransactionsBody.innerHTML = "";
}
}

256
frontend/js/assets_page.js Normal file
View File

@ -0,0 +1,256 @@
let assetsCatalog = [];
let assetsLoadedForNode = "";
let assetsLoadInFlight = false;
let assetsRefreshId = 0;
let activeAssetFilter = "all";
let assetsEmptyStateMessage = "";
function invalidateAssetsCatalog() {
assetsLoadedForNode = "";
}
function assetCatalogBalance(asset) {
const ticker = String(asset || "").trim().toLowerCase();
const match = activeWalletBalances.find((balance) => {
return (
String(balance.asset || "").trim().toLowerCase() === ticker &&
Number(balance.nft_series || 0) === 0
);
});
return match ? String(match.balance || "0") : "0";
}
function formatAssetSupply(value) {
return formatAtomicBalance(value || 0);
}
function assetCreatorLabel(asset, isCreator) {
const creator = String(asset.creator || "").trim();
if (isCreator) {
return "You";
}
return creator ? shortenAddress(creator) : "Unknown";
}
function formatAssetDate(timestamp) {
const numeric = Number(timestamp || 0);
if (!numeric) {
return "--";
}
return new Date(numeric * 1000).toLocaleDateString();
}
function escapeAssetText(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function compareAssetBalance(left, right) {
const leftBalance = BigInt(assetCatalogBalance(left.ticker));
const rightBalance = BigInt(assetCatalogBalance(right.ticker));
if (rightBalance !== leftBalance) {
return rightBalance > leftBalance ? 1 : -1;
}
return String(left.ticker || "").localeCompare(String(right.ticker || ""));
}
function sortedAssetCards() {
const query = String(assetsSearch?.value || "").trim().toLowerCase();
const sortMode = assetsSort?.value || "default";
const activeShortAddress = String(activeWalletAddressShort || "").toLowerCase();
const sourceRows = assetsCatalog;
let rows = sourceRows.filter((asset) => {
const ticker = String(asset.ticker || "").trim();
if (!ticker) {
return false;
}
const balance = BigInt(assetCatalogBalance(ticker));
const creator = String(asset.creator || "").toLowerCase();
const searchText = `${ticker} ${asset.origin_txid || ""} ${asset.creator || ""}`.toLowerCase();
if (activeAssetFilter === "holding" && balance <= 0n) {
return false;
}
if (activeAssetFilter === "created" && creator !== activeShortAddress) {
return false;
}
return !query || searchText.includes(query);
});
if (sortMode === "balance" || sortMode === "default") {
rows.sort(compareAssetBalance);
if (sortMode === "default") {
rows.sort((left, right) => {
const leftHolding = BigInt(assetCatalogBalance(left.ticker)) > 0n ? 0 : 1;
const rightHolding = BigInt(assetCatalogBalance(right.ticker)) > 0n ? 0 : 1;
return leftHolding - rightHolding || compareAssetBalance(left, right);
});
}
} else if (sortMode === "created") {
rows.sort((left, right) => Number(right.created_timestamp || 0) - Number(left.created_timestamp || 0));
} else {
rows.sort((left, right) => String(left.ticker || "").localeCompare(String(right.ticker || "")));
}
return rows;
}
function renderAssetCard(asset) {
const ticker = String(asset.ticker || "").trim();
const balance = assetCatalogBalance(ticker);
const isHolding = BigInt(balance || "0") > 0n;
const isCreator = String(asset.creator || "").toLowerCase() === String(activeWalletAddressShort || "").toLowerCase();
const policy = asset.hard_limit ? "Fixed Supply" : "Issuable";
const escapedTicker = escapeAssetText(ticker);
const escapedPolicy = escapeAssetText(policy);
const creatorLabel = assetCreatorLabel(asset, isCreator);
return `
<article class="portfolio-card">
<div class="portfolio-top">
<div class="asset-mark">${escapeAssetText(assetInitial(ticker))}</div>
<div>
<strong>${escapedTicker}</strong>
<span>${escapedPolicy}</span>
</div>
</div>
<b>${escapeAssetText(formatAtomicBalance(balance))} ${escapedTicker}</b>
<div class="asset-card-tags">
${isHolding ? '<span class="asset-card-tag holding">Holding</span>' : ""}
${isCreator ? '<span class="asset-card-tag created">Created by me</span>' : ""}
<span class="asset-card-tag ${asset.hard_limit ? "fixed" : ""}">${escapedPolicy}</span>
</div>
<div class="portfolio-meta">
<div><span>Current Supply</span><strong>${escapeAssetText(formatAssetSupply(asset.current_supply))}</strong></div>
<div><span>Total Burned</span><strong>${escapeAssetText(formatAssetSupply(asset.total_burned))}</strong></div>
<div><span>Holders</span><strong>${escapeAssetText(formatNumber(asset.holder_count))}</strong></div>
<div><span>Created</span><strong>${escapeAssetText(formatAssetDate(asset.created_timestamp))}</strong></div>
<div><span>Initial Supply</span><strong>${escapeAssetText(formatAssetSupply(asset.initial_supply))}</strong></div>
<div><span>Creator</span><strong>${escapeAssetText(creatorLabel)}</strong></div>
</div>
<code class="asset-origin">${escapeAssetText(asset.origin_txid || "")}</code>
</article>
`;
}
function setAssetsState(message) {
if (assetsState) {
assetsState.textContent = message || "";
}
}
function renderAssetsPage() {
if (!assetsCards) {
return;
}
const rows = sortedAssetCards();
assetsCards.innerHTML = rows.map(renderAssetCard).join("");
if (!walletLoaded) {
setAssetsState("Load a wallet to view assets.");
} else if (assetsLoadInFlight && !assetsCatalog.length) {
setAssetsState("Loading assets...");
} else if (!assetsCatalog.length) {
setAssetsState(assetsEmptyStateMessage || "Token catalog returned no rows.");
} else if (!rows.length) {
setAssetsState("No assets match the current filter.");
} else {
setAssetsState(`${rows.length} token${rows.length === 1 ? "" : "s"} shown`);
}
}
async function loadAssetsCatalog({ force = false } = {}) {
const invoke = window.__TAURI__?.core?.invoke;
const initialBroadcastNode = activeBroadcastNode();
if (!walletLoaded) {
assetsCatalog = [];
assetsEmptyStateMessage = "Asset catalog skipped: no wallet is loaded.";
renderAssetsPage();
return;
}
if (!invoke) {
assetsCatalog = [];
assetsEmptyStateMessage = "Asset catalog skipped: Tauri invoke is unavailable.";
renderAssetsPage();
return;
}
if (!initialBroadcastNode) {
assetsCatalog = [];
assetsEmptyStateMessage = "Asset catalog skipped: no broadcast node is selected.";
renderAssetsPage();
return;
}
const broadcastNode = initialBroadcastNode;
if (!force && assetsCatalog.length && assetsLoadedForNode === broadcastNode) {
renderAssetsPage();
return;
}
if (assetsLoadInFlight) {
return;
}
const refreshId = ++assetsRefreshId;
assetsLoadInFlight = true;
assetsEmptyStateMessage = "Calling token catalog RPC command 48...";
renderAssetsPage();
try {
const catalog = await invokeWalletRpcWithBackoff(
"asset catalog",
() => invoke("token_catalog", { broadcastNode }),
() => refreshId === assetsRefreshId && activeBroadcastNode() === broadcastNode
);
if (refreshId !== assetsRefreshId || activeBroadcastNode() !== broadcastNode) {
return;
}
assetsCatalog = Array.isArray(catalog) ? catalog : [];
assetsEmptyStateMessage = assetsCatalog.length
? ""
: "Token catalog command 48 returned 0 rows from the selected node.";
assetsLoadedForNode = broadcastNode;
} catch (error) {
if (refreshId === assetsRefreshId) {
assetsCatalog = [];
assetsEmptyStateMessage = `Token catalog command 48 failed: ${String(error)}`;
setAssetsState(String(error));
}
} finally {
if (refreshId === assetsRefreshId) {
assetsLoadInFlight = false;
renderAssetsPage();
}
}
}
function prepareAssetsPage() {
renderAssetsPage();
loadAssetsCatalog({ force: true });
}
function resetAssetsPage() {
assetsRefreshId += 1;
assetsCatalog = [];
assetsEmptyStateMessage = "";
assetsLoadedForNode = "";
assetsLoadInFlight = false;
renderAssetsPage();
}
assetsSearch?.addEventListener("input", renderAssetsPage);
assetsSort?.addEventListener("change", renderAssetsPage);
assetsFilterButtons.forEach((button) => {
button.addEventListener("click", () => {
activeAssetFilter = button.dataset.assetsFilter || "all";
assetsFilterButtons.forEach((item) => {
item.classList.toggle("active", item === button);
});
renderAssetsPage();
});
});

273
frontend/js/burn_form.js Normal file
View File

@ -0,0 +1,273 @@
const BURN_FEE_ATOMIC = 10000n;
let burnBalancesRefreshPromise = null;
function setBurnMessage(message, type = "") {
setScopedTransactionMessage("Burn", burnMessage, message, type);
}
function burnableBalances() {
return (Array.isArray(activeWalletBalances) ? activeWalletBalances : [])
.filter((balance) => {
const asset = String(balance.asset || "").trim();
return asset
&& !isBaseTransferAsset(asset)
&& balanceAtomicValue(balance) > 0n;
});
}
function burnAssetOptionLabel(balance) {
const amount = formatAtomicBalance(balance.balance || "0");
const series = Number(balance.nft_series || 0);
if (series > 0) {
return `${balance.asset} #${series} (${amount})`;
}
return `${balance.asset} (${amount})`;
}
function selectedBurnAsset() {
const option = burnAssetSelect?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
nftSeries: Number(option.dataset.nftSeries || 0),
balance: BigInt(option.dataset.balance || "0")
};
}
function populateBurnAssets() {
if (!burnAssetSelect) {
return;
}
const previous = burnAssetSelect.value;
burnAssetSelect.innerHTML = "";
const balances = burnableBalances();
if (balances.length) {
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select an asset";
burnAssetSelect.appendChild(placeholder);
}
balances.forEach((balance) => {
const option = document.createElement("option");
option.value = transferAssetKey(balance);
option.dataset.asset = balance.asset || "";
option.dataset.nftSeries = String(balance.nft_series || 0);
option.dataset.balance = String(balance.balance || "0");
option.textContent = burnAssetOptionLabel(balance);
burnAssetSelect.appendChild(option);
});
if (!balances.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No burnable assets";
burnAssetSelect.appendChild(option);
} else if ([...burnAssetSelect.options].some((option) => option.value === previous)) {
burnAssetSelect.value = previous;
}
updateBurnDerivedFields();
}
async function ensureBurnBalances() {
if (activeWalletBalances.length || !walletLoaded || !registrationRegistered) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
return;
}
burnBalancesRefreshPromise ||= invoke("get_active_wallet_balances", { broadcastNode })
.then((balances) => {
activeWalletBalances = Array.isArray(balances) ? balances : [];
})
.finally(() => {
burnBalancesRefreshPromise = null;
});
await burnBalancesRefreshPromise;
}
function updateBurnDerivedFields() {
const selected = selectedBurnAsset();
if (burnFeeInput) {
burnFeeInput.value = atomicToDecimal(BURN_FEE_ATOMIC);
}
if (burnFeeLabel) {
burnFeeLabel.textContent = `${atomicToDecimal(BURN_FEE_ATOMIC)} ${activeBaseCoin()}`;
}
if (burnAddress) {
burnAddress.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (!selected) {
if (burnAvailable) {
burnAvailable.textContent = "--";
}
if (burnType) {
burnType.textContent = "--";
}
if (burnAmountInput) {
burnAmountInput.disabled = false;
burnAmountInput.value = "";
}
return;
}
const isNft = selected.nftSeries > 0;
if (burnAvailable) {
burnAvailable.textContent = `${atomicToDecimal(selected.balance)} ${selected.asset}`;
}
if (burnType) {
burnType.textContent = isNft ? `NFT #${selected.nftSeries}` : "Token";
}
if (burnAmountInput) {
burnAmountInput.disabled = isNft;
if (isNft) {
burnAmountInput.value = "1.00000000";
}
}
}
function validateBurnForm() {
if (!walletLoaded) {
return "Load a wallet before burning an asset.";
}
if (!registrationRegistered) {
return "Address registration is required before burning an asset.";
}
const selected = selectedBurnAsset();
if (!selected) {
return "Select an asset to burn.";
}
const amount = decimalToAtomic(burnAmountInput?.value);
if (amount === null || amount <= 0n) {
return "Enter a valid burn amount.";
}
if (selected.nftSeries > 0 && amount !== ATOMIC_UNITS_PER_COIN) {
return "NFT burns must destroy exactly 1.00000000 units.";
}
if (amount > selected.balance) {
return "Burn amount cannot exceed the selected asset balance.";
}
if (baseCoinBalanceAtomic() < BURN_FEE_ATOMIC) {
return `Burning requires ${atomicToDecimal(BURN_FEE_ATOMIC)} ${activeBaseCoin()} for the transaction fee.`;
}
return "";
}
function burnPayload() {
const selected = selectedBurnAsset();
return {
coin: selected.asset,
nftSeries: selected.nftSeries,
value: String(decimalToAtomic(burnAmountInput.value)),
txfee: String(BURN_FEE_ATOMIC)
};
}
function clearBurnForm() {
if (burnAssetSelect) {
burnAssetSelect.value = "";
}
if (burnAmountInput) {
burnAmountInput.value = "";
burnAmountInput.disabled = false;
}
updateBurnDerivedFields();
}
function setBurnButtonsDisabled(disabled) {
document.querySelector("[data-burn-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-burn-broadcast]")?.toggleAttribute("disabled", disabled);
}
async function handleBurnAction(action) {
try {
await ensureBurnBalances();
populateBurnAssets();
} catch (error) {
setBurnMessage(String(error), "error");
return;
}
const validationError = validateBurnForm();
if (validationError) {
setBurnMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setBurnMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setBurnMessage("Signing cancelled.");
return;
}
setBurnButtonsDisabled(true);
try {
if (action === "save") {
setBurnMessage("Saving burn transaction...");
const review = await invoke("save_burn_transaction", {
...burnPayload(),
walletKey
});
clearBurnForm();
setBurnMessage(`Burn transaction saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setBurnMessage("Broadcasting burn transaction...");
const result = await invoke("broadcast_burn_transaction", {
...burnPayload(),
broadcastNode,
walletKey
});
if (typeof invalidateAssetsCatalog === "function") {
invalidateAssetsCatalog();
}
activeWalletBalances = [];
clearBurnForm();
setBurnMessage(result.response || "Burn transaction broadcast.", "success");
}
} catch (error) {
setBurnMessage(String(error), "error");
} finally {
setBurnButtonsDisabled(false);
}
}
async function prepareBurnForm() {
setBurnMessage("");
try {
await ensureBurnBalances();
} catch (error) {
setBurnMessage(String(error), "error");
}
populateBurnAssets();
}
function resetBurnSession() {
burnBalancesRefreshPromise = null;
clearBurnForm();
setBurnMessage("");
}
burnAssetSelect?.addEventListener("change", () => {
setBurnMessage("");
updateBurnDerivedFields();
});
burnAmountInput?.addEventListener("input", () => setBurnMessage(""));
document
.querySelector("[data-burn-save]")
?.addEventListener("click", () => handleBurnAction("save"));
document
.querySelector("[data-burn-broadcast]")
?.addEventListener("click", () => handleBurnAction("broadcast"));

View File

@ -0,0 +1,220 @@
const COLLATERAL_CLAIM_FEE_ATOMIC = 300000000n;
let collateralClaimContracts = [];
let collateralClaimLoadPromise = null;
let collateralClaimContractsKey = "";
function setCollateralClaimMessage(message, type = "") {
setScopedTransactionMessage("Collateral Claim", collateralClaimMessage, message, type);
}
function selectedCollateralClaimContract() {
const hash = String(collateralClaimContractSelect?.value || "");
return collateralClaimContracts.find((contract) => contract.contract === hash) || null;
}
function claimAssetLabel(asset, amount) {
return `${atomicToDecimal(BigInt(amount || "0"))} ${String(asset || "").trim()}`.trim();
}
function collateralClaimOptionLabel(contract) {
const role = String(contract.role || "").toLowerCase() === "borrower"
? "reclaim"
: "claim";
return `${role} ${claimAssetLabel(contract.collateral, contract.collateral_amount)} from ${truncateMiddle(contract.contract, 8, 8)}`;
}
function populateCollateralClaimContracts() {
if (!collateralClaimContractSelect) {
return;
}
const previous = collateralClaimContractSelect.value;
collateralClaimContractSelect.innerHTML = "";
if (collateralClaimContracts.length) {
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select claimable collateral";
collateralClaimContractSelect.appendChild(placeholder);
}
collateralClaimContracts.forEach((contract) => {
const option = document.createElement("option");
option.value = contract.contract;
option.textContent = collateralClaimOptionLabel(contract);
collateralClaimContractSelect.appendChild(option);
});
if (!collateralClaimContracts.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No collateral is currently claimable";
collateralClaimContractSelect.appendChild(option);
} else if ([...collateralClaimContractSelect.options].some((option) => option.value === previous)) {
collateralClaimContractSelect.value = previous;
}
updateCollateralClaimDetails();
}
async function loadCollateralClaimContracts(force = false) {
if (collateralClaimLoadPromise) {
return collateralClaimLoadPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
return;
}
const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (!force && collateralClaimContractsKey === key) {
populateCollateralClaimContracts();
return;
}
collateralClaimLoadPromise = (async () => {
setCollateralClaimMessage("Loading claimable collateral...");
const contracts = await invoke("claimable_collateral_contracts", { broadcastNode });
collateralClaimContracts = Array.isArray(contracts) ? contracts : [];
collateralClaimContractsKey = key;
populateCollateralClaimContracts();
setCollateralClaimMessage(
collateralClaimContracts.length ? "" : "No collateral is currently claimable."
);
})().catch((error) => {
collateralClaimContracts = [];
populateCollateralClaimContracts();
setCollateralClaimMessage(String(error), "error");
throw error;
}).finally(() => {
collateralClaimLoadPromise = null;
});
return collateralClaimLoadPromise;
}
function updateCollateralClaimDetails() {
const contract = selectedCollateralClaimContract();
if (!contract) {
collateralClaimRole.textContent = "--";
collateralClaimReason.textContent = "--";
collateralClaimLender.textContent = "--";
collateralClaimBorrower.textContent = "--";
collateralClaimLoan.textContent = "--";
collateralClaimCollateral.textContent = "--";
collateralClaimConfirmed.textContent = "--";
collateralClaimRemaining.textContent = "--";
collateralClaimFeeInput.value = atomicToDecimal(COLLATERAL_CLAIM_FEE_ATOMIC);
return;
}
collateralClaimRole.textContent = contract.role === "borrower" ? "Borrower reclaim" : "Lender claim";
collateralClaimReason.textContent = contract.reason || "Collateral claim is currently allowed.";
collateralClaimLender.textContent = contract.lender;
collateralClaimBorrower.textContent = contract.borrower;
collateralClaimLoan.textContent = claimAssetLabel(contract.loan_asset, contract.loan_amount);
collateralClaimCollateral.textContent = claimAssetLabel(contract.collateral, contract.collateral_amount);
collateralClaimConfirmed.textContent = claimAssetLabel(contract.loan_asset, contract.confirmed_paid);
collateralClaimRemaining.textContent = claimAssetLabel(contract.loan_asset, contract.remaining_balance);
collateralClaimFeeInput.value = atomicToDecimal(BigInt(contract.fee || COLLATERAL_CLAIM_FEE_ATOMIC));
}
function validateCollateralClaimForm() {
if (!walletLoaded) {
return "Load a wallet before claiming collateral.";
}
if (!registrationRegistered) {
return "Address registration is required before claiming collateral.";
}
if (!selectedCollateralClaimContract()) {
return "Select claimable collateral.";
}
if (baseCoinBalanceAtomic() < COLLATERAL_CLAIM_FEE_ATOMIC) {
return `Claiming collateral requires ${atomicToDecimal(COLLATERAL_CLAIM_FEE_ATOMIC)} ${activeBaseCoin()} for the transaction fee.`;
}
return "";
}
function collateralClaimPayload() {
return {
broadcastNode: activeBroadcastNode(),
contractHash: selectedCollateralClaimContract().contract
};
}
function setCollateralClaimButtonsDisabled(disabled) {
document.querySelector("[data-collateral-claim-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-collateral-claim-broadcast]")?.toggleAttribute("disabled", disabled);
}
function clearCollateralClaimForm() {
collateralClaimContractSelect.value = "";
updateCollateralClaimDetails();
}
async function handleCollateralClaimAction(action) {
const validationError = validateCollateralClaimForm();
if (validationError) {
setCollateralClaimMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setCollateralClaimMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setCollateralClaimMessage("Signing cancelled.");
return;
}
setCollateralClaimButtonsDisabled(true);
try {
if (action === "save") {
setCollateralClaimMessage("Saving collateral claim transaction...");
const review = await invoke("save_collateral_claim_transaction", {
...collateralClaimPayload(),
walletKey
});
clearCollateralClaimForm();
setCollateralClaimMessage(`Collateral claim saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
setCollateralClaimMessage("Broadcasting collateral claim...");
const result = await invoke("broadcast_collateral_claim_transaction", {
...collateralClaimPayload(),
walletKey
});
await loadCollateralClaimContracts(true);
clearCollateralClaimForm();
setCollateralClaimMessage(result.response || "Collateral claim broadcast.", "success");
}
} catch (error) {
setCollateralClaimMessage(String(error), "error");
await loadCollateralClaimContracts(true).catch(() => {});
} finally {
setCollateralClaimButtonsDisabled(false);
}
}
function prepareCollateralClaimForm() {
setCollateralClaimMessage("");
loadCollateralClaimContracts(false).catch(() => {});
}
function resetCollateralClaimSession() {
collateralClaimContracts = [];
collateralClaimLoadPromise = null;
collateralClaimContractsKey = "";
collateralClaimContractSelect.innerHTML = "";
updateCollateralClaimDetails();
setCollateralClaimMessage("");
}
collateralClaimContractSelect?.addEventListener("change", () => {
setCollateralClaimMessage("");
updateCollateralClaimDetails();
});
document
.querySelector("[data-collateral-claim-save]")
?.addEventListener("click", () => handleCollateralClaimAction("save"));
document
.querySelector("[data-collateral-claim-broadcast]")
?.addEventListener("click", () => handleCollateralClaimAction("broadcast"));

View File

@ -0,0 +1,248 @@
const CREATE_NFT_FEE_ATOMIC = 50000000n;
let createNftBalancesRefreshPromise = null;
function setCreateNftMessage(message, type = "") {
setScopedTransactionMessage("Create NFT", createNftMessage, message, type);
}
function createNftSeriesValue() {
return Number(createNftSeriesSelect?.value || 0) === 1 ? 1 : 0;
}
function normalizeCreateNftCountInput() {
const isCollection = createNftSeriesValue() === 1;
if (createNftCountInput) {
createNftCountInput.disabled = !isCollection;
createNftCountInput.min = isCollection ? "2" : "1";
if (!isCollection) {
createNftCountInput.value = "1";
} else if (Number(createNftCountInput.value || 0) < 2) {
createNftCountInput.value = "2";
}
}
}
function updateCreateNftDerivedFields({ clampCount = false } = {}) {
const isCollection = createNftSeriesValue() === 1;
if (clampCount) {
normalizeCreateNftCountInput();
} else if (createNftCountInput) {
createNftCountInput.disabled = !isCollection;
createNftCountInput.min = isCollection ? "2" : "1";
}
const rawCount = String(createNftCountInput?.value || "").trim();
const parsedCount = Number.parseInt(rawCount, 10);
const countLabel = Number.isInteger(parsedCount) && parsedCount > 0 ? String(parsedCount) : "--";
if (createNftFeeInput) {
createNftFeeInput.value = tokenAtomicToDecimal(CREATE_NFT_FEE_ATOMIC);
}
if (createNftCreator) {
createNftCreator.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (createNftTypeLabel) {
createNftTypeLabel.textContent = isCollection ? "Collection" : "Standalone NFT";
}
if (createNftCountLabel) {
createNftCountLabel.textContent = countLabel;
}
if (createNftFeeLabel) {
createNftFeeLabel.textContent =
`${tokenAtomicToDecimal(CREATE_NFT_FEE_ATOMIC)} ${activeBaseCoin()}`;
}
}
function validateCreateNftForm() {
if (!walletLoaded) {
return "Load a wallet before creating an NFT.";
}
if (!registrationRegistered) {
return "Address registration is required before creating an NFT.";
}
const name = String(createNftNameInput?.value || "").trim();
if (!name) {
return "NFT name is required.";
}
if (!/^[A-Za-z0-9\s]+$/.test(name)) {
return "NFT name can only contain letters, numbers, and spaces.";
}
const canonicalName = name.replace(/\s+/g, "").toLowerCase();
if (canonicalName.length < 3 || canonicalName.length > 15) {
return "NFT name must normalize to 3 to 15 letters or numbers.";
}
if (canonicalName.toUpperCase() === activeBaseCoin()) {
return "NFT name is reserved for the base coin.";
}
const cid = String(createNftIpfsInput?.value || "").trim();
if (!cid) {
return "IPFS CID is required.";
}
if (!/^[\x20-\x7E]+$/.test(cid) || cid.length > 100) {
return "IPFS CID must be an ASCII value no longer than 100 characters.";
}
const description = String(createNftDescriptionInput?.value || "").trim();
if (description && (!/^[\x20-\x7E]+$/.test(description) || description.length > 100)) {
return "NFT description must be ASCII and no longer than 100 characters.";
}
const count = Number.parseInt(createNftCountInput?.value || "0", 10);
if (!Number.isInteger(count) || count < 1 || count > 4294967295) {
return "Enter a valid NFT item count.";
}
if (createNftSeriesValue() === 0 && count !== 1) {
return "A standalone NFT must create exactly 1 item.";
}
if (createNftSeriesValue() === 1 && count < 2) {
return "An NFT collection must contain at least 2 items.";
}
if (baseCoinBalanceAtomic() < CREATE_NFT_FEE_ATOMIC) {
return `This transaction requires ${tokenAtomicToDecimal(CREATE_NFT_FEE_ATOMIC)} ${activeBaseCoin()}.`;
}
return "";
}
function createNftPayload() {
return {
series: createNftSeriesValue(),
nftName: String(createNftNameInput?.value || "").trim(),
itemIpfs: String(createNftIpfsInput?.value || "").trim(),
count: Number.parseInt(createNftCountInput?.value || "1", 10),
description: String(createNftDescriptionInput?.value || "").trim(),
txfee: String(CREATE_NFT_FEE_ATOMIC)
};
}
function clearCreateNftForm() {
if (createNftSeriesSelect) {
createNftSeriesSelect.value = "0";
}
if (createNftNameInput) {
createNftNameInput.value = "";
}
if (createNftIpfsInput) {
createNftIpfsInput.value = "";
}
if (createNftCountInput) {
createNftCountInput.value = "1";
}
if (createNftDescriptionInput) {
createNftDescriptionInput.value = "";
}
updateCreateNftDerivedFields({ clampCount: true });
}
function setCreateNftButtonsDisabled(disabled) {
document.querySelector("[data-create-nft-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-create-nft-broadcast]")?.toggleAttribute("disabled", disabled);
}
async function handleCreateNftAction(action) {
if (!activeWalletBalances.length && walletLoaded && registrationRegistered) {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (invoke && broadcastNode) {
createNftBalancesRefreshPromise ||= invoke("get_active_wallet_balances", { broadcastNode })
.then((balances) => {
activeWalletBalances = Array.isArray(balances) ? balances : [];
})
.finally(() => {
createNftBalancesRefreshPromise = null;
});
try {
await createNftBalancesRefreshPromise;
} catch (error) {
setCreateNftMessage(String(error), "error");
return;
}
}
}
const validationError = validateCreateNftForm();
if (validationError) {
setCreateNftMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setCreateNftMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setCreateNftMessage("Signing cancelled.");
return;
}
setCreateNftButtonsDisabled(true);
try {
if (action === "save") {
setCreateNftMessage("Saving NFT transaction...");
const review = await invoke("save_create_nft_transaction", {
...createNftPayload(),
walletKey
});
clearCreateNftForm();
setCreateNftMessage(`NFT transaction saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setCreateNftMessage("Broadcasting NFT transaction...");
const result = await invoke("broadcast_create_nft_transaction", {
...createNftPayload(),
broadcastNode,
walletKey
});
if (typeof invalidateDashboardNftBalanceKeys === "function") {
invalidateDashboardNftBalanceKeys();
}
clearCreateNftForm();
setCreateNftMessage(result.response || "NFT transaction broadcast.", "success");
}
} catch (error) {
setCreateNftMessage(String(error), "error");
} finally {
setCreateNftButtonsDisabled(false);
}
}
function prepareCreateNftForm() {
updateCreateNftDerivedFields({ clampCount: true });
setCreateNftMessage("");
}
function resetCreateNftSession() {
createNftBalancesRefreshPromise = null;
clearCreateNftForm();
setCreateNftMessage("");
}
createNftSeriesSelect?.addEventListener("change", () => {
updateCreateNftDerivedFields({ clampCount: true });
setCreateNftMessage("");
});
createNftCountInput?.addEventListener("input", () => {
updateCreateNftDerivedFields();
setCreateNftMessage("");
});
createNftCountInput?.addEventListener("blur", () => {
updateCreateNftDerivedFields({ clampCount: true });
});
createNftNameInput?.addEventListener("input", () => setCreateNftMessage(""));
createNftIpfsInput?.addEventListener("input", () => setCreateNftMessage(""));
createNftDescriptionInput?.addEventListener("input", () => setCreateNftMessage(""));
document
.querySelector("[data-create-nft-save]")
?.addEventListener("click", () => handleCreateNftAction("save"));
document
.querySelector("[data-create-nft-broadcast]")
?.addEventListener("click", () => handleCreateNftAction("broadcast"));

View File

@ -0,0 +1,171 @@
const CREATE_TOKEN_FEE_ATOMIC = 50000000000n;
const TOKEN_ATOMIC_UNITS = 100000000n;
function setCreateTokenMessage(message, type = "") {
setScopedTransactionMessage("Create Token", createTokenMessage, message, type);
}
function tokenDecimalToAtomic(value) {
const text = String(value || "").trim();
if (!/^\d+(\.\d{0,8})?$/.test(text)) {
return null;
}
const [whole, fraction = ""] = text.split(".");
const paddedFraction = fraction.padEnd(8, "0");
return BigInt(whole || "0") * TOKEN_ATOMIC_UNITS + BigInt(paddedFraction || "0");
}
function tokenAtomicToDecimal(value) {
const atomic = BigInt(value || 0);
const whole = atomic / TOKEN_ATOMIC_UNITS;
const fraction = String(atomic % TOKEN_ATOMIC_UNITS).padStart(8, "0");
return `${whole}.${fraction}`;
}
function updateCreateTokenDerivedFields() {
if (createTokenFeeInput) {
createTokenFeeInput.value = tokenAtomicToDecimal(CREATE_TOKEN_FEE_ATOMIC);
}
if (createTokenFeeLabel) {
createTokenFeeLabel.textContent = `${tokenAtomicToDecimal(CREATE_TOKEN_FEE_ATOMIC)} ${activeBaseCoin()}`;
}
if (createTokenCap) {
createTokenCap.textContent = createTokenAllowMoreInput?.checked ? "Future issuance allowed" : "Hard capped";
}
if (createTokenCreator) {
createTokenCreator.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
}
function validateCreateTokenForm() {
if (!walletLoaded) {
return "Load a wallet before creating a token.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a token.";
}
const ticker = String(createTokenTickerInput?.value || "").trim();
if (!ticker) {
return "Token ticker is required.";
}
if (!/^[A-Za-z0-9\s]+$/.test(ticker)) {
return "Token ticker can only contain letters, numbers, and spaces.";
}
const canonicalTicker = ticker.replace(/\s+/g, "").toLowerCase();
if (canonicalTicker.length < 3 || canonicalTicker.length > 15) {
return "Token ticker must normalize to 3 to 15 letters or numbers.";
}
if (canonicalTicker.toUpperCase() === activeBaseCoin()) {
return "Token ticker is reserved for the base coin.";
}
const number = tokenDecimalToAtomic(createTokenNumberInput?.value);
if (number === null || number <= 0n) {
return "Enter a valid initial supply with up to 8 decimal places.";
}
return "";
}
function createTokenPayload() {
return {
ticker: String(createTokenTickerInput?.value || "").trim(),
number: String(tokenDecimalToAtomic(createTokenNumberInput?.value) || 0n),
allowMore: Boolean(createTokenAllowMoreInput?.checked),
txfee: String(CREATE_TOKEN_FEE_ATOMIC)
};
}
function clearCreateTokenForm() {
if (createTokenTickerInput) {
createTokenTickerInput.value = "";
}
if (createTokenNumberInput) {
createTokenNumberInput.value = "";
}
if (createTokenAllowMoreInput) {
createTokenAllowMoreInput.checked = false;
}
updateCreateTokenDerivedFields();
}
function setCreateTokenButtonsDisabled(disabled) {
document.querySelector("[data-create-token-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-create-token-broadcast]")?.toggleAttribute("disabled", disabled);
}
async function handleCreateTokenAction(action) {
const error = validateCreateTokenForm();
if (error) {
setCreateTokenMessage(error, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setCreateTokenMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setCreateTokenMessage("Signing cancelled.");
return;
}
setCreateTokenButtonsDisabled(true);
try {
if (action === "save") {
setCreateTokenMessage("Saving token transaction...");
const review = await invoke("save_create_token_transaction", {
...createTokenPayload(),
walletKey
});
clearCreateTokenForm();
setCreateTokenMessage(`Token transaction saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setCreateTokenMessage("Broadcasting token transaction...");
const result = await invoke("broadcast_create_token_transaction", {
...createTokenPayload(),
broadcastNode,
walletKey
});
if (typeof invalidateAssetsCatalog === "function") {
invalidateAssetsCatalog();
}
clearCreateTokenForm();
setCreateTokenMessage(result.response || "Token transaction broadcast.", "success");
}
} catch (error) {
setCreateTokenMessage(String(error), "error");
} finally {
setCreateTokenButtonsDisabled(false);
}
}
function prepareCreateTokenForm() {
updateCreateTokenDerivedFields();
setCreateTokenMessage("");
}
createTokenTickerInput?.addEventListener("input", () => {
updateCreateTokenDerivedFields();
setCreateTokenMessage("");
});
createTokenNumberInput?.addEventListener("input", () => setCreateTokenMessage(""));
createTokenAllowMoreInput?.addEventListener("change", () => {
updateCreateTokenDerivedFields();
setCreateTokenMessage("");
});
document.querySelector("[data-create-token-save]")?.addEventListener("click", () => handleCreateTokenAction("save"));
document.querySelector("[data-create-token-broadcast]")?.addEventListener("click", () => handleCreateTokenAction("broadcast"));

View File

@ -0,0 +1,369 @@
function setRecentTransactionsForWalletState(state) {
recentTransactionsCacheReadId += 1;
latestTransactionsSyncId += 1;
clearRecentTransactionsRows();
stopLatestDashboardTransactionsPolling();
if (state === "registered") {
setRecentTransactionsState("");
resetLatestDashboardTransactionsMemory();
updateLatestDashboardTransactionsPolling();
startBackgroundTransactionSync();
return;
}
stopBackgroundTransactionSync();
resetLatestDashboardTransactionsMemory();
if (state === "checking") {
setRecentTransactionsState("Checking address registration before loading transactions.");
return;
}
if (state === "unregistered") {
setRecentTransactionsState("Address registration required before transactions can appear.");
return;
}
setRecentTransactionsState("Load a wallet to view recent transactions.");
}
function resetBalances(message = "Load wallet", statusMessage = "Balances refresh while dashboard is open.") {
activeWalletBalances = [];
if (typeof refreshTransferAssetOptions === "function") {
refreshTransferAssetOptions();
}
if (typeof refreshActiveTransactionFormAfterWalletData === "function") {
refreshActiveTransactionFormAfterWalletData();
}
if (baseBalanceLabel) {
baseBalanceLabel.textContent = `${activeBaseCoin()} Balance`;
}
if (clcBalance) {
clcBalance.textContent = message;
}
if (tokenBalances) {
tokenBalances.innerHTML = `
<div class="token-title">Token Balances</div>
<div class="empty-balance">No wallet loaded.</div>
`;
}
dashboardLastBalanceStatus = "";
setBalanceStatus(statusMessage);
}
async function refreshDashboardBalances({ force = false, quiet = false } = {}) {
if (!walletLoaded || !registrationRegistered || currentView !== "dashboard" || balanceRefreshInFlight) {
return false;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
return false;
}
balanceRefreshInFlight = true;
const requestId = ++balanceRefreshRequestId;
if (force && !quiet) {
setBalanceStatus("Refreshing balances...");
}
try {
const balances = await invoke("get_active_wallet_balances", {
broadcastNode
});
if (requestId !== balanceRefreshRequestId || !walletLoaded) {
return false;
}
renderBalances(balances);
dashboardLastBalanceStatus = `Updated ${new Date().toLocaleTimeString()}`;
setBalanceStatus(dashboardLastBalanceStatus, "success");
return true;
} catch (error) {
if (requestId !== balanceRefreshRequestId || !walletLoaded) {
return false;
}
setBalanceStatus(String(error), "error");
return false;
} finally {
if (requestId === balanceRefreshRequestId) {
balanceRefreshInFlight = false;
}
}
}
function startBalanceRefresh({ immediate = true } = {}) {
if (balanceRefreshTimer || !walletLoaded || !registrationRegistered || currentView !== "dashboard") {
return;
}
if (immediate) {
refreshDashboardBalances({ force: true });
}
balanceRefreshTimer = setInterval(() => {
refreshDashboardBalances();
}, BALANCE_REFRESH_MS);
}
function stopBalanceRefresh() {
if (balanceRefreshTimer) {
clearInterval(balanceRefreshTimer);
balanceRefreshTimer = null;
}
}
function updateBalanceRefreshState() {
if (walletLoaded && registrationRegistered && currentView === "dashboard") {
startBalanceRefresh();
return;
}
stopBalanceRefresh();
}
function restoreLatestDashboardTransactionsFromMemory() {
if (!latestDashboardTransactionRecords.size || !latestDashboardTxids.length) {
return false;
}
const latestCache = { transactions: Object.fromEntries(latestDashboardTransactionRecords) };
updateTransactionCacheChainStatus(latestCache);
latestDashboardTransactionRecords = new Map(Object.entries(latestCache.transactions));
renderRecentTransactions(normalizeRecentTransactionRecords(
transactionRecordsForTxids(latestCache, latestDashboardTxids),
{ preserveOrder: true }
));
setRecentTransactionsState("");
return true;
}
async function startRegisteredDashboardLoadSequence({ resetTransactions = false, quiet = false } = {}) {
const loadId = ++dashboardRegisteredLoadId;
stopLatestDashboardTransactionsPolling();
stopBalanceRefresh();
if (resetTransactions) {
resetLatestDashboardTransactionsMemory();
}
const restoredTransactions = restoreLatestDashboardTransactionsFromMemory();
if (!restoredTransactions && !quiet) {
setRecentTransactionsForWalletState("checking");
}
if (!walletLoaded || !registrationRegistered || currentView !== "dashboard") {
return;
}
if (dashboardLastBalanceStatus) {
setBalanceStatus(dashboardLastBalanceStatus, "success");
}
await refreshDashboardBalances({ force: true, quiet: quiet || Boolean(dashboardLastBalanceStatus) });
if (
loadId !== dashboardRegisteredLoadId ||
!walletLoaded ||
!registrationRegistered ||
currentView !== "dashboard"
) {
return;
}
startBalanceRefresh({ immediate: false });
if (!restoredTransactions) {
setRecentTransactionsForWalletState("registered");
return;
}
updateLatestDashboardTransactionsPolling();
startBackgroundTransactionSync();
}
function startNetworkInfoRefresh() {
if (networkInfoRefreshTimer || !walletLoaded || currentView !== "dashboard") {
return;
}
refreshNetworkInfo({ target: "dashboard" });
networkInfoRefreshTimer = setInterval(() => {
refreshNetworkInfo({ target: "dashboard" });
}, NETWORK_INFO_REFRESH_MS);
}
function stopNetworkInfoRefresh() {
if (networkInfoRefreshTimer) {
clearInterval(networkInfoRefreshTimer);
networkInfoRefreshTimer = null;
}
}
function updateNetworkInfoRefreshState() {
if (walletLoaded && currentView === "dashboard") {
startNetworkInfoRefresh();
return;
}
stopNetworkInfoRefresh();
}
function startNodeInfoRefresh() {
if (nodeInfoRefreshTimer || !walletLoaded) {
renderNodeInfo(null);
return;
}
refreshNetworkInfo({ target: "node", force: true });
nodeInfoRefreshTimer = setInterval(() => {
refreshNetworkInfo({ target: "node", force: true });
}, NODE_INFO_REFRESH_MS);
}
function stopNodeInfoRefresh() {
if (nodeInfoRefreshTimer) {
clearInterval(nodeInfoRefreshTimer);
nodeInfoRefreshTimer = null;
}
}
function activeBroadcastNode() {
return broadcastNodeButton?.value || broadcastNodeButton?.dataset.broadcastNode || broadcastNodeButton?.textContent?.trim() || "";
}
function setRegistrationState(state) {
const states = ["registered", "unregistered", "checking"];
registrationBadge?.classList.remove(...states);
registrationAction?.classList.remove(...states);
registrationNotice?.classList.remove(...states);
if (state === "registered") {
registrationRegistered = true;
registrationBadge && (registrationBadge.textContent = "Registered");
registrationBadge?.classList.add("registered");
if (registrationNotice) {
registrationNotice.textContent = "Address registered. This wallet can receive coins and tokens.";
registrationNotice.classList.add("registered");
}
if (registrationAction) {
registrationAction.textContent = "Address Registered";
registrationAction.disabled = true;
registrationAction.classList.add("registered");
}
startRegisteredDashboardLoadSequence();
updateHistoryRefreshState();
updateNetworkInfoRefreshState();
refreshReceivePage();
if (typeof refreshActiveTransactionFormAfterWalletData === "function") {
refreshActiveTransactionFormAfterWalletData();
}
return;
}
if (state === "checking") {
registrationBadge && (registrationBadge.textContent = "Checking");
registrationBadge?.classList.add("checking");
if (registrationNotice) {
registrationNotice.textContent = "Checking whether this address can receive coins and tokens.";
registrationNotice.classList.add("checking");
}
if (registrationAction) {
registrationAction.textContent = "Checking...";
registrationAction.disabled = true;
registrationAction.classList.add("checking");
}
dashboardRegisteredLoadId += 1;
stopBalanceRefresh();
setRecentTransactionsForWalletState("checking");
updateHistoryRefreshState();
refreshReceivePage();
return;
}
registrationRegistered = false;
registrationBadge && (registrationBadge.textContent = "Unregistered");
registrationBadge?.classList.add("unregistered");
if (registrationNotice) {
registrationNotice.textContent = "Address must be registered before this wallet can receive coins or tokens.";
registrationNotice.classList.add("unregistered");
}
if (registrationAction) {
registrationAction.textContent = "Register Address";
registrationAction.disabled = false;
registrationAction.classList.add("unregistered");
}
dashboardRegisteredLoadId += 1;
stopBalanceRefresh();
setRecentTransactionsForWalletState("unregistered");
updateHistoryRefreshState();
updateNetworkInfoRefreshState();
refreshReceivePage();
}
async function checkWalletRegistrationOnce() {
if (!walletLoaded || registrationRegistered || registrationCheckInFlight) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setRegistrationState("unregistered");
return;
}
registrationCheckInFlight = true;
const requestId = ++registrationRequestId;
setRegistrationState("checking");
try {
const status = await invoke("check_wallet_registration", { broadcastNode });
if (requestId !== registrationRequestId || !walletLoaded) {
return;
}
setRegistrationState(status?.registered ? "registered" : "unregistered");
} catch (_) {
if (requestId !== registrationRequestId || !walletLoaded) {
return;
}
setRegistrationState("unregistered");
} finally {
if (requestId === registrationRequestId) {
registrationCheckInFlight = false;
}
}
}
async function registerActiveWallet() {
if (!walletLoaded || registrationRegistered || registrationCheckInFlight) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setRegistrationState("unregistered");
return;
}
registrationCheckInFlight = true;
const requestId = ++registrationRequestId;
setRegistrationState("checking");
try {
const status = await invoke("register_active_wallet", { broadcastNode });
if (requestId !== registrationRequestId || !walletLoaded) {
return;
}
setRegistrationState(status?.registered ? "registered" : "unregistered");
} catch (_) {
if (requestId !== registrationRequestId || !walletLoaded) {
return;
}
setRegistrationState("unregistered");
} finally {
if (requestId === registrationRequestId) {
registrationCheckInFlight = false;
}
}
}

View File

@ -0,0 +1,284 @@
function renderDashboardNetworkInfo(info) {
if (!info || typeof info !== "object") {
return;
}
latestKnownBlockHeight = Number(info.height || latestKnownBlockHeight || 0);
setBridgeStatus(`${networkDisplayName(info)} connected`, "success");
networkInfoHeight && (networkInfoHeight.textContent = formatNumber(info.height));
networkInfoNetwork && (networkInfoNetwork.textContent = networkDisplayName(info));
networkInfoDifficulty && (networkInfoDifficulty.textContent = formatNumber(info.next_block_difficulty));
networkInfoMempool && (networkInfoMempool.textContent = formatNumber(info.total_mempool_transactions));
networkInfoChainTx && (networkInfoChainTx.textContent = formatNumber(info.total_block_transactions));
}
function renderNodeInfo(info, checkedAt = new Date()) {
const broadcastNode = activeBroadcastNode();
nodeInfoNode && (nodeInfoNode.textContent = broadcastNode || "--");
if (!info || typeof info !== "object") {
nodeInfoHeight && (nodeInfoHeight.textContent = "--");
nodeInfoTime && (nodeInfoTime.textContent = "--");
nodeInfoLastChecked && (nodeInfoLastChecked.textContent = "--");
nodeInfoLargestFee && (nodeInfoLargestFee.textContent = "--");
return;
}
nodeInfoHeight && (nodeInfoHeight.textContent = formatNumber(info.height));
nodeInfoTime && (nodeInfoTime.textContent = formatNetworkTime(info.time));
nodeInfoLastChecked && (nodeInfoLastChecked.textContent = checkedAt.toLocaleTimeString());
nodeInfoLargestFee && (nodeInfoLargestFee.textContent = formatLargestFee(info.largest_tx_fee));
}
function resetNetworkInfoDisplays() {
const networkName = activeNetwork === "mainnet" ? "Mainnet" : "Testnet";
networkInfoHeight && (networkInfoHeight.textContent = "--");
networkInfoNetwork && (networkInfoNetwork.textContent = `${networkName} / ${activeBaseCoin()}`);
networkInfoDifficulty && (networkInfoDifficulty.textContent = "--");
networkInfoMempool && (networkInfoMempool.textContent = "--");
networkInfoChainTx && (networkInfoChainTx.textContent = "--");
setBridgeStatus(`${networkName} disconnected`, "error");
renderNodeInfo(null);
}
async function refreshNetworkInfo({ target = "dashboard", force = false } = {}) {
if (!walletLoaded) {
renderNodeInfo(null);
setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} disconnected`, "error");
return null;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} unavailable`, "error");
return null;
}
if (target === "node" && nodeInfoRefreshInFlight) {
return null;
}
if (target !== "node" && networkInfoRefreshInFlight) {
return null;
}
if (target === "node") {
nodeInfoRefreshInFlight = true;
} else {
networkInfoRefreshInFlight = true;
}
try {
const info = await invokeWalletRpcWithBackoff(
"Network info lookup",
() => invoke("network_info", { broadcastNode }),
() => walletLoaded && activeBroadcastNode() === broadcastNode
);
const checkedAt = new Date();
renderDashboardNetworkInfo(info);
if (target === "node" || force) {
renderNodeInfo(info, checkedAt);
}
return info;
} catch (error) {
setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} disconnected`, "error");
if (target === "node") {
nodeInfoLastChecked && (nodeInfoLastChecked.textContent = String(error));
}
return null;
} finally {
if (target === "node") {
nodeInfoRefreshInFlight = false;
} else {
networkInfoRefreshInFlight = false;
}
}
}
function sleepMs(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function walletRpcBackoffDelay(attempt) {
return Math.min(WALLET_RPC_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1), WALLET_RPC_BACKOFF_MAX_MS);
}
async function invokeWalletRpcWithBackoff(label, operation, shouldContinue = () => true) {
let lastError = null;
for (let attempt = 1; attempt <= WALLET_RPC_RETRY_LIMIT; attempt += 1) {
if (!shouldContinue()) {
throw new Error(`${label} cancelled`);
}
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt >= WALLET_RPC_RETRY_LIMIT || !shouldContinue()) {
break;
}
await sleepMs(walletRpcBackoffDelay(attempt));
}
}
throw lastError || new Error(`${label} failed`);
}
function bytesToHex(bytes) {
return Array.from(bytes || [])
.map((byte) => Number(byte).toString(16).padStart(2, "0"))
.join("");
}
function bytesToText(bytes) {
return String.fromCharCode(...Array.from(bytes || [])).trim();
}
function readU32LE(bytes, offset) {
return (
Number(bytes[offset] || 0) |
(Number(bytes[offset + 1] || 0) << 8) |
(Number(bytes[offset + 2] || 0) << 16) |
(Number(bytes[offset + 3] || 0) << 24)
) >>> 0;
}
function readU64LE(bytes, offset) {
let value = 0n;
for (let index = 7; index >= 0; index -= 1) {
value = (value << 8n) + BigInt(Number(bytes[offset + index] || 0));
}
return value;
}
function formatAtomicValue(value) {
const atomic = typeof value === "bigint" ? value : BigInt(value || 0);
const whole = atomic / 100000000n;
const fraction = atomic % 100000000n;
const fractionText = fraction.toString().padStart(8, "0").replace(/0+$/, "");
return fractionText ? `${whole}.${fractionText}` : whole.toString();
}
function bytesToAddressHex(bytes) {
return bytesToHex(bytes).toLowerCase();
}
function addressLabel(addressHex) {
return addressHex ? `${addressHex.slice(0, 8)}...${addressHex.slice(-8)}` : "Unknown";
}
function isActiveAddress(addressHex) {
return Boolean(addressHex && activeWalletAddressHex && addressHex.toLowerCase() === activeWalletAddressHex.toLowerCase());
}
function isActiveShortAddress(address) {
return Boolean(address && activeWalletAddressShort && String(address).trim().toLowerCase() === activeWalletAddressShort.toLowerCase());
}
function isActiveWalletAddress(address) {
const clean = String(address || "").trim().toLowerCase();
if (!clean) {
return false;
}
return clean.includes(".") ? isActiveShortAddress(clean) : isActiveAddress(clean);
}
function assetWithSeries(asset, series) {
const cleanAsset = (asset || "").trim() || activeBaseCoin();
return Number(series || 0) > 0 ? `${cleanAsset} #${series}` : cleanAsset;
}
function signedAmount(sign, value) {
const formatted = formatAtomicValue(value);
if (!formatted || formatted === "0") {
return formatted;
}
return `${sign}${formatted}`;
}
function signedBaseFee(sign, value) {
const amount = signedAmount(sign, value);
return amount && amount !== "0" ? `${amount} ${activeBaseCoin()}` : "--";
}
function statusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) {
if (!currentHeight || !blockHeight || currentHeight < blockHeight) {
return "Pending";
}
return currentHeight - blockHeight >= CONFIRMED_AFTER_BLOCKS ? "Confirmed" : "Pending";
}
function walletStatusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) {
const height = Number(blockHeight || 0);
if (!height) {
return "Unconfirmed";
}
return statusForBlock(height, currentHeight);
}
function blockValueFromRecord(record) {
const block = record?.block ?? record?.block_height ?? record?.blockHeight ?? null;
const numeric = Number(block || 0);
return numeric > 0 ? numeric : null;
}
function dashboardRow(base, type, asset, counterparty, amount, fee = "--") {
return {
...base,
type,
asset,
counterparty,
amount,
fee
};
}
function txTypeName(type) {
return {
0: "Genesis",
1: "Reward",
2: "Transfer",
3: "Create Token",
4: "Create NFT",
5: "Marketing",
6: "Swap",
7: "Create Loan",
8: "Loan Payment",
9: "Collateral Claim",
10: "Burn",
11: "Issue Token",
12: "Vanity Address"
}[type] || "Transaction";
}
function parseLoanContractSummary(transaction) {
if (transaction && typeof transaction === "object" && !Array.isArray(transaction)) {
const unsigned = transaction.unsigned_loan_contract;
if (!unsigned) {
return null;
}
return {
loanCoin: unsigned.loan_coin || "Loan",
loanAmount: unsigned.loan_amount || 0,
lender: unsigned.lender || "",
collateral: unsigned.collateral || "Collateral",
collateralAmount: unsigned.collateral_amount || 0,
borrower: unsigned.borrower || ""
};
}
const bytes = Array.from(transaction || []);
if (Number(bytes[0] || 0) !== 7) {
return null;
}
return {
loanCoin: bytesToText(bytes.slice(5, 20)) || "Loan",
loanAmount: readU64LE(bytes, 20),
lender: bytesToAddressHex(bytes.slice(28, 50)),
collateral: bytesToText(bytes.slice(50, 71)) || "Collateral",
collateralAmount: readU64LE(bytes, 71),
borrower: bytesToAddressHex(bytes.slice(79, 101))
};
}

639
frontend/js/history_page.js Normal file
View File

@ -0,0 +1,639 @@
function setHistoryState(message, mode = "") {
if (!historyState) {
return;
}
historyState.textContent = message;
historyState.classList.toggle("hidden", !message);
historyState.classList.toggle("loading", mode === "loading");
}
function clearHistoryRows() {
if (historyTransactionsBody) {
historyTransactionsBody.innerHTML = "";
}
if (historyCards) {
historyCards.innerHTML = "";
}
}
function setHistoryFooter(
visibleCount = 0,
totalCount = 0,
overallCount = totalCount,
searchTerm = "",
page = 1,
totalPages = 1,
pageStart = 0
) {
if (historyCount) {
const normalizedSearchTerm = String(searchTerm || "").trim();
const rangeStart = visibleCount ? pageStart + 1 : 0;
const rangeEnd = pageStart + visibleCount;
if (normalizedSearchTerm && overallCount !== totalCount) {
historyCount.textContent = totalCount
? `Showing ${formatNumber(rangeStart)}-${formatNumber(rangeEnd)} of ${formatNumber(totalCount)} matches (${formatNumber(overallCount)} total)`
: `No matches (${formatNumber(overallCount)} total)`;
} else {
historyCount.textContent = totalCount
? `Showing ${formatNumber(rangeStart)}-${formatNumber(rangeEnd)} of ${formatNumber(totalCount)}`
: "";
}
}
if (historyPagination) {
historyPagination.hidden = totalPages <= 1;
historyPagination.querySelectorAll("[data-history-page]").forEach((button) => {
const action = button.dataset.historyPage;
button.disabled = action === "first" || action === "previous"
? page <= 1
: page >= totalPages;
});
}
if (historyPageInput) {
historyPageInput.value = String(page);
historyPageInput.max = String(Math.max(1, totalPages));
historyPageInput.disabled = totalPages <= 1;
}
if (historyPageTotal) {
historyPageTotal.textContent = formatNumber(Math.max(1, totalPages));
}
}
function normalizedHistorySearchTerm(value) {
return String(value || "").trim().toLowerCase();
}
function compactHistorySearchText(value) {
return normalizedHistorySearchTerm(value).replace(/[\s_-]+/g, "");
}
function historyTypeSearchLabels(type) {
const numeric = Number(type || 0);
const key = txTypeKey(numeric);
const aliases = {
0: ["genesis"],
1: ["reward", "mining reward"],
2: ["transfer", "transfer in", "transfer out"],
3: ["token create", "create token"],
4: ["nft create", "create nft"],
5: ["marketing"],
6: ["swap"],
7: ["loan create", "loan contract"],
8: ["loan payment", "contract payment"],
9: ["collateral claim"],
10: ["burn"],
11: ["token issue", "issue token"],
12: ["vanity", "vanity address"]
};
return [String(numeric), key, key.replace(/_/g, " "), ...(aliases[numeric] || [])];
}
function historyTransactionMatchesSearch(index, txid, searchTerm) {
const term = normalizedHistorySearchTerm(searchTerm);
if (!term) {
return true;
}
const compactTerm = compactHistorySearchText(term);
if (String(txid || "").toLowerCase().includes(term)) {
return true;
}
const type = Number(index?.txid_to_type?.[txid] ?? 0);
return historyTypeSearchLabels(type).some((label) => {
const normalized = normalizedHistorySearchTerm(label);
return normalized.includes(term) || compactHistorySearchText(normalized).includes(compactTerm);
});
}
function historyTxidsForSearch(index, orderedTxids, searchTerm) {
const term = normalizedHistorySearchTerm(searchTerm);
if (!term) {
return orderedTxids;
}
return orderedTxids.filter((txid) => historyTransactionMatchesSearch(index, txid, term));
}
function historyExportRows(transactions) {
return transactions.map((transaction) => ({
date: formatHistoryDate(transaction.timestamp),
timestamp: Number(transaction.timestamp || 0),
type: recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction"),
asset: recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin()),
counterparty: recentTransactionField(transaction, ["counterparty", "to", "from", "address"], ""),
status: recentTransactionField(transaction, ["status"], "Pending"),
amount: recentTransactionField(transaction, ["amount", "value", "display_amount"], ""),
fee: recentTransactionField(transaction, ["fee", "txfee", "display_fee"], "--"),
txid: recentTransactionField(transaction, ["txid"], ""),
block: Number(transaction.block_height || transaction.blockHeight || transaction.height || 0) || null
}));
}
function csvCell(value) {
const text = value === null || value === undefined ? "" : String(value);
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function historyRowsToCsv(rows) {
const headers = ["date", "timestamp", "type", "asset", "counterparty", "status", "amount", "fee", "txid", "block"];
return [
headers.join(","),
...rows.map((row) => headers.map((header) => csvCell(row[header])).join(","))
].join("\r\n");
}
function xmlEscape(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function historyRowsToXml(rows) {
const body = rows.map((row) => ` <transaction>
<date>${xmlEscape(row.date)}</date>
<timestamp>${xmlEscape(row.timestamp)}</timestamp>
<type>${xmlEscape(row.type)}</type>
<asset>${xmlEscape(row.asset)}</asset>
<counterparty>${xmlEscape(row.counterparty)}</counterparty>
<status>${xmlEscape(row.status)}</status>
<amount>${xmlEscape(row.amount)}</amount>
<fee>${xmlEscape(row.fee)}</fee>
<txid>${xmlEscape(row.txid)}</txid>
<block>${xmlEscape(row.block ?? "")}</block>
</transaction>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<history>\n${body}\n</history>\n`;
}
function historyRowsToExport(rows, format) {
const normalizedFormat = String(format || "").toLowerCase();
if (normalizedFormat === "json") {
return JSON.stringify({
exported_at: new Date().toISOString(),
address: activeWalletAddressShort || activeWalletAddressHex || "",
transactions: rows
}, null, 2);
}
if (normalizedFormat === "xml") {
return historyRowsToXml(rows);
}
if (normalizedFormat === "csv") {
return historyRowsToCsv(rows);
}
throw new Error("Unsupported export format.");
}
function setHistoryExportMessage(message, mode = "") {
if (!historyExportMessage) {
return;
}
historyExportMessage.textContent = message;
historyExportMessage.classList.toggle("error", mode === "error");
historyExportMessage.classList.toggle("success", mode === "success");
}
function toggleHistoryExportModal(open) {
if (!historyExportModal) {
return;
}
historyExportModal.classList.toggle("hidden", !open);
if (open) {
setHistoryExportMessage("");
}
}
async function exportFullHistory(format) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !walletLoaded || !registrationRegistered) {
setHistoryExportMessage("Load a registered wallet before exporting history.", "error");
return;
}
const normalizedFormat = String(format || "").toLowerCase();
const walletToken = activeWalletCacheToken();
setHistoryExportMessage("Preparing export. This may take several minutes.");
historyExportFormatButtons.forEach((button) => {
button.disabled = true;
});
try {
const cache = await readTransactionCacheObject(walletToken);
if (!isActiveWalletCacheToken(walletToken)) {
throw new Error("Wallet changed before export completed.");
}
const rows = historyExportRows(normalizeHistoryTransactions(cache));
if (!rows.length) {
throw new Error("No local transaction history is available to export.");
}
const contents = historyRowsToExport(rows, normalizedFormat);
const address = activeWalletAddressShort || activeWalletAddressHex || "address";
const safeAddress = String(address).replace(/[^a-z0-9._-]/gi, "_");
const savedPath = await invoke("save_history_export", {
defaultFileName: `${safeAddress}-history.${normalizedFormat}`,
format: normalizedFormat,
contents
});
if (savedPath) {
setHistoryExportMessage(`Saved to ${savedPath}`, "success");
} else {
setHistoryExportMessage("Export cancelled.");
}
} catch (error) {
setHistoryExportMessage(String(error), "error");
} finally {
historyExportFormatButtons.forEach((button) => {
button.disabled = false;
});
}
}
function historyTransactionTimestamp(record) {
const transaction = record?.transaction;
if (Array.isArray(transaction)) {
const timestamp = Number(readU32LE(transaction, 1));
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0;
}
const candidates = [
transaction?.unsigned?.timestamp,
transaction?.unsigned?.time,
transaction?.unsigned_transfer?.timestamp,
transaction?.unsigned_transfer?.time,
transaction?.unsigned_create_token?.timestamp,
transaction?.unsigned_create_token?.time,
transaction?.unsigned_create_nft?.timestamp,
transaction?.unsigned_create_nft?.time,
transaction?.unsigned_marketing?.timestamp,
transaction?.unsigned_marketing?.time,
transaction?.unsigned_swap?.timestamp,
transaction?.unsigned_swap?.time,
transaction?.unsigned_loan_contract?.timestamp,
transaction?.unsigned_loan_contract?.time,
transaction?.unsigned_contract_payment?.timestamp,
transaction?.unsigned_contract_payment?.time,
transaction?.unsigned_collateral_claim?.timestamp,
transaction?.unsigned_collateral_claim?.time,
transaction?.unsigned_burn?.timestamp,
transaction?.unsigned_burn?.time,
transaction?.unsigned_issue_token?.timestamp,
transaction?.unsigned_issue_token?.time,
transaction?.unsigned_vanity_address?.timestamp,
transaction?.unsigned_vanity_address?.time
];
const timestamp = candidates.map(Number).find((value) => Number.isFinite(value) && value > 0);
return timestamp || 0;
}
function formatHistoryDate(timestamp) {
const numeric = Number(timestamp || 0);
if (!numeric) {
return "--";
}
return new Date(numeric * 1000).toLocaleString();
}
function historyStatusClass(status) {
return String(status || "").trim().toLowerCase() === "confirmed" ? "confirmed" : "pending";
}
function historyBlockLabel(block) {
const numeric = Number(block || 0);
return numeric > 0 ? formatNumber(numeric) : "Pending";
}
function normalizeHistoryTransactions(cache) {
const normalized = normalizeTransactionCache(cache);
const orderedTxids = normalizeOrderedTxids(normalized);
const records = transactionRecordsForTxids(normalized, orderedTxids);
return records
.flatMap((record, recordIndex) => {
const repaired = repairCachedTransactionRecord(record.txid, record);
const block = blockValueFromRecord(repaired);
const timestamp = historyTransactionTimestamp(repaired);
const status = repaired.status || walletStatusForBlock(block);
return parseDashboardTransactionRows(
repaired.txid,
block,
repaired.transaction,
repaired.linked_transactions || {},
repaired.miner_earnings || []
).map((row, rowIndex) => ({
...row,
txid: row.txid || repaired.txid,
block_height: row.block_height ?? block,
status,
timestamp,
__order: recordIndex + rowIndex / 1000
}));
})
.sort((left, right) => {
const leftPending = historyStatusClass(left.status) === "pending" ? 1 : 0;
const rightPending = historyStatusClass(right.status) === "pending" ? 1 : 0;
if (rightPending !== leftPending) {
return rightPending - leftPending;
}
const leftBlock = Number(left.block_height || 0);
const rightBlock = Number(right.block_height || 0);
if (rightBlock !== leftBlock) {
return rightBlock - leftBlock;
}
const leftTimestamp = Number(left.timestamp || 0);
const rightTimestamp = Number(right.timestamp || 0);
if (rightTimestamp !== leftTimestamp) {
return rightTimestamp - leftTimestamp;
}
return right.__order - left.__order;
})
.map(({ __order: _order, ...transaction }) => transaction);
}
function renderHistoryTransactions(transactions) {
clearHistoryRows();
if (!historyTransactionsBody || !historyCards) {
return;
}
transactions.forEach((transaction) => {
const type = recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction");
const asset = recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin());
const counterparty = recentTransactionField(transaction, ["counterparty", "to", "from", "address"], shortenAddress(transaction.txid));
const status = recentTransactionField(transaction, ["status"], "Pending");
const amount = recentTransactionField(transaction, ["amount", "value", "display_amount"], "");
const fee = recentTransactionField(transaction, ["fee", "txfee", "display_fee"], "--");
const txid = recentTransactionField(transaction, ["txid"], "");
const block = historyBlockLabel(recentTransactionField(transaction, ["block_height", "blockHeight", "height"], "0"));
const date = formatHistoryDate(transaction.timestamp);
const statusClass = historyStatusClass(status);
const shortTxid = shortenAddress(txid);
const row = document.createElement("tr");
row.innerHTML = `
<td></td>
<td></td>
<td></td>
<td></td>
<td><span class="table-status ${statusClass}"></span></td>
<td></td>
<td></td>
<td><button class="hash-button" type="button" data-copy-history-hash></button></td>
<td></td>
`;
row.children[0].textContent = date;
row.children[1].textContent = type;
row.children[2].textContent = asset;
row.children[3].textContent = counterparty;
row.querySelector(".table-status").textContent = status;
row.children[5].textContent = amount;
row.children[6].textContent = fee;
row.querySelector("[data-copy-history-hash]").textContent = shortTxid;
row.querySelector("[data-copy-history-hash]").dataset.hash = txid;
row.children[8].textContent = block;
historyTransactionsBody.appendChild(row);
const card = document.createElement("article");
card.className = "history-card";
card.innerHTML = `
<div><strong></strong><span class="table-status ${statusClass}"></span></div>
<b></b>
<span></span>
<button class="hash-button" type="button" data-copy-history-hash></button>
`;
card.querySelector("strong").textContent = `${type} · ${asset}`;
card.querySelector(".table-status").textContent = status;
card.querySelector("b").textContent = amount || "--";
card.querySelector("span:not(.table-status)").textContent = `${counterparty} · Block ${block} · ${date}`;
card.querySelector("[data-copy-history-hash]").textContent = shortTxid;
card.querySelector("[data-copy-history-hash]").dataset.hash = txid;
card.querySelector("strong").textContent = `${type} - ${asset}`;
card.querySelector("span:not(.table-status)").textContent = `${counterparty} - Fee ${fee} - Block ${block} - ${date}`;
historyCards.appendChild(card);
});
}
async function refreshHistoryFromCache() {
if (!walletLoaded || currentView !== "history") {
return;
}
if (historyRefreshInFlight) {
historyRefreshPending = true;
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
clearHistoryRows();
setHistoryState("Transaction history requires the desktop app build.");
return;
}
const readId = ++historyRefreshReadId;
if (!registrationRegistered) {
clearHistoryRows();
setHistoryFooter(0, 0);
setHistoryState("Address registration required before transactions can appear.");
return;
}
historyRefreshInFlight = true;
historyRefreshPending = false;
try {
const walletToken = activeWalletCacheToken();
const index = await readTransactionIndexObject(walletToken);
if (readId !== historyRefreshReadId || currentView !== "history") {
return;
}
const orderedTxids = normalizeOrderedTxids(index);
const searchTerm = normalizedHistorySearchTerm(historySearch?.value || "");
const matchedTxids = historyTxidsForSearch(index, orderedTxids, searchTerm);
const totalCount = orderedTxids.length;
const resultCount = matchedTxids.length;
const totalPages = Math.max(1, Math.ceil(resultCount / HISTORY_PAGE_SIZE));
historyCurrentPage = Math.min(Math.max(1, historyCurrentPage), totalPages);
const newestOffset = (historyCurrentPage - 1) * HISTORY_PAGE_SIZE;
const pageEnd = Math.max(0, resultCount - newestOffset);
const pageStart = Math.max(0, pageEnd - HISTORY_PAGE_SIZE);
const visibleTxids = matchedTxids.slice(pageStart, pageEnd);
const visibleCount = visibleTxids.length;
const visibleStart = visibleTxids[0] || "";
const visibleEnd = visibleTxids[visibleTxids.length - 1] || "";
const signature = `${searchTerm}:${totalCount}:${resultCount}:${historyCurrentPage}:${visibleStart}:${visibleEnd}:${index.updated_at || ""}`;
if (signature === historyLastRenderSignature) {
setHistoryFooter(visibleCount, resultCount, totalCount, searchTerm, historyCurrentPage, totalPages, newestOffset);
return;
}
const cache = await readTransactionCacheRecords(index, visibleTxids, walletToken);
if (readId !== historyRefreshReadId || currentView !== "history") {
return;
}
const broadcastNode = activeBroadcastNode();
if (broadcastNode) {
for (const txid of visibleTxids) {
const record = cache.transactions?.[txid];
if (!cachedRecordNeedsRefresh(record)) {
continue;
}
const repaired = await hydrateLinkedTransactionsForRecord(
txid,
record,
invoke,
broadcastNode,
cache,
() => readId === historyRefreshReadId && currentView === "history" && walletLoaded,
0
);
if (readId !== historyRefreshReadId || currentView !== "history") {
return;
}
cache.transactions[txid] = repaired;
const fileName = index.txid_to_file?.[txid];
if (fileName) {
await writeTransactionRecordFile(fileName, repaired, walletToken);
}
}
}
const transactions = normalizeHistoryTransactions(cache);
renderHistoryTransactions(transactions);
historyLastRenderSignature = signature;
setHistoryFooter(visibleCount, resultCount, totalCount, searchTerm, historyCurrentPage, totalPages, newestOffset);
setHistoryState(transactions.length ? "" : (searchTerm ? "No transactions match this search." : "No transaction history has synced yet."));
} catch (error) {
if (readId !== historyRefreshReadId || currentView !== "history") {
return;
}
clearHistoryRows();
setHistoryState(String(error) || "Unable to read local transaction history.");
} finally {
historyRefreshInFlight = false;
if (historyRefreshPending && walletLoaded && currentView === "history") {
historyRefreshPending = false;
refreshHistoryFromCache();
}
}
}
function startHistoryRefresh() {
if (historyRefreshTimer || currentView !== "history") {
return;
}
historyCurrentPage = Math.max(1, historyCurrentPage);
refreshHistoryFromCache();
historyRefreshTimer = setInterval(refreshHistoryFromCache, HISTORY_REFRESH_MS);
}
function stopHistoryRefresh() {
historyRefreshReadId += 1;
historyRefreshInFlight = false;
historyRefreshPending = false;
historyCurrentPage = 1;
historyLastRenderSignature = "";
if (historySearchDebounceTimer) {
clearTimeout(historySearchDebounceTimer);
historySearchDebounceTimer = null;
}
setHistoryFooter(0, 0);
if (historyRefreshTimer) {
clearInterval(historyRefreshTimer);
historyRefreshTimer = null;
}
}
function updateHistoryRefreshState() {
if (walletLoaded && currentView === "history") {
startHistoryRefresh();
return;
}
stopHistoryRefresh();
}
if (historySearch) {
historySearch.addEventListener("input", () => {
historyCurrentPage = 1;
historyLastRenderSignature = "";
if (historySearchDebounceTimer) {
clearTimeout(historySearchDebounceTimer);
}
historySearchDebounceTimer = setTimeout(() => {
historySearchDebounceTimer = null;
refreshHistoryFromCache();
}, 180);
});
}
historyExportOpen?.addEventListener("click", () => toggleHistoryExportModal(true));
historyExportClose?.addEventListener("click", () => toggleHistoryExportModal(false));
historyExportModal?.addEventListener("click", (event) => {
if (event.target === historyExportModal) {
toggleHistoryExportModal(false);
}
});
historyExportFormatButtons.forEach((button) => {
button.addEventListener("click", () => exportFullHistory(button.dataset.historyExportFormat));
});
document.addEventListener("click", async (event) => {
const pageButton = event.target.closest("[data-history-page]");
if (pageButton) {
const totalPages = Math.max(1, Number(historyPageInput?.max || 1));
const action = pageButton.dataset.historyPage;
if (action === "first") {
historyCurrentPage = 1;
} else if (action === "previous") {
historyCurrentPage = Math.max(1, historyCurrentPage - 1);
} else if (action === "next") {
historyCurrentPage = Math.min(totalPages, historyCurrentPage + 1);
} else if (action === "last") {
historyCurrentPage = totalPages;
}
historyLastRenderSignature = "";
await refreshHistoryFromCache();
return;
}
const button = event.target.closest("[data-copy-history-hash]");
if (!button) {
return;
}
const hash = button.dataset.hash || button.textContent;
if (hash) {
await writeClipboardText(hash);
}
const original = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = original;
}, 1200);
});
historyPageInput?.addEventListener("change", async () => {
const totalPages = Math.max(1, Number(historyPageInput.max || 1));
const requestedPage = Number.parseInt(historyPageInput.value, 10);
historyCurrentPage = Number.isFinite(requestedPage)
? Math.min(Math.max(1, requestedPage), totalPages)
: 1;
historyLastRenderSignature = "";
await refreshHistoryFromCache();
});
historyPageInput?.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
historyPageInput.blur();
}
});

View File

@ -0,0 +1,281 @@
const ISSUE_TOKEN_FEE_ATOMIC = 10000000000n;
let issueTokenEligibility = null;
let issueTokenLookupId = 0;
function setIssueTokenMessage(message, type = "") {
setScopedTransactionMessage("Issue More Tokens", issueTokenMessage, message, type);
}
function setIssueTokenButtonsDisabled(disabled) {
document.querySelector("[data-issue-token-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-issue-token-broadcast]")?.toggleAttribute("disabled", disabled);
}
function updateIssueTokenFee() {
const displayFee = tokenAtomicToDecimal(ISSUE_TOKEN_FEE_ATOMIC);
if (issueTokenFeeInput) {
issueTokenFeeInput.value = displayFee;
}
if (issueTokenFeeLabel) {
issueTokenFeeLabel.textContent = `${displayFee} ${activeBaseCoin()}`;
}
}
function resetIssueTokenDetails() {
issueTokenEligibility = null;
if (issueTokenCreator) {
issueTokenCreator.textContent = "--";
}
if (issueTokenPolicy) {
issueTokenPolicy.textContent = "--";
}
setIssueTokenButtonsDisabled(true);
}
async function refreshIssueTokenEligibility() {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
const walletAddress = activeWalletAddressShort || activeWalletAddressHex || "";
const ticker = String(issueTokenTickerSelect?.value || "").trim();
const lookupId = ++issueTokenLookupId;
resetIssueTokenDetails();
if (!ticker || !invoke || !broadcastNode || !walletLoaded) {
return;
}
setIssueTokenMessage("Checking token issuance policy...");
try {
const details = await invokeWalletRpcWithBackoff(
"Token details lookup",
() => invoke("issue_token_details", { broadcastNode, ticker }),
() =>
lookupId === issueTokenLookupId &&
walletLoaded &&
(activeWalletAddressShort || activeWalletAddressHex || "") === walletAddress &&
activeBroadcastNode() === broadcastNode &&
issueTokenTickerSelect?.value === ticker
);
if (lookupId !== issueTokenLookupId) {
return;
}
issueTokenEligibility = details;
if (issueTokenCreator) {
issueTokenCreator.textContent = details.creator || "--";
}
if (issueTokenPolicy) {
issueTokenPolicy.textContent = details.hard_limit
? "Hard capped"
: "Future issuance allowed";
}
if (!details.eligible) {
setIssueTokenMessage(
details.reason || "This token cannot be issued by the active wallet.",
"error"
);
return;
}
setIssueTokenMessage("The active wallet may issue more of this token.", "success");
setIssueTokenButtonsDisabled(false);
} catch (error) {
if (lookupId !== issueTokenLookupId) {
return;
}
setIssueTokenMessage(String(error), "error");
}
}
async function refreshIssueTokenList() {
issueTokenLookupId += 1;
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
const walletAddress = activeWalletAddressShort || activeWalletAddressHex || "";
resetIssueTokenDetails();
if (!issueTokenTickerSelect) {
return;
}
issueTokenTickerSelect.innerHTML = "";
const loadingOption = document.createElement("option");
loadingOption.value = "";
loadingOption.textContent = "Loading tokens...";
issueTokenTickerSelect.appendChild(loadingOption);
if (!invoke || !walletLoaded || !broadcastNode) {
loadingOption.textContent = "Load a wallet and select a node";
return;
}
try {
const tokens = await invokeWalletRpcWithBackoff(
"Token list lookup",
() => invoke("token_list", { broadcastNode }),
() =>
walletLoaded &&
(activeWalletAddressShort || activeWalletAddressHex || "") === walletAddress &&
activeBroadcastNode() === broadcastNode
);
if (
!walletLoaded ||
(activeWalletAddressShort || activeWalletAddressHex || "") !== walletAddress ||
activeBroadcastNode() !== broadcastNode
) {
return;
}
issueTokenTickerSelect.innerHTML = "";
const tokenItems = Array.isArray(tokens) ? tokens : [];
if (!tokenItems.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No tokens exist";
issueTokenTickerSelect.appendChild(option);
setIssueTokenMessage("No tokens are available to issue.", "error");
return;
}
tokenItems.forEach((token) => {
const ticker = String(token?.ticker || "").trim();
if (!ticker) {
return;
}
const option = document.createElement("option");
option.value = ticker;
option.textContent = ticker;
issueTokenTickerSelect.appendChild(option);
});
await refreshIssueTokenEligibility();
} catch (error) {
issueTokenTickerSelect.innerHTML = "";
const option = document.createElement("option");
option.value = "";
option.textContent = "Token list unavailable";
issueTokenTickerSelect.appendChild(option);
setIssueTokenMessage(String(error), "error");
}
}
function validateIssueTokenForm() {
if (!walletLoaded) {
return "Load a wallet before issuing tokens.";
}
if (!registrationRegistered) {
return "Address registration is required before issuing tokens.";
}
if (!issueTokenEligibility?.eligible) {
return issueTokenEligibility?.reason || "Select a token the active wallet may issue.";
}
const number = tokenDecimalToAtomic(issueTokenNumberInput?.value);
if (number === null || number <= 0n) {
return "Enter a valid additional supply with up to 8 decimal places.";
}
const baseCoin = activeBaseCoin();
const baseBalance = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []).find(
(balance) =>
String(balance.asset || "").trim().toUpperCase() === baseCoin &&
!Number(balance.nft_series || 0)
);
if (baseBalance && balanceAtomicValue(baseBalance) < ISSUE_TOKEN_FEE_ATOMIC) {
return `This transaction requires ${tokenAtomicToDecimal(ISSUE_TOKEN_FEE_ATOMIC)} ${baseCoin}.`;
}
return "";
}
function issueTokenPayload() {
return {
broadcastNode: activeBroadcastNode(),
ticker: String(issueTokenTickerSelect?.value || "").trim(),
number: String(tokenDecimalToAtomic(issueTokenNumberInput?.value) || 0n),
txfee: String(ISSUE_TOKEN_FEE_ATOMIC)
};
}
function clearIssueTokenForm() {
if (issueTokenNumberInput) {
issueTokenNumberInput.value = "";
}
}
async function handleIssueTokenAction(action) {
const error = validateIssueTokenForm();
if (error) {
setIssueTokenMessage(error, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setIssueTokenMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setIssueTokenMessage("Signing cancelled.");
return;
}
setIssueTokenButtonsDisabled(true);
try {
if (action === "save") {
setIssueTokenMessage("Saving issue-token transaction...");
const review = await invoke("save_issue_token_transaction", {
...issueTokenPayload(),
walletKey
});
clearIssueTokenForm();
setIssueTokenMessage(`Issue-token transaction saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
setIssueTokenMessage("Broadcasting issue-token transaction...");
const result = await invoke("broadcast_issue_token_transaction", {
...issueTokenPayload(),
walletKey
});
if (typeof invalidateAssetsCatalog === "function") {
invalidateAssetsCatalog();
}
clearIssueTokenForm();
setIssueTokenMessage(result.response || "Issue-token transaction broadcast.", "success");
}
} catch (actionError) {
setIssueTokenMessage(String(actionError), "error");
} finally {
setIssueTokenButtonsDisabled(!issueTokenEligibility?.eligible);
}
}
function prepareIssueTokenForm() {
updateIssueTokenFee();
setIssueTokenMessage("");
refreshIssueTokenList();
}
function resetIssueTokenSession() {
issueTokenLookupId += 1;
resetIssueTokenDetails();
if (issueTokenTickerSelect) {
issueTokenTickerSelect.innerHTML = '<option value="">Load a wallet first</option>';
}
clearIssueTokenForm();
setIssueTokenMessage("");
}
issueTokenTickerSelect?.addEventListener("change", refreshIssueTokenEligibility);
issueTokenNumberInput?.addEventListener("input", () => {
if (issueTokenEligibility?.eligible) {
setIssueTokenMessage("");
}
});
document
.querySelector("[data-issue-token-save]")
?.addEventListener("click", () => handleIssueTokenAction("save"));
document
.querySelector("[data-issue-token-broadcast]")
?.addEventListener("click", () => handleIssueTokenAction("broadcast"));

View File

@ -0,0 +1,465 @@
const LOAN_FEE_ATOMIC = 300000000n;
let loanOfferLoadPromise = null;
let loanCollateralAssets = [];
let loanNftKeys = new Set();
let loanOfferAssetsKey = "";
function setLoanOfferMessage(message, type = "") {
setScopedTransactionMessage("Create Loan", loanOfferMessage, message, type);
}
function loanNftKey(asset, series) {
return `${String(asset || "").trim().toLowerCase()}|${Number(series || 0)}`;
}
function selectedLoanAsset() {
const option = loanAssetSelect?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
balance: BigInt(option.dataset.balance || "0")
};
}
function selectedLoanCollateral() {
const option = loanCollateralSelect?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
kind: option.dataset.kind || "token",
series: Number(option.dataset.series || 0),
wireName: option.dataset.wireName || option.dataset.asset || ""
};
}
function loanLocalInputValue(timestampMs) {
const date = new Date(timestampMs);
const pad = (value) => String(value).padStart(2, "0");
return [
date.getFullYear(),
"-",
pad(date.getMonth() + 1),
"-",
pad(date.getDate()),
"T",
pad(date.getHours()),
":",
pad(date.getMinutes())
].join("");
}
function loanStartTimestamp() {
const value = String(loanStartInput?.value || "");
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
if (!match) {
return null;
}
const timestamp = new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
0
).getTime();
if (!Number.isFinite(timestamp) || timestamp < 0) {
return null;
}
return Math.floor(timestamp / 1000);
}
function populateLoanAssetSelect() {
if (!loanAssetSelect) {
return;
}
const previous = loanAssetSelect.value;
const assets = (Array.isArray(activeWalletBalances) ? activeWalletBalances : [])
.filter((balance) => {
const asset = String(balance.asset || "").trim();
const series = Number(balance.nft_series || 0);
return asset
&& balanceAtomicValue(balance) > 0n
&& !loanNftKeys.has(loanNftKey(asset, series));
})
.sort((left, right) => String(left.asset).localeCompare(String(right.asset)));
loanAssetSelect.innerHTML = "";
assets.forEach((balance) => {
const option = document.createElement("option");
option.value = String(balance.asset || "").trim();
option.textContent =
`${option.value} (${formatAtomicBalance(String(balance.balance || "0"))})`;
option.dataset.asset = option.value;
option.dataset.balance = String(balance.balance || "0");
loanAssetSelect.appendChild(option);
});
if (!assets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = walletLoaded ? "No lendable assets available" : "Load a wallet first";
loanAssetSelect.appendChild(option);
} else if ([...loanAssetSelect.options].some((option) => option.value === previous)) {
loanAssetSelect.value = previous;
}
}
function populateLoanCollateralSelect() {
if (!loanCollateralSelect) {
return;
}
const previous = loanCollateralSelect.value;
loanCollateralSelect.innerHTML = "";
loanCollateralAssets.forEach((asset) => {
const option = document.createElement("option");
option.value = `${asset.kind}|${asset.wireName}`;
option.textContent = asset.label;
option.dataset.asset = asset.asset;
option.dataset.kind = asset.kind;
option.dataset.series = String(asset.series || 0);
option.dataset.wireName = asset.wireName;
loanCollateralSelect.appendChild(option);
});
if (!loanCollateralAssets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No collateral assets available";
loanCollateralSelect.appendChild(option);
} else if ([...loanCollateralSelect.options].some((option) => option.value === previous)) {
loanCollateralSelect.value = previous;
}
}
function refreshLoanOfferSelects() {
populateLoanAssetSelect();
populateLoanCollateralSelect();
updateLoanOfferDerivedFields();
}
async function loadLoanOfferAssets() {
if (loanOfferLoadPromise) {
return loanOfferLoadPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
return;
}
const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (loanOfferAssetsKey === key && loanCollateralAssets.length) {
refreshLoanOfferSelects();
return;
}
loanOfferLoadPromise = (async () => {
setLoanOfferMessage("Loading loan assets...");
const [balances, tokens, nfts] = await Promise.all([
activeWalletBalances.length
? Promise.resolve(activeWalletBalances)
: invoke("get_active_wallet_balances", { broadcastNode }),
invoke("token_list", { broadcastNode }),
invoke("nft_list", { broadcastNode })
]);
activeWalletBalances = Array.isArray(balances) ? balances : [];
const tokenRows = Array.isArray(tokens) ? tokens : [];
const nftRows = Array.isArray(nfts) ? nfts : [];
loanNftKeys = new Set(nftRows.map((nft) => loanNftKey(nft.nft_name, nft.series)));
loanCollateralAssets = [
{
asset: activeBaseCoin(),
wireName: activeBaseCoin(),
series: 0,
kind: "coin",
label: `${activeBaseCoin()} (Base Coin)`
},
...tokenRows
.filter((token) => {
return String(token.ticker || "").trim().toLowerCase()
!== activeBaseCoin().toLowerCase();
})
.map((token) => {
const asset = String(token.ticker || "").trim();
return { asset, wireName: asset, series: 0, kind: "token", label: `${asset} (Token)` };
}),
...nftRows.map((nft) => {
const asset = String(nft.nft_name || "").trim();
const series = Number(nft.series || 0);
return {
asset,
wireName: series > 0 ? `${asset}_${series}` : asset,
series,
kind: "nft",
label: series > 0 ? `${asset} #${series} (NFT)` : `${asset} (NFT)`
};
})
].filter((asset) => {
return asset.asset
&& asset.wireName.length <= 21
&& (asset.kind !== "nft" || asset.series <= 99999);
});
loanCollateralAssets.sort((left, right) => left.label.localeCompare(right.label));
loanOfferAssetsKey = key;
refreshLoanOfferSelects();
setLoanOfferMessage("");
})().catch((error) => {
setLoanOfferMessage(String(error), "error");
throw error;
}).finally(() => {
loanOfferLoadPromise = null;
});
return loanOfferLoadPromise;
}
function updateLoanOfferDerivedFields() {
const loanAsset = selectedLoanAsset();
const collateral = selectedLoanCollateral();
if (loanLender) {
loanLender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (loanAvailable) {
loanAvailable.textContent = loanAsset
? `${formatAtomicBalance(String(loanAsset.balance))} ${loanAsset.asset}`
: "--";
}
if (loanFeeInput) {
loanFeeInput.value = atomicToDecimal(LOAN_FEE_ATOMIC);
}
if (collateral?.kind === "nft") {
loanCollateralAmountInput.value = "1";
loanCollateralAmountInput.disabled = true;
} else if (loanCollateralAmountInput) {
loanCollateralAmountInput.disabled = false;
}
const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value);
const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10);
if (loanTotalRepayment) {
loanTotalRepayment.textContent =
paymentAmount !== null && paymentAmount > 0n && paymentNumber > 0
? `${atomicToDecimal(paymentAmount * BigInt(paymentNumber))} ${loanAsset?.asset || ""}`.trim()
: "--";
}
}
function validateLoanOfferForm() {
if (!walletLoaded) {
return "Load a wallet before creating a loan offer.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a loan offer.";
}
const loanAsset = selectedLoanAsset();
const collateral = selectedLoanCollateral();
if (!loanAsset) {
return "Select an asset to lend.";
}
if (!collateral) {
return "Select the collateral required from the borrower.";
}
const borrower = String(loanBorrowerInput?.value || "").trim();
if (!borrower) {
return "Enter the borrower wallet address.";
}
if (
borrower.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase()
|| borrower.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase()
) {
return "Borrower cannot be the active lender wallet.";
}
const loanAmount = decimalToAtomic(loanAmountInput?.value);
const collateralAmount = collateral.kind === "nft"
? ATOMIC_UNITS_PER_COIN
: decimalToAtomic(loanCollateralAmountInput?.value);
const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value);
const maxLateValue = decimalToAtomic(loanMaxLateValueInput?.value);
if (loanAmount === null || loanAmount <= 0n) {
return "Enter a valid loan amount.";
}
if (collateralAmount === null || collateralAmount <= 0n) {
return "Enter a valid collateral amount.";
}
if (paymentAmount === null || paymentAmount <= 0n) {
return "Enter a valid payment amount.";
}
if (maxLateValue === null) {
return "Enter a valid maximum overdue value.";
}
if (paymentAmount > loanAmount) {
return "A single payment cannot exceed the loan amount.";
}
const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10);
const gracePeriod = Number.parseInt(loanGracePeriodInput?.value || "0", 10);
if (!Number.isInteger(paymentNumber) || paymentNumber < 1 || paymentNumber > 255) {
return "Number of payments must be between 1 and 255.";
}
if (!Number.isInteger(gracePeriod) || gracePeriod < 0 || gracePeriod > paymentNumber) {
return "Missed payments allowed must be between 0 and the payment count.";
}
if (paymentAmount * BigInt(paymentNumber) < loanAmount) {
return "Scheduled payments must repay at least the loan principal.";
}
if (maxLateValue > loanAmount) {
return "Maximum overdue value cannot exceed the loan amount.";
}
const startTimestamp = loanStartTimestamp();
if (startTimestamp === null || startTimestamp > 4294967295) {
return "Enter a valid loan start date and time.";
}
if (loanAmount > loanAsset.balance) {
return "Loan amount exceeds the available asset balance.";
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(loanAsset.asset)) {
if (loanAmount + LOAN_FEE_ATOMIC > loanAsset.balance) {
return `The loan amount and ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`;
}
} else if (baseBalance < LOAN_FEE_ATOMIC) {
return `Creating a loan requires ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} for the lender fee.`;
}
return "";
}
function loanOfferPayload() {
const loanAsset = selectedLoanAsset();
const collateral = selectedLoanCollateral();
const collateralAmount = collateral.kind === "nft"
? ATOMIC_UNITS_PER_COIN
: decimalToAtomic(loanCollateralAmountInput.value);
return {
broadcastNode: activeBroadcastNode(),
loanCoin: loanAsset.asset,
loanAmount: String(decimalToAtomic(loanAmountInput.value)),
borrower: String(loanBorrowerInput.value || "").trim(),
collateral: collateral.wireName,
collateralAmount: String(collateralAmount),
paymentPeriod: String(loanPaymentPeriodSelect.value || "d"),
paymentNumber: Number.parseInt(loanPaymentNumberInput.value, 10),
paymentAmount: String(decimalToAtomic(loanPaymentAmountInput.value)),
gracePeriod: Number.parseInt(loanGracePeriodInput.value, 10),
maxLateValue: String(decimalToAtomic(loanMaxLateValueInput.value)),
startTimestamp: loanStartTimestamp()
};
}
function clearLoanOfferForm() {
loanAmountInput.value = "";
loanBorrowerInput.value = "";
loanCollateralAmountInput.value = "";
loanPaymentPeriodSelect.value = "d";
loanPaymentNumberInput.value = "1";
loanPaymentAmountInput.value = "";
loanGracePeriodInput.value = "0";
loanMaxLateValueInput.value = "";
loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000);
updateLoanOfferDerivedFields();
}
function setSavedLoanOfferPath(path = "") {
if (loanSavedPathInput) {
loanSavedPathInput.value = path;
}
loanSavedPathPanel?.classList.toggle("hidden", !path);
}
async function saveLoanOffer() {
const validationError = validateLoanOfferForm();
if (validationError) {
setLoanOfferMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setLoanOfferMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setLoanOfferMessage("Signing cancelled.");
return;
}
const button = document.querySelector("[data-loan-offer-save]");
button.disabled = true;
setSavedLoanOfferPath("");
setLoanOfferMessage("Signing and saving loan offer...");
try {
const result = await invoke("save_loan_offer", {
...loanOfferPayload(),
walletKey
});
clearLoanOfferForm();
setSavedLoanOfferPath(result.file_path || "");
setLoanOfferMessage(
`Loan offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the borrower for review and signature.`,
"success"
);
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} catch (error) {
setLoanOfferMessage(String(error), "error");
} finally {
button.disabled = false;
}
}
function prepareLoanOfferForm() {
if (!loanStartInput.value) {
loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000);
}
setLoanOfferMessage("");
loadLoanOfferAssets().catch(() => {
// The form displays the lookup error.
});
updateLoanOfferDerivedFields();
}
function resetLoanOfferSession() {
loanOfferLoadPromise = null;
loanCollateralAssets = [];
loanNftKeys = new Set();
loanOfferAssetsKey = "";
clearLoanOfferForm();
setSavedLoanOfferPath("");
loanAssetSelect.innerHTML = "";
loanCollateralSelect.innerHTML = "";
setLoanOfferMessage("");
}
[
loanAssetSelect,
loanCollateralSelect,
loanAmountInput,
loanCollateralAmountInput,
loanPaymentPeriodSelect,
loanPaymentNumberInput,
loanPaymentAmountInput,
loanGracePeriodInput,
loanMaxLateValueInput,
loanStartInput
].forEach((element) => element?.addEventListener("input", () => {
setLoanOfferMessage("");
updateLoanOfferDerivedFields();
}));
loanBorrowerInput?.addEventListener("input", () => setLoanOfferMessage(""));
document.querySelector("[data-loan-offer-save]")?.addEventListener("click", saveLoanOffer);
loanCopyPathButton?.addEventListener("click", async () => {
const path = String(loanSavedPathInput?.value || "");
if (!path || !(await writeClipboardText(path))) {
setLoanOfferMessage("Unable to copy the saved loan offer path.", "error");
return;
}
const original = loanCopyPathButton.textContent;
loanCopyPathButton.textContent = "Copied";
setTimeout(() => {
loanCopyPathButton.textContent = original;
}, 1200);
});

View File

@ -0,0 +1,287 @@
const LOAN_PAYMENT_FEE_ATOMIC = 1000000n;
let loanPaymentContracts = [];
let loanPaymentLoadPromise = null;
let loanPaymentContractsKey = "";
function setLoanPaymentMessage(message, type = "") {
setScopedTransactionMessage("Loan Payment", loanPaymentMessage, message, type);
}
function selectedLoanPaymentContract() {
const hash = String(loanPaymentContractSelect?.value || "");
return loanPaymentContracts.find((contract) => contract.contract === hash) || null;
}
function loanPaymentPeriodLabel(period) {
return {
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly"
}[String(period || "").toLowerCase()] || String(period || "Payment");
}
function populateLoanPaymentContracts() {
if (!loanPaymentContractSelect) {
return;
}
const previous = loanPaymentContractSelect.value;
loanPaymentContractSelect.innerHTML = "";
if (loanPaymentContracts.length) {
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select an active loan";
loanPaymentContractSelect.appendChild(placeholder);
}
loanPaymentContracts.forEach((contract) => {
const option = document.createElement("option");
option.value = contract.contract;
const remaining = formatAtomicBalance(contract.remaining_after_pending || "0");
option.textContent =
`${contract.loan_asset} loan from ${truncateMiddle(contract.lender, 8, 8)} (${remaining} remaining)`;
loanPaymentContractSelect.appendChild(option);
});
if (!loanPaymentContracts.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No active borrower loans";
loanPaymentContractSelect.appendChild(option);
} else if ([...loanPaymentContractSelect.options].some((option) => option.value === previous)) {
loanPaymentContractSelect.value = previous;
}
updateLoanPaymentDerivedFields(true);
}
async function loadLoanPaymentContracts(force = false) {
if (loanPaymentLoadPromise) {
return loanPaymentLoadPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
return;
}
const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (!force && loanPaymentContractsKey === key) {
populateLoanPaymentContracts();
return;
}
loanPaymentLoadPromise = (async () => {
setLoanPaymentMessage("Loading active loans...");
const contracts = await invoke("active_loan_contracts", { broadcastNode });
loanPaymentContracts = Array.isArray(contracts) ? contracts : [];
loanPaymentContractsKey = key;
populateLoanPaymentContracts();
setLoanPaymentMessage(
loanPaymentContracts.length ? "" : "No active loans require payment."
);
})().catch((error) => {
loanPaymentContracts = [];
populateLoanPaymentContracts();
setLoanPaymentMessage(String(error), "error");
throw error;
}).finally(() => {
loanPaymentLoadPromise = null;
});
return loanPaymentLoadPromise;
}
function expectedLoanPayment(contract) {
if (!contract) {
return 0n;
}
const scheduled = BigInt(contract.payment_amount || "0");
const remaining = BigInt(contract.remaining_after_pending || "0");
return scheduled < remaining ? scheduled : remaining;
}
function updateLoanPaymentWarning(contract, amount) {
if (!loanPaymentWarning) {
return;
}
const expected = expectedLoanPayment(contract);
const show = contract && amount !== null && amount > 0n && amount < expected;
loanPaymentWarning.classList.toggle("hidden", !show);
loanPaymentWarning.textContent = show
? `This is less than the expected ${atomicToDecimal(expected)} ${contract.loan_asset} payment. Partial payments are allowed, but the unpaid amount still counts toward the loan's overdue balance.`
: "";
}
function updateLoanPaymentDerivedFields(setSuggestedAmount = false) {
const contract = selectedLoanPaymentContract();
if (!contract) {
loanPaymentLender.textContent = "--";
loanPaymentAsset.textContent = "--";
loanPaymentScheduled.textContent = "--";
loanPaymentConfirmed.textContent = "--";
loanPaymentPending.textContent = "--";
loanPaymentRemaining.textContent = "--";
loanPaymentTipInput.value = "";
loanPaymentFeeInput.value = atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC);
updateLoanPaymentWarning(null, null);
return;
}
const expected = expectedLoanPayment(contract);
if (setSuggestedAmount || !loanPaymentValueInput.value) {
loanPaymentValueInput.value = atomicToDecimal(expected);
}
const amount = decimalToAtomic(loanPaymentValueInput.value);
const tip = amount !== null && amount > 0n ? (amount + 99n) / 100n : 0n;
loanPaymentLender.textContent = contract.lender;
loanPaymentAsset.textContent = contract.loan_asset;
loanPaymentScheduled.textContent =
`${loanPaymentPeriodLabel(contract.payment_period)}: ${atomicToDecimal(BigInt(contract.payment_amount || "0"))} ${contract.loan_asset}`;
loanPaymentConfirmed.textContent =
`${atomicToDecimal(BigInt(contract.confirmed_paid || "0"))} ${contract.loan_asset}`;
loanPaymentPending.textContent =
`${atomicToDecimal(BigInt(contract.pending_paid || "0"))} ${contract.loan_asset}`;
loanPaymentRemaining.textContent =
`${atomicToDecimal(BigInt(contract.remaining_after_pending || "0"))} ${contract.loan_asset}`;
loanPaymentTipInput.value = atomicToDecimal(tip);
loanPaymentFeeInput.value = atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC);
updateLoanPaymentWarning(contract, amount);
}
function loanPaymentBalance(asset) {
return activeWalletBalances.find((balance) => {
return String(balance.asset || "").trim().toLowerCase() === String(asset || "").trim().toLowerCase()
&& Number(balance.nft_series || 0) === 0;
});
}
function validateLoanPaymentForm() {
if (!walletLoaded) {
return "Load a wallet before making a loan payment.";
}
if (!registrationRegistered) {
return "Address registration is required before making a loan payment.";
}
const contract = selectedLoanPaymentContract();
if (!contract) {
return "Select an active loan.";
}
const amount = decimalToAtomic(loanPaymentValueInput?.value);
if (amount === null || amount <= 0n) {
return "Enter a valid payment amount.";
}
const remaining = BigInt(contract.remaining_after_pending || "0");
if (amount > remaining) {
return `Payment cannot exceed the remaining ${atomicToDecimal(remaining)} ${contract.loan_asset}.`;
}
const tip = (amount + 99n) / 100n;
const available = balanceAtomicValue(loanPaymentBalance(contract.loan_asset));
if (amount + tip > available) {
return `This wallet cannot cover the payment and 1% miner tip in ${contract.loan_asset}.`;
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(contract.loan_asset)) {
if (amount + tip + LOAN_PAYMENT_FEE_ATOMIC > available) {
return `This wallet cannot cover the payment, tip, and ${atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC)} ${activeBaseCoin()} fee.`;
}
} else if (baseBalance < LOAN_PAYMENT_FEE_ATOMIC) {
return `This payment requires ${atomicToDecimal(LOAN_PAYMENT_FEE_ATOMIC)} ${activeBaseCoin()} for the transaction fee.`;
}
return "";
}
function loanPaymentPayload() {
const contract = selectedLoanPaymentContract();
return {
broadcastNode: activeBroadcastNode(),
contractHash: contract.contract,
paybackAmount: String(decimalToAtomic(loanPaymentValueInput.value))
};
}
function setLoanPaymentButtonsDisabled(disabled) {
document.querySelector("[data-loan-payment-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-loan-payment-broadcast]")?.toggleAttribute("disabled", disabled);
}
function clearLoanPaymentForm() {
loanPaymentContractSelect.value = "";
loanPaymentValueInput.value = "";
updateLoanPaymentDerivedFields(false);
}
async function handleLoanPaymentAction(action) {
const validationError = validateLoanPaymentForm();
if (validationError) {
setLoanPaymentMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setLoanPaymentMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setLoanPaymentMessage("Signing cancelled.");
return;
}
setLoanPaymentButtonsDisabled(true);
try {
if (action === "save") {
setLoanPaymentMessage("Saving loan payment transaction...");
const review = await invoke("save_loan_payment_transaction", {
...loanPaymentPayload(),
walletKey
});
clearLoanPaymentForm();
setLoanPaymentMessage(`Loan payment saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
setLoanPaymentMessage("Broadcasting loan payment...");
const result = await invoke("broadcast_loan_payment_transaction", {
...loanPaymentPayload(),
walletKey
});
await loadLoanPaymentContracts(true);
clearLoanPaymentForm();
setLoanPaymentMessage(result.response || "Loan payment broadcast.", "success");
}
} catch (error) {
setLoanPaymentMessage(String(error), "error");
await loadLoanPaymentContracts(true).catch(() => {});
} finally {
setLoanPaymentButtonsDisabled(false);
}
}
function prepareLoanPaymentForm() {
setLoanPaymentMessage("");
loadLoanPaymentContracts(false).catch(() => {
// The form displays the lookup error.
});
}
function resetLoanPaymentSession() {
loanPaymentContracts = [];
loanPaymentLoadPromise = null;
loanPaymentContractsKey = "";
loanPaymentContractSelect.innerHTML = "";
loanPaymentValueInput.value = "";
updateLoanPaymentDerivedFields(false);
setLoanPaymentMessage("");
}
loanPaymentContractSelect?.addEventListener("change", () => {
setLoanPaymentMessage("");
updateLoanPaymentDerivedFields(true);
});
loanPaymentValueInput?.addEventListener("input", () => {
setLoanPaymentMessage("");
updateLoanPaymentDerivedFields(false);
});
document
.querySelector("[data-loan-payment-save]")
?.addEventListener("click", () => handleLoanPaymentAction("save"));
document
.querySelector("[data-loan-payment-broadcast]")
?.addEventListener("click", () => handleLoanPaymentAction("broadcast"));

488
frontend/js/loans_page.js Normal file
View File

@ -0,0 +1,488 @@
let loansContracts = [];
let loansLoadedForKey = "";
let loansLoadInFlight = false;
let selectedLoanContractHash = "";
let activeLoansStatusFilter = "all";
let loansStateMessage = "";
function loanFormatAtomic(value) {
if (typeof atomicToDecimal === "function") {
return atomicToDecimal(BigInt(value || "0"));
}
if (typeof formatAtomicBalance === "function") {
return formatAtomicBalance(value || "0");
}
return String(value || "0");
}
function loanShort(value, left = 8, right = 8) {
if (typeof truncateMiddle === "function") {
return truncateMiddle(value, left, right);
}
const text = String(value || "");
return text.length > left + right + 3 ? `${text.slice(0, left)}...${text.slice(-right)}` : text;
}
function escapeLoanText(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function activeLoanRole(contract) {
const active = String(activeWalletAddressShort || "").trim().toLowerCase();
if (String(contract.lender || "").trim().toLowerCase() === active) {
return "lender";
}
if (String(contract.borrower || "").trim().toLowerCase() === active) {
return "borrower";
}
return "unknown";
}
function loanRoleLabel(role) {
return role === "lender" ? "Lending" : role === "borrower" ? "Borrowing" : "Related";
}
function loanCounterparty(contract) {
return activeLoanRole(contract) === "lender" ? contract.borrower : contract.lender;
}
function loanStatusClass(status) {
const normalized = String(status || "").toLowerCase();
if (normalized === "delinquent") {
return "due";
}
if (normalized === "inactive") {
return "claimable";
}
return "active";
}
function loanStatusLabel(status) {
const normalized = String(status || "").toLowerCase();
if (normalized === "inactive") {
return "Completed";
}
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Unknown";
}
function loanPaymentCount(contract) {
return Array.isArray(contract.payments) ? contract.payments.length : 0;
}
function loanAmountLabel(value, asset) {
return `${loanFormatAtomic(value)} ${String(asset || "").trim()}`.trim();
}
function addUtcMonths(timestampSeconds, monthsToAdd) {
const start = new Date(Number(timestampSeconds || 0) * 1000);
if (Number.isNaN(start.getTime())) {
return null;
}
const year = start.getUTCFullYear();
const month = start.getUTCMonth() + Number(monthsToAdd || 0);
const day = start.getUTCDate();
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
return new Date(Date.UTC(
year,
month,
Math.min(day, lastDay),
start.getUTCHours(),
start.getUTCMinutes(),
start.getUTCSeconds()
));
}
function loanDueDateForPayment(contract, paymentIndex) {
const startTimestamp = Number(contract.start_timestamp || 0);
if (!startTimestamp || !paymentIndex) {
return null;
}
const period = String(contract.payment_period || "").trim().toLowerCase();
if (period === "daily") {
return new Date((startTimestamp + paymentIndex * 86_400) * 1000);
}
if (period === "weekly") {
return new Date((startTimestamp + paymentIndex * 7 * 86_400) * 1000);
}
if (period === "monthly") {
return addUtcMonths(startTimestamp, paymentIndex);
}
return null;
}
function formatLoanDueDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "--";
}
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short"
});
}
function nextLoanPaymentDueLabel(contract) {
if (String(contract.status || "").toLowerCase() === "inactive") {
return "No remaining payments";
}
const paymentAmount = BigInt(contract.payment_amount || "0");
const confirmedPaid = BigInt(contract.confirmed_paid || "0");
const paymentNumber = Number(contract.payment_number || 0);
if (!paymentNumber || paymentAmount <= 0n) {
return "--";
}
const completedPayments = Number(confirmedPaid / paymentAmount);
if (completedPayments >= paymentNumber) {
return "No remaining payments";
}
return formatLoanDueDate(loanDueDateForPayment(contract, completedPayments + 1));
}
function sortedLoans() {
const query = String(loansSearch?.value || "").trim().toLowerCase();
const roleFilter = loansRoleFilter?.value || "all";
return loansContracts
.filter((contract) => {
const role = activeLoanRole(contract);
const status = String(contract.status || "").toLowerCase();
const searchText = [
contract.contract,
contract.lender,
contract.borrower,
contract.loan_asset,
contract.collateral,
status,
role
].join(" ").toLowerCase();
if (roleFilter !== "all" && role !== roleFilter) {
return false;
}
if (activeLoansStatusFilter !== "all" && status !== activeLoansStatusFilter) {
return false;
}
return !query || searchText.includes(query);
})
.sort((left, right) => Number(right.start_timestamp || 0) - Number(left.start_timestamp || 0));
}
function renderLoanCard(contract) {
const role = activeLoanRole(contract);
const selected = contract.contract === selectedLoanContractHash;
const counterparty = loanCounterparty(contract);
const principal = loanAmountLabel(contract.loan_amount, contract.loan_asset);
const remaining = loanAmountLabel(contract.remaining_after_pending, contract.loan_asset);
const payments = `${loanPaymentCount(contract)} / ${contract.payment_number || 0}`;
const nextDue = nextLoanPaymentDueLabel(contract);
return `
<button class="loan-card ${selected ? "active" : ""}" type="button" data-loan-contract="${escapeLoanText(contract.contract)}">
<div class="loan-card-top">
<div>
<strong>Loan ${escapeLoanText(loanShort(contract.contract, 6, 6))}</strong>
<span>${activeLoanRole(contract) === "lender" ? "Borrower" : "Lender"} ${escapeLoanText(loanShort(counterparty, 8, 8))}</span>
</div>
<div class="loan-badges">
<span class="role-badge ${role === "lender" ? "lending" : "borrowing"}">${escapeLoanText(loanRoleLabel(role))}</span>
<span class="loan-status ${loanStatusClass(contract.status)}">${escapeLoanText(loanStatusLabel(contract.status))}</span>
</div>
</div>
<div class="loan-metrics">
<div><span>Principal</span><b>${escapeLoanText(principal)}</b></div>
<div><span>Remaining</span><b>${escapeLoanText(remaining)}</b></div>
<div><span>Payments</span><b>${escapeLoanText(payments)}</b></div>
</div>
<div class="loan-next-due">
<span>Next payment due by:</span>
<b>${escapeLoanText(nextDue)}</b>
</div>
</button>
`;
}
function selectedLoanContract() {
return loansContracts.find((contract) => contract.contract === selectedLoanContractHash) || null;
}
function detailItem(label, value) {
return `<div><span>${escapeLoanText(label)}</span><b>${escapeLoanText(value || "--")}</b></div>`;
}
function renderLoanPaymentHistory(contract) {
if (!loanPaymentHistory) {
return;
}
const payments = Array.isArray(contract?.payments) ? contract.payments : [];
if (!payments.length) {
loanPaymentHistory.innerHTML = '<div class="subtle-state">No confirmed payments recorded for this loan.</div>';
return;
}
loanPaymentHistory.innerHTML = `
<table>
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Payer</th>
<th>Tx Hash</th>
</tr>
</thead>
<tbody>
${payments.map((payment) => `
<tr>
<td>${escapeLoanText(payment.date || "--")}</td>
<td>${escapeLoanText(payment.amount || "0")} ${escapeLoanText(contract.loan_asset || "")}</td>
<td>${escapeLoanText(loanShort(payment.payer || "", 8, 8))}</td>
<td>${escapeLoanText(loanShort(payment.txid || "", 8, 8))}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
function renderLoanDetail() {
const contract = selectedLoanContract();
if (!loanDetailGrid) {
return;
}
if (!contract) {
loanDetailGrid.innerHTML = detailItem("Selected Loan", "Select a loan to view details.");
if (loanPaymentHistory) {
loanPaymentHistory.innerHTML = '<div class="subtle-state">No loan selected.</div>';
}
loanCopyContractButton?.toggleAttribute("disabled", true);
loanMakePaymentButton?.toggleAttribute("disabled", true);
loanClaimCollateralButton?.toggleAttribute("disabled", true);
return;
}
const role = activeLoanRole(contract);
const collateral = loanAmountLabel(contract.collateral_amount, contract.collateral);
const payment = loanAmountLabel(contract.payment_amount, contract.loan_asset);
const confirmed = loanAmountLabel(contract.confirmed_paid, contract.loan_asset);
const pending = loanAmountLabel(contract.pending_paid, contract.loan_asset);
const remaining = loanAmountLabel(contract.remaining_after_pending, contract.loan_asset);
loanDetailGrid.innerHTML = [
detailItem("Contract Hash", contract.contract),
detailItem("Role", loanRoleLabel(role)),
detailItem("Status", loanStatusLabel(contract.status)),
detailItem("Principal", loanAmountLabel(contract.loan_amount, contract.loan_asset)),
detailItem("Payment Amount", payment),
detailItem("Collateral", collateral),
detailItem("Payment Schedule", `${contract.payment_number || 0} ${contract.payment_period || "payments"}`),
detailItem("Confirmed Paid", confirmed),
detailItem("Pending Total", pending),
detailItem("Remaining", remaining),
detailItem("Lender", contract.lender),
detailItem("Borrower", contract.borrower)
].join("");
renderLoanPaymentHistory(contract);
loanCopyContractButton?.toggleAttribute("disabled", false);
loanMakePaymentButton?.toggleAttribute(
"disabled",
!(role === "borrower" && String(contract.status || "").toLowerCase() !== "inactive")
);
loanClaimCollateralButton?.toggleAttribute("disabled", String(contract.status || "").toLowerCase() === "inactive");
}
function renderLoansPage() {
if (!loansList) {
return;
}
const rows = sortedLoans();
if (!selectedLoanContractHash && rows.length) {
selectedLoanContractHash = rows[0].contract;
}
if (selectedLoanContractHash && !loansContracts.some((contract) => contract.contract === selectedLoanContractHash)) {
selectedLoanContractHash = rows[0]?.contract || "";
}
loansList.innerHTML = rows.map(renderLoanCard).join("");
if (!walletLoaded) {
loansState && (loansState.textContent = "Load a wallet to view loans.");
} else if (loansLoadInFlight && !loansContracts.length) {
loansState && (loansState.textContent = "Loading loans...");
} else if (loansStateMessage) {
loansState && (loansState.textContent = loansStateMessage);
} else if (!loansContracts.length) {
loansState && (loansState.textContent = "No loan contracts found for this wallet.");
} else if (!rows.length) {
loansState && (loansState.textContent = "No loans match the current filters.");
} else {
loansState && (loansState.textContent = `${rows.length} loan${rows.length === 1 ? "" : "s"} shown`);
}
renderLoanDetail();
}
async function loadLoansPage({ force = false } = {}) {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!walletLoaded) {
loansContracts = [];
selectedLoanContractHash = "";
loansStateMessage = "Load a wallet to view loans.";
renderLoansPage();
return;
}
if (!invoke) {
loansContracts = [];
selectedLoanContractHash = "";
loansStateMessage = "Loan lookup skipped: Tauri invoke is unavailable.";
renderLoansPage();
return;
}
if (!broadcastNode) {
loansContracts = [];
selectedLoanContractHash = "";
loansStateMessage = "Loan lookup skipped: no broadcast node is selected.";
renderLoansPage();
return;
}
const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (!force && loansLoadedForKey === key) {
renderLoansPage();
return;
}
if (loansLoadInFlight) {
return;
}
loansLoadInFlight = true;
loansStateMessage = "Loading loans...";
renderLoansPage();
try {
const contracts = await invokeWalletRpcWithBackoff(
"loan contracts",
() => invoke("loan_contracts", { broadcastNode }),
() => currentView === "loans" && activeBroadcastNode() === broadcastNode
);
loansContracts = Array.isArray(contracts) ? contracts : [];
loansLoadedForKey = key;
selectedLoanContractHash = loansContracts[0]?.contract || "";
loansStateMessage = loansContracts.length
? ""
: "Loan contract command returned 0 contracts for this wallet.";
} catch (error) {
loansContracts = [];
selectedLoanContractHash = "";
loansStateMessage = `Loan contract lookup failed: ${String(error)}`;
} finally {
loansLoadInFlight = false;
renderLoansPage();
}
}
function prepareLoansPage({ force = false } = {}) {
renderLoansPage();
loadLoansPage({ force });
}
function resetLoansPage() {
loansContracts = [];
loansLoadedForKey = "";
loansLoadInFlight = false;
selectedLoanContractHash = "";
activeLoansStatusFilter = "all";
loansStateMessage = "";
loansSearch && (loansSearch.value = "");
loansRoleFilter && (loansRoleFilter.value = "all");
loansStatusButtons.forEach((button) => {
button.classList.toggle("active", button.dataset.loansStatusFilter === "all");
});
renderLoansPage();
}
loansSearch?.addEventListener("input", renderLoansPage);
loansRoleFilter?.addEventListener("change", renderLoansPage);
loansRefreshButton?.addEventListener("click", () => loadLoansPage({ force: true }));
loansCreateButton?.addEventListener("click", () => {
setActiveView("send");
setActiveTransactionType("Create Loan");
});
loansStatusButtons.forEach((button) => {
button.addEventListener("click", () => {
activeLoansStatusFilter = button.dataset.loansStatusFilter || "all";
loansStatusButtons.forEach((item) => item.classList.toggle("active", item === button));
selectedLoanContractHash = "";
renderLoansPage();
});
});
loansList?.addEventListener("click", (event) => {
const card = event.target.closest("[data-loan-contract]");
if (!card) {
return;
}
selectedLoanContractHash = card.dataset.loanContract || "";
renderLoansPage();
});
loanCopyContractButton?.addEventListener("click", async () => {
const contract = selectedLoanContract();
if (!contract) {
return;
}
const copied = typeof writeClipboardText === "function"
? await writeClipboardText(contract.contract)
: false;
if (!copied) {
return;
}
const original = loanCopyContractButton.textContent;
loanCopyContractButton.textContent = "Copied";
setTimeout(() => {
loanCopyContractButton.textContent = original;
}, 900);
});
loanMakePaymentButton?.addEventListener("click", () => {
const contract = selectedLoanContract();
if (typeof openTransactionType === "function") {
openTransactionType("Loan Payment");
} else {
setActiveView("send");
setActiveTransactionType("Loan Payment");
showTransactionForm("Loan Payment");
}
const selectContract = () => {
if (contract?.contract && loanPaymentContractSelect) {
loanPaymentContractSelect.value = contract.contract;
updateLoanPaymentDerivedFields(true);
}
};
if (typeof loadLoanPaymentContracts === "function") {
loadLoanPaymentContracts(false).then(selectContract).catch(() => {});
} else {
setTimeout(selectContract, 0);
}
});
loanClaimCollateralButton?.addEventListener("click", () => {
const contract = selectedLoanContract();
if (typeof openTransactionType === "function") {
openTransactionType("Collateral Claim");
} else {
setActiveView("send");
setActiveTransactionType("Collateral Claim");
showTransactionForm("Collateral Claim");
}
const selectContract = () => {
if (contract?.contract && collateralClaimContractSelect) {
collateralClaimContractSelect.value = contract.contract;
updateCollateralClaimDetails();
}
};
if (typeof loadCollateralClaimContracts === "function") {
loadCollateralClaimContracts(false).then(selectContract).catch(() => {});
} else {
setTimeout(selectContract, 0);
}
});

View File

@ -0,0 +1,290 @@
const MARKETING_CAMPAIGN_PAGE_SIZE = 100;
let marketingCampaignPage = 1;
let marketingCampaignTotalRows = 0;
let marketingCampaignLastQuery = null;
let marketingCampaignLoading = false;
function setMarketingCampaignState(message, mode = "") {
if (!marketingCampaignState) {
return;
}
marketingCampaignState.textContent = message;
marketingCampaignState.classList.toggle("hidden", !message);
marketingCampaignState.classList.toggle("loading", mode === "loading");
marketingCampaignState.classList.toggle("error", mode === "error");
}
function clearMarketingCampaignRows() {
if (marketingCampaignRows) {
marketingCampaignRows.innerHTML = "";
}
if (marketingCampaignCards) {
marketingCampaignCards.innerHTML = "";
}
}
function marketingBigInt(value) {
try {
return BigInt(String(value || "0"));
} catch (_) {
return 0n;
}
}
function formatMarketingInteger(value) {
const text = marketingBigInt(value).toString();
return text.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatMarketingCents(value) {
const cents = marketingBigInt(value);
const sign = cents < 0n ? "-" : "";
const absolute = cents < 0n ? -cents : cents;
const whole = absolute / 100n;
const fraction = String(absolute % 100n).padStart(2, "0");
return `${sign}${formatMarketingInteger(whole)}.${fraction}`;
}
function formatMarketingAverage(totalValue, count) {
const total = marketingBigInt(totalValue);
const divisor = marketingBigInt(count);
if (divisor === 0n) {
return "--";
}
return formatMarketingCents(total / divisor);
}
function setMarketingCampaignSummary(data = null) {
const totalRecords = marketingBigInt(data?.total_records);
const totalImpressions = marketingBigInt(data?.total_impressions);
const totalClicks = marketingBigInt(data?.total_clicks);
const totalImpressionValue = marketingBigInt(data?.total_impression_value);
const totalClickValue = marketingBigInt(data?.total_click_value);
const totalValue = totalImpressionValue + totalClickValue;
if (marketingTotalRecords) {
marketingTotalRecords.textContent = data ? formatMarketingInteger(totalRecords) : "--";
}
if (marketingTotalImpressions) {
marketingTotalImpressions.textContent = data ? formatMarketingInteger(totalImpressions) : "--";
}
if (marketingTotalClicks) {
marketingTotalClicks.textContent = data ? formatMarketingInteger(totalClicks) : "--";
}
if (marketingAvgImpressionValue) {
marketingAvgImpressionValue.textContent = data
? formatMarketingAverage(totalImpressionValue, totalImpressions)
: "--";
}
if (marketingAvgClickValue) {
marketingAvgClickValue.textContent = data
? formatMarketingAverage(totalClickValue, totalClicks)
: "--";
}
if (marketingTotalValue) {
marketingTotalValue.textContent = data ? formatMarketingCents(totalValue) : "--";
}
}
function setMarketingCampaignFooter(visibleCount = 0, totalCount = 0, page = 1) {
const totalPages = Math.max(1, Math.ceil(totalCount / MARKETING_CAMPAIGN_PAGE_SIZE));
const pageStart = totalCount && visibleCount ? (page - 1) * MARKETING_CAMPAIGN_PAGE_SIZE : 0;
if (marketingCampaignCount) {
marketingCampaignCount.textContent = totalCount
? `Showing ${formatMarketingInteger(pageStart + 1)}-${formatMarketingInteger(pageStart + visibleCount)} of ${formatMarketingInteger(totalCount)}`
: "";
}
if (marketingCampaignPagination) {
marketingCampaignPagination.hidden = totalPages <= 1;
marketingCampaignPagination.querySelectorAll("[data-marketing-campaign-page]").forEach((button) => {
const action = button.dataset.marketingCampaignPage;
button.disabled = action === "first" || action === "previous"
? page <= 1
: page >= totalPages;
});
}
if (marketingCampaignPageInput) {
marketingCampaignPageInput.value = String(page);
marketingCampaignPageInput.max = String(totalPages);
marketingCampaignPageInput.disabled = totalPages <= 1;
}
if (marketingCampaignPageTotal) {
marketingCampaignPageTotal.textContent = formatNumber(totalPages);
}
}
function marketingStatusClass(status) {
return String(status || "").toLowerCase() === "pending" ? "pending" : "confirmed";
}
function renderMarketingCampaignRows(rows = []) {
clearMarketingCampaignRows();
const tableFragment = document.createDocumentFragment();
const cardFragment = document.createDocumentFragment();
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${formatHistoryDate(row.time)}</td>
<td>${htmlEscape(row.campaign)}</td>
<td>${htmlEscape(row.ad_type)}</td>
<td>${htmlEscape(row.keyword)}</td>
<td class="marketing-displayed-cell" title="${htmlEscape(row.displayed)}">${htmlEscape(row.displayed)}</td>
<td>${formatMarketingInteger(row.impression || 0)}</td>
<td>${formatMarketingInteger(row.click || 0)}</td>
<td>${formatMarketingCents(row.impression_value || 0)}</td>
<td>${formatMarketingCents(row.click_value || 0)}</td>
<td><span class="table-status ${marketingStatusClass(row.status)}">${htmlEscape(row.status)}</span></td>
`;
tableFragment.appendChild(tr);
const card = document.createElement("article");
card.className = "history-card";
card.innerHTML = `
<div><span>Time</span><b>${formatHistoryDate(row.time)}</b></div>
<div><span>Campaign</span><b>${htmlEscape(row.campaign)}</b></div>
<div><span>Ad Type</span><b>${htmlEscape(row.ad_type)}</b></div>
<div><span>Keyword</span><b>${htmlEscape(row.keyword)}</b></div>
<div><span>Displayed</span><b>${htmlEscape(row.displayed)}</b></div>
<div><span>Impressions</span><b>${formatMarketingInteger(row.impression || 0)}</b></div>
<div><span>Clicks</span><b>${formatMarketingInteger(row.click || 0)}</b></div>
<div><span>Status</span><b>${htmlEscape(row.status)}</b></div>
`;
cardFragment.appendChild(card);
});
marketingCampaignRows?.appendChild(tableFragment);
marketingCampaignCards?.appendChild(cardFragment);
}
function validateMarketingCampaignQuery() {
const advertiser = String(marketingCampaignAdvertiserInput?.value || "").trim();
const campaign = String(marketingCampaignIdInput?.value || "").trim();
if (!walletLoaded) {
return { error: "Load a wallet before viewing campaign data." };
}
if (!activeBroadcastNode()) {
return { error: "Select a broadcast node before viewing campaign data." };
}
if (!advertiser) {
return { error: "Agency wallet address is required." };
}
if (!/^\d+$/.test(campaign)) {
return { error: "Campaign ID must be a whole number." };
}
return { advertiser, campaign };
}
async function loadMarketingCampaignPage(page = marketingCampaignPage) {
if (marketingCampaignLoading) {
return;
}
const query = validateMarketingCampaignQuery();
if (query.error) {
clearMarketingCampaignRows();
setMarketingCampaignSummary(null);
setMarketingCampaignFooter(0, 0, 1);
setMarketingCampaignState(query.error, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setMarketingCampaignState("Wallet bridge is not available.", "error");
return;
}
marketingCampaignLoading = true;
marketingCampaignLoadButton?.toggleAttribute("disabled", true);
marketingCampaignRefreshButton?.toggleAttribute("disabled", true);
setMarketingCampaignState("Loading campaign activity...", "loading");
try {
const normalizedPage = Math.max(1, Number.parseInt(page || 1, 10));
const skip = (normalizedPage - 1) * MARKETING_CAMPAIGN_PAGE_SIZE;
const data = await invoke("marketing_campaign_history", {
broadcastNode: activeBroadcastNode(),
advertiser: query.advertiser,
campaign: query.campaign,
skip,
limit: MARKETING_CAMPAIGN_PAGE_SIZE
});
marketingCampaignLastQuery = query;
marketingCampaignPage = normalizedPage;
marketingCampaignTotalRows = Number(marketingBigInt(data?.total_records));
setMarketingCampaignSummary(data);
renderMarketingCampaignRows(data?.rows || []);
setMarketingCampaignFooter((data?.rows || []).length, marketingCampaignTotalRows, marketingCampaignPage);
setMarketingCampaignState((data?.rows || []).length ? "" : "No campaign records found.");
} catch (error) {
setMarketingCampaignState(String(error), "error");
} finally {
marketingCampaignLoading = false;
marketingCampaignLoadButton?.toggleAttribute("disabled", false);
marketingCampaignRefreshButton?.toggleAttribute("disabled", false);
}
}
function prepareMarketingCampaignPage() {
if (!marketingCampaignLastQuery) {
setMarketingCampaignSummary(null);
setMarketingCampaignFooter(0, 0, 1);
if (!marketingCampaignRows?.children.length) {
setMarketingCampaignState("Enter an agency wallet and campaign ID to load campaign activity.");
}
}
}
function resetMarketingCampaignPage() {
marketingCampaignPage = 1;
marketingCampaignTotalRows = 0;
marketingCampaignLastQuery = null;
marketingCampaignLoading = false;
if (marketingCampaignAdvertiserInput) {
marketingCampaignAdvertiserInput.value = "";
}
if (marketingCampaignIdInput) {
marketingCampaignIdInput.value = "";
}
clearMarketingCampaignRows();
setMarketingCampaignSummary(null);
setMarketingCampaignFooter(0, 0, 1);
setMarketingCampaignState("Enter an agency wallet and campaign ID to load campaign activity.");
}
marketingCampaignForm?.addEventListener("submit", (event) => {
event.preventDefault();
marketingCampaignPage = 1;
loadMarketingCampaignPage(1);
});
marketingCampaignRefreshButton?.addEventListener("click", () => {
loadMarketingCampaignPage(marketingCampaignPage);
});
marketingCampaignPagination?.querySelectorAll("[data-marketing-campaign-page]").forEach((button) => {
button.addEventListener("click", () => {
const action = button.dataset.marketingCampaignPage;
const totalPages = Math.max(1, Math.ceil(marketingCampaignTotalRows / MARKETING_CAMPAIGN_PAGE_SIZE));
if (action === "first") {
loadMarketingCampaignPage(1);
} else if (action === "previous") {
loadMarketingCampaignPage(Math.max(1, marketingCampaignPage - 1));
} else if (action === "next") {
loadMarketingCampaignPage(Math.min(totalPages, marketingCampaignPage + 1));
} else if (action === "last") {
loadMarketingCampaignPage(totalPages);
}
});
});
marketingCampaignPageInput?.addEventListener("change", () => {
const totalPages = Math.max(1, Math.ceil(marketingCampaignTotalRows / MARKETING_CAMPAIGN_PAGE_SIZE));
const requestedPage = Math.min(
totalPages,
Math.max(1, Number.parseInt(marketingCampaignPageInput.value || "1", 10))
);
loadMarketingCampaignPage(requestedPage);
});

View File

@ -0,0 +1,188 @@
const MARKETING_FEE_ATOMIC = 100000000n;
function setMarketingMessage(message, type = "") {
setScopedTransactionMessage("Marketing", marketingMessage, message, type);
}
function decimalToCents(value) {
const text = String(value || "").trim();
if (!/^\d+(\.\d{0,2})?$/.test(text)) {
return null;
}
const [whole, fraction = ""] = text.split(".");
const cents = Number.parseInt(whole || "0", 10) * 100
+ Number.parseInt(fraction.padEnd(2, "0") || "0", 10);
return Number.isInteger(cents) && cents >= 0 && cents <= 65535 ? cents : null;
}
function updateMarketingDerivedFields() {
if (marketingFeeInput) {
marketingFeeInput.value = atomicToDecimal(MARKETING_FEE_ATOMIC);
}
if (marketingAdvertiser) {
marketingAdvertiser.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (marketingFeeLabel) {
marketingFeeLabel.textContent = `${atomicToDecimal(MARKETING_FEE_ATOMIC)} ${activeBaseCoin()}`;
}
}
function marketingCountValue(input) {
const value = Number.parseInt(input?.value || "0", 10);
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : null;
}
function validateMarketingForm() {
if (!walletLoaded) {
return "Load a wallet before creating a marketing transaction.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a marketing transaction.";
}
const campaign = String(marketingCampaignInput?.value || "").trim();
if (!/^\d+$/.test(campaign)) {
return "Campaign ID must be a whole number.";
}
const keyword = String(marketingKeywordInput?.value || "").trim();
if (!keyword) {
return "Keyword is required.";
}
if (!/^[\x20-\x7E]+$/.test(keyword) || keyword.length > 40) {
return "Keyword must be ASCII and no longer than 40 characters.";
}
const displayed = String(marketingDisplayedInput?.value || "").trim();
if (!displayed) {
return "Displayed location is required.";
}
if (!/^[\x20-\x7E]+$/.test(displayed) || displayed.length > 100) {
return "Displayed location must be ASCII and no longer than 100 characters.";
}
if (marketingCountValue(marketingImpressionInput) === null) {
return "Impressions must be between 0 and 255.";
}
if (marketingCountValue(marketingClickInput) === null) {
return "Clicks must be between 0 and 255.";
}
if (decimalToCents(marketingImpressionValueInput?.value) === null) {
return "Impression value must be between 0.00 and 655.35.";
}
if (decimalToCents(marketingClickValueInput?.value) === null) {
return "Click value must be between 0.00 and 655.35.";
}
if (baseCoinBalanceAtomic() < MARKETING_FEE_ATOMIC) {
return `Marketing transactions require ${atomicToDecimal(MARKETING_FEE_ATOMIC)} ${activeBaseCoin()} for the fee.`;
}
return "";
}
function marketingPayload() {
return {
campaign: String(marketingCampaignInput.value || "0").trim(),
adType: marketingAdTypeSelect.value,
keyword: String(marketingKeywordInput.value || "").trim(),
displayed: String(marketingDisplayedInput.value || "").trim(),
impression: marketingCountValue(marketingImpressionInput),
click: marketingCountValue(marketingClickInput),
impressionValue: decimalToCents(marketingImpressionValueInput.value),
clickValue: decimalToCents(marketingClickValueInput.value),
txfee: String(MARKETING_FEE_ATOMIC)
};
}
function clearMarketingForm() {
marketingCampaignInput.value = "";
marketingAdTypeSelect.value = "banner";
marketingKeywordInput.value = "";
marketingDisplayedInput.value = "";
marketingImpressionInput.value = "0";
marketingClickInput.value = "0";
marketingImpressionValueInput.value = "";
marketingClickValueInput.value = "";
updateMarketingDerivedFields();
}
function setMarketingButtonsDisabled(disabled) {
document.querySelector("[data-marketing-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-marketing-broadcast]")?.toggleAttribute("disabled", disabled);
}
async function handleMarketingAction(action) {
const validationError = validateMarketingForm();
if (validationError) {
setMarketingMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setMarketingMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setMarketingMessage("Signing cancelled.");
return;
}
setMarketingButtonsDisabled(true);
try {
if (action === "save") {
setMarketingMessage("Saving marketing transaction...");
const review = await invoke("save_marketing_transaction", {
...marketingPayload(),
walletKey
});
clearMarketingForm();
setMarketingMessage(`Marketing transaction saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setMarketingMessage("Broadcasting marketing transaction...");
const result = await invoke("broadcast_marketing_transaction", {
...marketingPayload(),
broadcastNode,
walletKey
});
clearMarketingForm();
setMarketingMessage(result.response || "Marketing transaction broadcast.", "success");
}
} catch (error) {
setMarketingMessage(String(error), "error");
} finally {
setMarketingButtonsDisabled(false);
}
}
function prepareMarketingForm() {
updateMarketingDerivedFields();
setMarketingMessage("");
}
function resetMarketingSession() {
clearMarketingForm();
setMarketingMessage("");
}
[
marketingCampaignInput,
marketingAdTypeSelect,
marketingKeywordInput,
marketingDisplayedInput,
marketingImpressionInput,
marketingClickInput,
marketingImpressionValueInput,
marketingClickValueInput
].forEach((element) => {
element?.addEventListener("input", () => setMarketingMessage(""));
element?.addEventListener("change", () => setMarketingMessage(""));
});
document
.querySelector("[data-marketing-save]")
?.addEventListener("click", () => handleMarketingAction("save"));
document
.querySelector("[data-marketing-broadcast]")
?.addEventListener("click", () => handleMarketingAction("broadcast"));

View File

@ -0,0 +1,154 @@
const nodeInfoButton = document.querySelector("[data-node-info]");
const nodeModal = document.querySelector("[data-node-modal]");
const nodeCloseButton = document.querySelector("[data-node-close]");
const nftInstructionsOpenButton = document.querySelector("[data-nft-instructions-open]");
const nftInstructionsModal = document.querySelector("[data-nft-instructions-modal]");
const nftInstructionsCloseButton = document.querySelector("[data-nft-instructions-close]");
function toggleNodeModal(open) {
if (!nodeModal) {
return;
}
nodeModal.classList.toggle("hidden", !open);
if (open) {
startNodeInfoRefresh();
return;
}
stopNodeInfoRefresh();
}
function toggleNftInstructionsModal(open) {
nftInstructionsModal?.classList.toggle("hidden", !open);
}
nodeInfoButton?.addEventListener("click", () => toggleNodeModal(true));
nodeCloseButton?.addEventListener("click", () => toggleNodeModal(false));
nodeModal?.addEventListener("click", (event) => {
if (event.target === nodeModal) {
toggleNodeModal(false);
}
});
nftInstructionsOpenButton?.addEventListener("click", () => toggleNftInstructionsModal(true));
nftInstructionsCloseButton?.addEventListener("click", () => toggleNftInstructionsModal(false));
nftInstructionsModal?.addEventListener("click", (event) => {
if (event.target === nftInstructionsModal) {
toggleNftInstructionsModal(false);
}
});
document.querySelectorAll(".nft-hosting-links a").forEach((link) => {
link.addEventListener("click", async (event) => {
event.preventDefault();
const url = link.href;
const invoke = window.__TAURI__?.core?.invoke;
if (invoke) {
try {
await invoke("open_external_url", { url });
return;
} catch (error) {
console.error(error);
}
}
window.open(url, "_blank", "noopener,noreferrer");
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
toggleNodeModal(false);
toggleNftInstructionsModal(false);
if (!walletSwitchModal?.classList.contains("hidden")) {
closeWalletSwitchModal();
}
if (!historyExportModal?.classList.contains("hidden")) {
toggleHistoryExportModal(false);
}
if (!backupKeyModal?.classList.contains("hidden")) {
closeBackupKeyModal(null);
}
}
});
topWalletListSelect?.addEventListener("change", () => {
const selectedWalletPath = topWalletListSelect.value;
if (!selectedWalletPath || selectedWalletPath === activeWalletPath) {
restoreActiveWalletSelection();
return;
}
openWalletSwitchModal(selectedWalletPath);
});
walletSwitchForm?.addEventListener("submit", async (event) => {
event.preventDefault();
if (!pendingWalletSwitch) {
closeWalletSwitchModal();
return;
}
const walletKey = walletSwitchKeyInput?.value || "";
if (!walletKey.trim()) {
setWalletSwitchMessage("Wallet key cannot be empty.", "error");
walletSwitchKeyInput?.focus();
return;
}
if (walletSwitchSubmit) {
walletSwitchSubmit.disabled = true;
}
setWalletSwitchMessage("Unlocking wallet...");
try {
const wallet = await loadWalletByPath(pendingWalletSwitch.walletPath, walletKey);
updateActiveWallet(wallet);
document.body.classList.add("wallet-open");
closeWalletSwitchModal({ restoreSelection: false });
await populateTopWalletList();
} catch (error) {
setWalletSwitchMessage(String(error), "error");
walletSwitchKeyInput?.focus();
} finally {
if (walletSwitchSubmit) {
walletSwitchSubmit.disabled = false;
}
}
});
walletSwitchCloseButton?.addEventListener("click", () => closeWalletSwitchModal());
walletSwitchCancelButton?.addEventListener("click", () => closeWalletSwitchModal());
walletSwitchModal?.addEventListener("click", (event) => {
if (event.target === walletSwitchModal) {
closeWalletSwitchModal();
}
});
broadcastNodeButton?.addEventListener("change", () => {
if (!walletLoaded) {
return;
}
if (currentView === "send" && !issueTokenForm?.classList.contains("hidden")) {
refreshIssueTokenList();
}
if (registrationRegistered) {
resetLatestDashboardTransactionsMemory();
stopLatestDashboardTransactionsPolling();
refreshReceivePage();
startRegisteredDashboardLoadSequence({ resetTransactions: true });
stopNetworkInfoRefresh();
updateNetworkInfoRefreshState();
if (!nodeModal?.classList.contains("hidden")) {
refreshNetworkInfo({ target: "node", force: true });
}
return;
}
refreshNetworkInfo({ target: "dashboard", force: !nodeModal?.classList.contains("hidden") });
refreshReceivePage();
checkWalletRegistrationOnce();
});
populateBroadcastNodeList();

View File

@ -0,0 +1,401 @@
function setActiveView(view) {
if (view === "transactions") {
view = "history";
}
const previousView = currentView;
if (previousView === "send" && view !== "send" && typeof resetTransactionForms === "function") {
resetTransactionForms();
}
if (previousView === "marketing" && view !== "marketing" && typeof resetMarketingCampaignPage === "function") {
resetMarketingCampaignPage();
}
if (previousView === "nfts" && view !== "nfts" && typeof resetNftsPage === "function") {
resetNftsPage();
}
const [title, subtitle] = titles[view] || titles.dashboard;
currentView = view;
navButtons.forEach((button) => {
button.classList.toggle("active", button.dataset.view === view);
});
closeMobileMoreMenu();
document.querySelector("[data-mobile-more]")?.classList.toggle(
"active",
["assets", "marketing", "addressbook", "settings"].includes(view)
);
if (viewTitle) {
viewTitle.textContent = title;
}
if (viewSubtitle) {
viewSubtitle.textContent = subtitle;
}
if (view === "dashboard") {
dashboardView.classList.remove("hidden");
transactionView.classList.add("hidden");
receiveView.classList.add("hidden");
historyView.classList.add("hidden");
assetsView.classList.add("hidden");
nftsView.classList.add("hidden");
loansView.classList.add("hidden");
marketingView.classList.add("hidden");
addressbookView.classList.add("hidden");
settingsView.classList.add("hidden");
updateNetworkInfoRefreshState();
if (registrationRegistered) {
startRegisteredDashboardLoadSequence({ quiet: true });
} else {
updateBalanceRefreshState();
updateLatestDashboardTransactionsPolling();
}
updateHistoryRefreshState();
return;
}
updateBalanceRefreshState();
updateNetworkInfoRefreshState();
updateLatestDashboardTransactionsPolling();
updateHistoryRefreshState();
dashboardView.classList.add("hidden");
transactionView.classList.add("hidden");
receiveView.classList.add("hidden");
historyView.classList.add("hidden");
assetsView.classList.add("hidden");
nftsView.classList.add("hidden");
loansView.classList.add("hidden");
marketingView.classList.add("hidden");
addressbookView.classList.add("hidden");
settingsView.classList.add("hidden");
if (view === "receive") {
receiveView.classList.remove("hidden");
refreshReceivePage();
return;
}
if (view === "history") {
historyView.classList.remove("hidden");
updateHistoryRefreshState();
return;
}
if (view === "assets") {
assetsView.classList.remove("hidden");
prepareAssetsPage();
return;
}
if (view === "nfts") {
nftsView.classList.remove("hidden");
prepareNftsPage();
return;
}
if (view === "loans") {
loansView.classList.remove("hidden");
prepareLoansPage();
return;
}
if (view === "marketing") {
marketingView.classList.remove("hidden");
prepareMarketingCampaignPage();
return;
}
if (view === "addressbook") {
addressbookView.classList.remove("hidden");
prepareAddressBookPage();
return;
}
if (view === "settings") {
settingsView.classList.remove("hidden");
loadSettingsPage();
return;
}
transactionView.classList.remove("hidden");
if (view === "send") {
setActiveTransactionType("Transfer");
showTransactionList();
return;
}
showTransactionList(title, subtitle);
}
navButtons.forEach((button) => {
button.addEventListener("click", () => setActiveView(button.dataset.view));
});
function setMobileMoreMenuOpen(open) {
const moreButton = document.querySelector("[data-mobile-more]");
const backdrop = document.querySelector("[data-mobile-more-backdrop]");
const sheet = document.querySelector("[data-mobile-more-sheet]");
moreButton?.setAttribute("aria-expanded", open ? "true" : "false");
backdrop?.classList.toggle("hidden", !open);
sheet?.classList.toggle("hidden", !open);
}
function closeMobileMoreMenu() {
setMobileMoreMenuOpen(false);
}
document.querySelector("[data-mobile-more]")?.addEventListener("click", () => {
const moreButton = document.querySelector("[data-mobile-more]");
setMobileMoreMenuOpen(moreButton?.getAttribute("aria-expanded") !== "true");
});
document.querySelector("[data-mobile-more-backdrop]")?.addEventListener("click", closeMobileMoreMenu);
function showTransactionList(title = "Unbroadcast Transactions", copy = "Transactions saved locally and waiting for review.") {
currentTransactionType = "";
transactionTitle.textContent = title;
transactionCopy.textContent = copy;
transactionList.classList.remove("hidden");
formPanel.classList.add("hidden");
reviewPanel.classList.add("hidden");
unbroadcastListMessage?.classList.remove("hidden");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
}
function setActiveTransactionType(type = "Transfer") {
document
.querySelectorAll("[data-transaction-type]")
.forEach((item) => item.classList.toggle("active", item.dataset.transactionType === type));
}
function refreshActiveTransactionFormAfterWalletData() {
if (currentView !== "send" || !currentTransactionType || !walletLoaded || !registrationRegistered) {
return;
}
if (currentTransactionType === "Transfer" && typeof refreshTransferBalancesIfNeeded === "function") {
refreshTransferBalancesIfNeeded().catch(() => {});
} else if (currentTransactionType === "Issue More Tokens" && typeof prepareIssueTokenForm === "function") {
prepareIssueTokenForm();
} else if (currentTransactionType === "Create NFT" && typeof prepareCreateNftForm === "function") {
prepareCreateNftForm();
} else if (currentTransactionType === "Swap" && typeof prepareSwapOfferForm === "function") {
prepareSwapOfferForm();
} else if (currentTransactionType === "Create Loan" && typeof prepareLoanOfferForm === "function") {
prepareLoanOfferForm();
} else if (currentTransactionType === "Loan Payment" && typeof prepareLoanPaymentForm === "function") {
prepareLoanPaymentForm();
} else if (currentTransactionType === "Collateral Claim" && typeof prepareCollateralClaimForm === "function") {
prepareCollateralClaimForm();
} else if (currentTransactionType === "Burn" && typeof prepareBurnForm === "function") {
prepareBurnForm();
}
}
function isActiveTransactionType(type) {
return currentView === "send" && currentTransactionType === type;
}
function clearInactiveTransactionMessages(activeType) {
const messageByType = {
"Transfer": transferMessage,
"Create Token": createTokenMessage,
"Issue More Tokens": issueTokenMessage,
"Create NFT": createNftMessage,
"Swap": swapOfferMessage,
"Create Loan": loanOfferMessage,
"Loan Payment": loanPaymentMessage,
"Collateral Claim": collateralClaimMessage,
"Burn": burnMessage,
"Marketing": marketingMessage,
"Vanity Address": vanityMessage
};
Object.entries(messageByType).forEach(([type, element]) => {
if (!element || type === activeType) {
return;
}
element.textContent = "";
element.classList.add("hidden");
element.classList.remove("error", "success");
});
}
function showTransactionForm(type) {
currentTransactionType = type;
clearInactiveTransactionMessages(type);
transactionTitle.textContent = type;
transactionCopy.textContent = "Create, save, or broadcast this transaction type.";
transactionList.classList.add("hidden");
unbroadcastListMessage?.classList.add("hidden");
reviewPanel.classList.add("hidden");
formPanel.classList.remove("hidden");
const isTransfer = type === "Transfer";
const isCreateToken = type === "Create Token";
const isIssueToken = type === "Issue More Tokens";
const isCreateNft = type === "Create NFT";
const isSwap = type === "Swap";
const isLoan = type === "Create Loan";
const isLoanPayment = type === "Loan Payment";
const isCollateralClaim = type === "Collateral Claim";
const isBurn = type === "Burn";
const isMarketing = type === "Marketing";
const isVanity = type === "Vanity Address";
document.querySelector("[data-transfer-form]")?.classList.toggle("hidden", !isTransfer);
transferActions?.classList.toggle("hidden", !isTransfer);
transferMessage?.classList.toggle("hidden", !isTransfer);
createTokenForm?.classList.toggle("hidden", !isCreateToken);
createTokenActions?.classList.toggle("hidden", !isCreateToken);
createTokenMessage?.classList.toggle("hidden", !isCreateToken);
issueTokenForm?.classList.toggle("hidden", !isIssueToken);
issueTokenActions?.classList.toggle("hidden", !isIssueToken);
issueTokenMessage?.classList.toggle("hidden", !isIssueToken);
createNftForm?.classList.toggle("hidden", !isCreateNft);
createNftActions?.classList.toggle("hidden", !isCreateNft);
createNftMessage?.classList.toggle("hidden", !isCreateNft);
swapOfferForm?.classList.toggle("hidden", !isSwap);
swapOfferActions?.classList.toggle("hidden", !isSwap);
swapOfferMessage?.classList.toggle("hidden", !isSwap);
loanOfferForm?.classList.toggle("hidden", !isLoan);
loanOfferActions?.classList.toggle("hidden", !isLoan);
loanSavedPathPanel?.classList.toggle("hidden", !isLoan || !loanSavedPathInput?.value);
loanOfferMessage?.classList.toggle("hidden", !isLoan);
loanPaymentForm?.classList.toggle("hidden", !isLoanPayment);
loanPaymentActions?.classList.toggle("hidden", !isLoanPayment);
loanPaymentMessage?.classList.toggle("hidden", !isLoanPayment);
collateralClaimForm?.classList.toggle("hidden", !isCollateralClaim);
collateralClaimActions?.classList.toggle("hidden", !isCollateralClaim);
collateralClaimMessage?.classList.toggle("hidden", !isCollateralClaim);
burnForm?.classList.toggle("hidden", !isBurn);
burnActions?.classList.toggle("hidden", !isBurn);
burnMessage?.classList.toggle("hidden", !isBurn);
marketingForm?.classList.toggle("hidden", !isMarketing);
marketingActions?.classList.toggle("hidden", !isMarketing);
marketingMessage?.classList.toggle("hidden", !isMarketing);
vanityForm?.classList.toggle("hidden", !isVanity);
vanityActions?.classList.toggle("hidden", !isVanity);
vanityMessage?.classList.toggle("hidden", !isVanity);
if (isTransfer && typeof prepareTransferForm === "function") {
prepareTransferForm();
} else if (isCreateToken && typeof prepareCreateTokenForm === "function") {
prepareCreateTokenForm();
} else if (isIssueToken && typeof prepareIssueTokenForm === "function") {
prepareIssueTokenForm();
} else if (isCreateNft && typeof prepareCreateNftForm === "function") {
prepareCreateNftForm();
} else if (isSwap && typeof prepareSwapOfferForm === "function") {
prepareSwapOfferForm();
} else if (isLoan && typeof prepareLoanOfferForm === "function") {
prepareLoanOfferForm();
} else if (isLoanPayment && typeof prepareLoanPaymentForm === "function") {
prepareLoanPaymentForm();
} else if (isCollateralClaim && typeof prepareCollateralClaimForm === "function") {
prepareCollateralClaimForm();
} else if (isBurn && typeof prepareBurnForm === "function") {
prepareBurnForm();
} else if (isMarketing && typeof prepareMarketingForm === "function") {
prepareMarketingForm();
} else if (isVanity && typeof prepareVanityAddressForm === "function") {
prepareVanityAddressForm();
}
}
function openTransactionType(type) {
setActiveView("send");
setActiveTransactionType(type);
showTransactionForm(type);
}
function openImportTransaction() {
setActiveView("send");
if (typeof showUnbroadcastList === "function") {
showUnbroadcastList();
return;
}
showTransactionList();
}
function showTransactionReview() {
transactionTitle.textContent = "Review Transaction";
transactionCopy.textContent = "Inspect this local transaction before broadcasting.";
transactionList.classList.add("hidden");
formPanel.classList.add("hidden");
reviewPanel.classList.remove("hidden");
}
document.querySelectorAll("[data-transaction-type]").forEach((button) => {
button.addEventListener("click", () => {
setActiveTransactionType(button.dataset.transactionType);
showTransactionForm(button.dataset.transactionType);
});
});
document.querySelector("[data-open-create-transaction]")?.addEventListener("click", () => {
openTransactionType("Transfer");
});
document.querySelector("[data-view-all-transactions]")?.addEventListener("click", () => {
setActiveView("history");
});
document.querySelector("[data-open-import-transaction]")?.addEventListener("click", () => {
openImportTransaction();
});
document.querySelector("[data-open-vanity-transaction]")?.addEventListener("click", () => {
openTransactionType("Vanity Address");
});
document.querySelector("[data-open-address-book]")?.addEventListener("click", () => {
setActiveView("addressbook");
});
function flashCopyButton(button) {
if (!button) {
return;
}
const original = button.textContent || "Copy";
button.textContent = "Copied";
setTimeout(() => {
button.textContent = original;
}, 1200);
}
async function copyReceiveValue(selector, button) {
const value = document.querySelector(selector)?.textContent?.trim();
if (value && value !== "N/A" && await writeClipboardText(value)) {
flashCopyButton(button);
}
}
document.querySelector("[data-copy-address]")?.addEventListener("click", async (event) => {
await copyReceiveValue("[data-active-address]", event.currentTarget);
});
document.querySelector("[data-copy-vanity]")?.addEventListener("click", async (event) => {
await copyReceiveValue("[data-active-vanity]", event.currentTarget);
});
document.querySelectorAll("[data-copy-hash]").forEach((button) => {
button.addEventListener("click", () => {
const original = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = original;
}, 1200);
});
});
document.querySelectorAll("[data-loan-card]").forEach((button) => {
button.addEventListener("click", () => {
document
.querySelectorAll("[data-loan-card]")
.forEach((item) => item.classList.toggle("active", item === button));
});
});

597
frontend/js/nfts_page.js Normal file
View File

@ -0,0 +1,597 @@
let ownedNfts = [];
let ownedNftRows = [];
let selectedNftKey = "";
let nftsLoadId = 0;
let nftsLoading = false;
let nftsPage = 1;
let nftsCachingVisible = false;
const NFTS_PAGE_SIZE = 30;
const nftMetadataCache = new Map();
function nftKey(nft) {
return `${String(nft.nft_name || nft.asset || "").trim().toLowerCase()}|${Number(nft.series || nft.nft_series || 0)}`;
}
function nftCacheKey(nft) {
return `${String(nft.nft_name || nft.asset || "").trim().toLowerCase()}_${Number(nft.series || nft.nft_series || 0)}`;
}
function escapeNftText(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function shortenNftTx(txid) {
const value = String(txid || "");
return value.length > 16 ? `${value.slice(0, 6)}...${value.slice(-8)}` : value || "--";
}
function nftDisplayName(nft) {
const metadataName = String(nft.metadata?.name || "").trim();
if (metadataName) {
return metadataName;
}
const name = String(nft.nft_name || nft.asset || "NFT").trim();
return Number(nft.series || 0) > 0 ? `${name} #${Number(nft.series)}` : name;
}
function nftImageUri(nft) {
const cachedImage = String(nft.image_data_url || nft.imageDataUrl || "").trim();
if (cachedImage) {
return cachedImage;
}
return String(
nft.metadata?.image
|| nft.metadata?.image_url
|| nft.metadata?.animation_url
|| ""
).trim();
}
function normalizeIpfsGateway(gateway) {
const fallback = "https://gateway.pinata.cloud/ipfs/";
const clean = String(gateway || "").trim() || fallback;
return clean.endsWith("/") ? clean : `${clean}/`;
}
function normalizeIpfsPath(value) {
let path = String(value || "").trim();
let previous = "";
while (path && path !== previous) {
previous = path;
path = path
.replace(/^ipfs:\/\//i, "")
.replace(/^ipfs:\//i, "")
.replace(/^ipfs:/i, "")
.replace(/^\/?ipfs\//i, "")
.replace(/^\/+/, "");
}
return path;
}
function ipfsToGatewayUrl(uri, gateway) {
const value = String(uri || "").trim();
if (!value) {
return "";
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
if (value.startsWith("data:image/")) {
return value;
}
const path = normalizeIpfsPath(value);
return `${normalizeIpfsGateway(gateway)}${path}`;
}
function nftTypeLabel(nft) {
const metadataType = String(nft.metadata?.contractless?.metadata_type || "").trim();
if (metadataType.toLowerCase() === "rwa") {
return "RWA";
}
if (nftImageUri(nft)) {
return "Image NFT";
}
return "NFT";
}
function setNftsState(message) {
if (nftsState) {
nftsState.textContent = message || "";
}
}
function setNftDetailText(element, value) {
if (element) {
element.textContent = value || "--";
}
}
function resetNftDetails(message = "Select an owned NFT.") {
if (nftDetailTitle) {
nftDetailTitle.textContent = "NFT Details";
}
if (nftDetailSubtitle) {
nftDetailSubtitle.textContent = message;
}
setNftDetailText(nftDetailName, "");
setNftDetailText(nftDetailAsset, "");
setNftDetailText(nftDetailCreator, "");
setNftDetailText(nftDetailGenesis, "");
setNftDetailText(nftDetailMetadata, "");
if (nftMetadataSummary) {
nftMetadataSummary.innerHTML = "";
}
if (nftHistory) {
nftHistory.innerHTML = "";
}
}
function hasOwnedBalance(balance) {
try {
return BigInt(String(balance?.balance || "0")) > 0n;
} catch (_) {
return Number(balance?.balance || 0) > 0;
}
}
function ownedNftRowsFromBalances(balances, registry) {
const normalizedBalances = Array.isArray(balances) ? balances : [];
const registryRows = Array.isArray(registry) ? registry : [];
const balanceByAsset = new Map();
normalizedBalances.forEach((balance) => {
if (!hasOwnedBalance(balance)) {
return;
}
const asset = String(balance.asset || "").trim().toLowerCase();
const series = Number(balance.nft_series || 0);
balanceByAsset.set(`${asset}|${series}`, balance);
});
return registryRows
.map((nft) => {
const nftName = String(nft.nft_name || "").trim();
const series = Number(nft.series || 0);
const balance = balanceByAsset.get(`${nftName.toLowerCase()}|${series}`);
if (!balance) {
return null;
}
return {
asset: nftName,
nft_name: nftName,
series,
balance: String(balance.balance || "0"),
origin_txid: nft.origin_txid || ""
};
})
.filter(Boolean)
.filter((nft) => nft.nft_name);
}
function legacyOwnedNftRowsFromBalances(balances) {
return (Array.isArray(balances) ? balances : [])
.filter((balance) => {
const series = Number(balance.nft_series || 0);
let owned = false;
try {
owned = BigInt(String(balance.balance || "0")) > 0n;
} catch (_) {
owned = Number(balance.balance || 0) > 0;
}
return series > 0 && owned;
})
.map((balance) => ({
asset: String(balance.asset || "").trim(),
nft_name: String(balance.asset || "").trim(),
series: Number(balance.nft_series || 0),
balance: String(balance.balance || "0")
}))
.filter((nft) => nft.nft_name);
}
function metadataDescription(metadata) {
return String(metadata?.description || metadata?.summary || "").trim();
}
function renderNftCard(nft) {
const key = nftKey(nft);
const name = nftDisplayName(nft);
const image = nftImageUri(nft);
const type = nftTypeLabel(nft);
const active = key === selectedNftKey ? " active" : "";
const preview = image && nft.gateway
? `<img src="${escapeNftText(ipfsToGatewayUrl(image, nft.gateway))}" alt="">`
: `<div class="asset-mark art">${escapeNftText(assetInitial(name))}</div>`;
return `
<article class="nft-record${active}" data-nft-key="${escapeNftText(key)}">
<div class="nft-card-media">${preview}</div>
<div class="nft-record-top">
<div>
<strong>${escapeNftText(name)}</strong>
<span>${escapeNftText(type)} - ${escapeNftText(nft.asset_name || `${nft.nft_name}_${nft.series}`)}</span>
</div>
</div>
<div class="nft-link-row">
<span>Metadata</span>
<code>${escapeNftText(nft.metadata_uri || nft.item_ipfs || "--")}</code>
</div>
<div class="nft-actions">
<button class="secondary-button" type="button" data-nft-select="${escapeNftText(key)}">View Details</button>
</div>
</article>
`;
}
function renderNftHistoryEntry(entry) {
const asset = entry.received_asset
? `${entry.received_asset}${Number(entry.received_series || 0) > 0 ? ` #${entry.received_series}` : ""}`
: "";
return `
<div class="nft-history-row">
<div>
<b>${escapeNftText(entry.action || "Unknown")}</b>
<span>Block ${escapeNftText(formatNumber(entry.block || 0))}</span>
</div>
<code>${escapeNftText(shortenNftTx(entry.txid || ""))}</code>
${entry.from ? `<div><span>From</span><strong>${escapeNftText(shortenAddress(entry.from))}</strong></div>` : ""}
${entry.to ? `<div><span>To</span><strong>${escapeNftText(shortenAddress(entry.to))}</strong></div>` : ""}
${asset ? `<div><span>Received</span><strong>${escapeNftText(asset)}</strong></div>` : ""}
</div>
`;
}
function renderNftMetadataSummary(nft) {
const metadata = nft.metadata || {};
const description = metadataDescription(metadata);
const attributes = Array.isArray(metadata.attributes) ? metadata.attributes : [];
const contractless = metadata.contractless && typeof metadata.contractless === "object"
? metadata.contractless
: null;
const parts = [];
if (description) {
parts.push(`<p>${escapeNftText(description)}</p>`);
}
if (attributes.length) {
parts.push(`
<div class="nft-attributes">
${attributes.slice(0, 8).map((attribute) => `
<div>
<span>${escapeNftText(attribute.trait_type || attribute.type || "Trait")}</span>
<b>${escapeNftText(attribute.value ?? "")}</b>
</div>
`).join("")}
</div>
`);
}
if (contractless) {
parts.push(`
<div class="nft-rwa-fields">
${Object.entries(contractless).slice(0, 6).map(([key, value]) => `
<div><span>${escapeNftText(key)}</span><b>${escapeNftText(value)}</b></div>
`).join("")}
</div>
`);
}
return parts.join("") || "<p>No metadata summary available.</p>";
}
function renderNftDetails(nft) {
if (!nft) {
resetNftDetails();
return;
}
const name = nftDisplayName(nft);
if (nftDetailTitle) {
nftDetailTitle.textContent = name;
}
if (nftDetailSubtitle) {
nftDetailSubtitle.textContent = nftTypeLabel(nft);
}
setNftDetailText(nftDetailName, name);
setNftDetailText(nftDetailAsset, nft.asset_name || `${nft.nft_name}_${nft.series}`);
setNftDetailText(nftDetailCreator, nft.creator ? shortenAddress(nft.creator) : "");
setNftDetailText(nftDetailGenesis, nft.genesis_txid ? shortenNftTx(nft.genesis_txid) : "");
setNftDetailText(nftDetailMetadata, nft.metadata_uri || nft.item_ipfs || "");
if (nftMetadataSummary) {
nftMetadataSummary.innerHTML = renderNftMetadataSummary(nft);
}
if (nftHistory) {
nftHistory.innerHTML = Array.isArray(nft.history) && nft.history.length
? nft.history.map(renderNftHistoryEntry).join("")
: '<div class="nft-history-empty">No provenance records returned.</div>';
}
}
function renderNftsPage() {
if (!nftsList) {
return;
}
nftsList.innerHTML = ownedNfts.map(renderNftCard).join("");
const selected = ownedNfts.find((nft) => nftKey(nft) === selectedNftKey) || ownedNfts[0];
selectedNftKey = selected ? nftKey(selected) : "";
renderNftDetails(selected);
if (!walletLoaded) {
setNftsState("Load a wallet to view NFTs.");
} else if (!ownedNftRows.length) {
setNftsState(nftsLoading ? "Loading owned NFTs..." : "No owned NFTs found for this address.");
} else if (!ownedNfts.length) {
const start = (nftsPage - 1) * NFTS_PAGE_SIZE + 1;
const end = Math.min(start + NFTS_PAGE_SIZE - 1, ownedNftRows.length);
const suffix = nftsCachingVisible ? " - Caching media..." : "";
setNftsState(`Loading ${start}-${end} of ${ownedNftRows.length} NFTs${suffix}`);
} else {
const start = (nftsPage - 1) * NFTS_PAGE_SIZE + 1;
const end = Math.min(start + ownedNfts.length - 1, ownedNftRows.length);
const suffix = nftsCachingVisible ? " - Caching media..." : "";
setNftsState(`Showing ${start}-${end} of ${ownedNftRows.length} NFTs${suffix}`);
}
renderNftsPagination();
}
async function loadNftMetadata(invoke, detail, gateway) {
const metadataUri = detail.metadata_uri || detail.item_ipfs || "";
if (!metadataUri) {
return null;
}
const cacheKey = `${gateway}|${metadataUri}`;
if (nftMetadataCache.has(cacheKey)) {
return nftMetadataCache.get(cacheKey);
}
const metadata = await invoke("fetch_ipfs_metadata", {
ipfsPath: metadataUri,
gateway
});
nftMetadataCache.set(cacheKey, metadata);
return metadata;
}
function nftFromCache(row, cached, gateway) {
const detail = cached?.detail && typeof cached.detail === "object" ? cached.detail : {};
const metadata = cached?.metadata && typeof cached.metadata === "object" ? cached.metadata : {};
return {
...row,
...detail,
metadata,
image_data_url: cached?.image_data_url || "",
gateway
};
}
function renderNftsPagination() {
if (!nftsPagination) {
return;
}
const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE));
const showPagination = ownedNftRows.length > NFTS_PAGE_SIZE;
nftsPagination.hidden = !showPagination;
if (nftsPageTotal) {
nftsPageTotal.textContent = String(totalPages);
}
if (nftsPageInput && Number(nftsPageInput.value) !== nftsPage) {
nftsPageInput.value = String(nftsPage);
}
document.querySelectorAll("[data-nfts-page]").forEach((button) => {
const action = button.dataset.nftsPage;
button.disabled = !showPagination
|| (["first", "previous"].includes(action) && nftsPage <= 1)
|| (["next", "last"].includes(action) && nftsPage >= totalPages);
});
}
function clampNftsPage(page) {
const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE));
return Math.min(Math.max(1, Number(page) || 1), totalPages);
}
function visibleNftRows() {
const start = (nftsPage - 1) * NFTS_PAGE_SIZE;
return ownedNftRows.slice(start, start + NFTS_PAGE_SIZE);
}
async function hydrateVisibleNftPage({ loadId, gateway, broadcastNode, force }) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || loadId !== nftsLoadId) {
return;
}
const rows = visibleNftRows();
const loaded = [];
nftsCachingVisible = false;
for (const row of rows) {
if (loadId !== nftsLoadId || currentView !== "nfts") {
return;
}
try {
if (!force) {
const cached = await invoke("read_nft_cache", {
nftName: row.nft_name,
series: row.series
});
if (cached) {
loaded.push(nftFromCache(row, cached, gateway));
ownedNfts = loaded.slice();
renderNftsPage();
continue;
}
}
nftsCachingVisible = true;
renderNftsPage();
const detail = await invoke("nft_details", {
broadcastNode,
nftName: row.nft_name,
series: row.series
});
let metadata = null;
try {
metadata = await loadNftMetadata(invoke, detail, gateway);
} catch (error) {
metadata = { description: `Metadata unavailable: ${String(error)}` };
}
const imageUrl = ipfsToGatewayUrl(
String(metadata?.image || metadata?.image_url || metadata?.animation_url || ""),
gateway
);
let cacheRecord = null;
try {
cacheRecord = await invoke("write_nft_cache", {
nftName: row.nft_name,
series: row.series,
detailJson: JSON.stringify(detail),
metadataJson: JSON.stringify(metadata || {}),
imageUrl
});
} catch (_) {
cacheRecord = null;
}
loaded.push(cacheRecord ? nftFromCache(row, cacheRecord, gateway) : {
...row,
...detail,
metadata,
gateway
});
ownedNfts = loaded.slice();
renderNftsPage();
} catch (error) {
loaded.push({
...row,
metadata: { description: `NFT details unavailable: ${String(error)}` },
gateway
});
ownedNfts = loaded.slice();
renderNftsPage();
}
}
nftsCachingVisible = false;
ownedNfts = loaded;
renderNftsPage();
}
async function prepareNftsPage({ force = false } = {}) {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!walletLoaded) {
ownedNfts = [];
ownedNftRows = [];
renderNftsPage();
return;
}
if (!invoke || !broadcastNode) {
ownedNfts = [];
ownedNftRows = [];
setNftsState("NFT lookup skipped: wallet bridge or broadcast node is unavailable.");
renderNftsPage();
return;
}
if (nftsLoading && !force) {
return;
}
const loadId = ++nftsLoadId;
nftsLoading = true;
nftsCachingVisible = false;
renderNftsPage();
try {
const [balances, settings, nftRegistry] = await Promise.all([
invoke("get_active_wallet_balances", { broadcastNode }),
invoke("read_gui_settings"),
invoke("nft_list", { broadcastNode })
]);
if (loadId !== nftsLoadId || currentView !== "nfts") {
return;
}
const gateway = normalizeIpfsGateway(settings?.ipfs_gateway);
ownedNftRows = ownedNftRowsFromBalances(balances, nftRegistry);
if (!ownedNftRows.length && !Array.isArray(nftRegistry)) {
ownedNftRows = legacyOwnedNftRowsFromBalances(balances);
}
nftsPage = clampNftsPage(nftsPage);
invoke("prune_nft_cache", {
keepKeys: ownedNftRows.map(nftCacheKey)
}).catch(() => {});
ownedNfts = [];
renderNftsPage();
await hydrateVisibleNftPage({ loadId, gateway, broadcastNode, force });
} catch (error) {
ownedNfts = [];
ownedNftRows = [];
setNftsState(String(error));
} finally {
if (loadId === nftsLoadId) {
nftsLoading = false;
renderNftsPage();
}
}
}
function resetNftsPage() {
nftsLoadId += 1;
ownedNfts = [];
ownedNftRows = [];
selectedNftKey = "";
nftsPage = 1;
nftsLoading = false;
nftsCachingVisible = false;
resetNftDetails();
renderNftsPage();
}
nftsList?.addEventListener("click", (event) => {
const button = event.target.closest("[data-nft-select]");
const card = event.target.closest("[data-nft-key]");
const key = button?.dataset.nftSelect || card?.dataset.nftKey || "";
if (!key) {
return;
}
selectedNftKey = key;
renderNftsPage();
});
nftsRefreshButton?.addEventListener("click", () => {
nftMetadataCache.clear();
prepareNftsPage({ force: true });
});
function reloadNftPageFromControls(force = false) {
nftsLoadId += 1;
nftsLoading = false;
nftsCachingVisible = false;
ownedNfts = [];
selectedNftKey = "";
prepareNftsPage({ force });
}
document.querySelectorAll("[data-nfts-page]").forEach((button) => {
button.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(ownedNftRows.length / NFTS_PAGE_SIZE));
const action = button.dataset.nftsPage;
if (action === "first") {
nftsPage = 1;
} else if (action === "previous") {
nftsPage = clampNftsPage(nftsPage - 1);
} else if (action === "next") {
nftsPage = clampNftsPage(nftsPage + 1);
} else if (action === "last") {
nftsPage = totalPages;
}
reloadNftPageFromControls();
});
});
nftsPageInput?.addEventListener("change", () => {
nftsPage = clampNftsPage(nftsPageInput.value);
reloadNftPageFromControls();
});

433
frontend/js/receive_page.js Normal file
View File

@ -0,0 +1,433 @@
let receiveTokenListRequestId = 0;
function setPaymentMessage(message, mode = "") {
if (!paymentMessage) {
return;
}
paymentMessage.textContent = message;
paymentMessage.classList.toggle("error", mode === "error");
paymentMessage.classList.toggle("success", mode === "success");
}
function receiveAddress() {
return activeWalletAddressShort || activeWalletAddressHex || "";
}
function paymentRequestUri() {
const address = receiveAddress();
if (!address) {
return "";
}
const params = new URLSearchParams();
const asset = paymentAssetSelect?.value || activeBaseCoin();
const amount = paymentAmountInput?.value?.trim() || "";
const label = paymentLabelInput?.value?.trim() || "";
if (asset) {
params.set("asset", asset);
}
if (amount) {
params.set("amount", amount);
}
if (label) {
params.set("label", label);
}
const query = params.toString();
return `contractless:${address}${query ? `?${query}` : ""}`;
}
function qrGaloisTables() {
const exp = new Array(512).fill(0);
const log = new Array(256).fill(0);
let value = 1;
for (let index = 0; index < 255; index += 1) {
exp[index] = value;
log[value] = index;
value <<= 1;
if (value & 0x100) {
value ^= 0x11d;
}
}
for (let index = 255; index < 512; index += 1) {
exp[index] = exp[index - 255];
}
return { exp, log };
}
function qrMultiply(left, right, tables) {
if (!left || !right) {
return 0;
}
return tables.exp[tables.log[left] + tables.log[right]];
}
function qrGeneratorPolynomial(degree, tables) {
let poly = [1];
for (let index = 0; index < degree; index += 1) {
const next = new Array(poly.length + 1).fill(0);
poly.forEach((coefficient, coefficientIndex) => {
next[coefficientIndex] ^= coefficient;
next[coefficientIndex + 1] ^= qrMultiply(coefficient, tables.exp[index], tables);
});
poly = next;
}
return poly;
}
function qrReedSolomon(data, eccLength) {
const tables = qrGaloisTables();
const generator = qrGeneratorPolynomial(eccLength, tables);
const ecc = new Array(eccLength).fill(0);
data.forEach((byte) => {
const factor = byte ^ ecc.shift();
ecc.push(0);
for (let index = 0; index < eccLength; index += 1) {
ecc[index] ^= qrMultiply(generator[index + 1], factor, tables);
}
});
return ecc;
}
function qrAppendBits(bits, value, length) {
for (let bit = length - 1; bit >= 0; bit -= 1) {
bits.push((value >>> bit) & 1);
}
}
function qrEncodeBytes(text) {
const bytes = Array.from(new TextEncoder().encode(text));
if (bytes.length > 134) {
throw new Error("Payment URI is too long for the QR code. Shorten the label or amount.");
}
const bits = [];
qrAppendBits(bits, 0b0100, 4);
qrAppendBits(bits, bytes.length, 8);
bytes.forEach((byte) => qrAppendBits(bits, byte, 8));
const dataBitCapacity = 136 * 8;
const terminator = Math.min(4, dataBitCapacity - bits.length);
qrAppendBits(bits, 0, terminator);
while (bits.length % 8) {
bits.push(0);
}
const data = [];
for (let index = 0; index < bits.length; index += 8) {
data.push(bits.slice(index, index + 8).reduce((value, bit) => (value << 1) | bit, 0));
}
for (let pad = 0xec; data.length < 136; pad = pad === 0xec ? 0x11 : 0xec) {
data.push(pad);
}
const block1 = data.slice(0, 68);
const block2 = data.slice(68, 136);
const ecc1 = qrReedSolomon(block1, 18);
const ecc2 = qrReedSolomon(block2, 18);
const codewords = [];
for (let index = 0; index < 68; index += 1) {
codewords.push(block1[index], block2[index]);
}
for (let index = 0; index < 18; index += 1) {
codewords.push(ecc1[index], ecc2[index]);
}
return codewords.flatMap((byte) => {
const out = [];
qrAppendBits(out, byte, 8);
return out;
});
}
function qrFormatBits() {
let data = 0b01000; // ECC level L, mask 0.
let value = data << 10;
const generator = 0x537;
for (let bit = 14; bit >= 10; bit -= 1) {
if ((value >>> bit) & 1) {
value ^= generator << (bit - 10);
}
}
return ((data << 10) | value) ^ 0x5412;
}
function qrSet(matrix, reserved, row, col, value, isReserved = true) {
if (row < 0 || col < 0 || row >= matrix.length || col >= matrix.length) {
return;
}
matrix[row][col] = Boolean(value);
if (isReserved) {
reserved[row][col] = true;
}
}
function qrFinder(matrix, reserved, row, col) {
for (let y = -1; y <= 7; y += 1) {
for (let x = -1; x <= 7; x += 1) {
const r = row + y;
const c = col + x;
const dark = x >= 0 && x <= 6 && y >= 0 && y <= 6
&& (x === 0 || x === 6 || y === 0 || y === 6 || (x >= 2 && x <= 4 && y >= 2 && y <= 4));
qrSet(matrix, reserved, r, c, dark);
}
}
}
function qrAlignment(matrix, reserved, row, col) {
for (let y = -2; y <= 2; y += 1) {
for (let x = -2; x <= 2; x += 1) {
qrSet(matrix, reserved, row + y, col + x, Math.max(Math.abs(x), Math.abs(y)) !== 1);
}
}
}
function qrBuildMatrix(text) {
const size = 41;
const matrix = Array.from({ length: size }, () => new Array(size).fill(false));
const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
qrFinder(matrix, reserved, 0, 0);
qrFinder(matrix, reserved, 0, size - 7);
qrFinder(matrix, reserved, size - 7, 0);
qrAlignment(matrix, reserved, 34, 34);
for (let index = 8; index < size - 8; index += 1) {
qrSet(matrix, reserved, 6, index, index % 2 === 0);
qrSet(matrix, reserved, index, 6, index % 2 === 0);
}
qrSet(matrix, reserved, 33, 8, true);
for (let index = 0; index < 9; index += 1) {
if (index !== 6) {
reserved[8][index] = true;
reserved[index][8] = true;
}
}
for (let index = 0; index < 8; index += 1) {
reserved[8][size - 1 - index] = true;
reserved[size - 1 - index][8] = true;
}
const bits = qrEncodeBytes(text);
let bitIndex = 0;
let upward = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col === 6) {
col -= 1;
}
for (let step = 0; step < size; step += 1) {
const row = upward ? size - 1 - step : step;
for (let offset = 0; offset < 2; offset += 1) {
const c = col - offset;
if (reserved[row][c]) {
continue;
}
const dark = bitIndex < bits.length ? bits[bitIndex] === 1 : false;
matrix[row][c] = ((row + c) % 2 === 0) ? !dark : dark;
bitIndex += 1;
}
}
upward = !upward;
}
const format = qrFormatBits();
for (let index = 0; index < 15; index += 1) {
const bit = ((format >>> index) & 1) === 1;
const first = [
[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8],
[7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8]
][index];
const second = index < 8 ? [size - 1 - index, 8] : [8, size - 15 + index];
qrSet(matrix, reserved, first[0], first[1], bit);
qrSet(matrix, reserved, second[0], second[1], bit);
}
return matrix;
}
function drawPaymentQr(canvas, text, label) {
if (!canvas) {
return;
}
const size = canvas.width || 256;
const context = canvas.getContext("2d");
if (!context) {
return;
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, size, size);
context.fillStyle = "#101820";
try {
const matrix = qrBuildMatrix(text);
const modules = matrix.length;
const quiet = 4;
const cell = Math.floor(size / (modules + quiet * 2));
const offset = Math.floor((size - cell * modules) / 2);
matrix.forEach((row, y) => {
row.forEach((dark, x) => {
if (dark) {
context.fillRect(offset + x * cell, offset + y * cell, cell, cell);
}
});
});
setPaymentMessage("");
} catch (error) {
context.fillStyle = "#101820";
context.font = "16px sans-serif";
context.textAlign = "center";
context.fillText("QR unavailable", size / 2, size / 2 - 8);
context.font = "12px sans-serif";
context.fillText("Shorten request text", size / 2, size / 2 + 14);
setPaymentMessage(String(error), "error");
}
if (label) {
const footerHeight = 28;
context.fillStyle = "rgba(255,255,255,0.92)";
context.fillRect(0, size - footerHeight, size, footerHeight);
context.fillStyle = "#101820";
context.font = "13px sans-serif";
context.textAlign = "center";
const trimmed = label.length > 30 ? `${label.slice(0, 27)}...` : label;
context.fillText(trimmed, size / 2, size - 10);
}
}
function drawEmptyPaymentQr(canvas) {
if (!canvas) {
return;
}
const size = canvas.width || 256;
const context = canvas.getContext("2d");
if (!context) {
return;
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, size, size);
context.fillStyle = "#101820";
context.font = "16px sans-serif";
context.textAlign = "center";
context.fillText("Load wallet", size / 2, size / 2);
}
function renderPaymentQr(uri, label) {
if (!uri) {
drawEmptyPaymentQr(paymentQrCanvas);
return;
}
drawPaymentQr(paymentQrCanvas, uri, label);
}
function updateReceiveDetails() {
if (receiveRegistration) {
receiveRegistration.textContent = registrationRegistered ? "Registered" : "Unregistered";
}
}
function updatePaymentRequest() {
const uri = paymentRequestUri();
if (paymentUriInput) {
paymentUriInput.value = uri;
}
if (paymentQrLabel) {
paymentQrLabel.textContent = paymentLabelInput?.value?.trim() || "Payment Request";
}
renderPaymentQr(uri, paymentQrLabel?.textContent || "");
updateReceiveDetails();
}
async function refreshReceiveTokenList() {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !walletLoaded || !broadcastNode || !paymentAssetSelect) {
return;
}
const requestId = ++receiveTokenListRequestId;
const selected = paymentAssetSelect.value || activeBaseCoin();
paymentAssetSelect.innerHTML = "";
const baseOption = document.createElement("option");
baseOption.value = activeBaseCoin();
baseOption.textContent = activeBaseCoin();
paymentAssetSelect.appendChild(baseOption);
try {
const tokens = await invokeWalletRpcWithBackoff(
"Token list lookup",
() => invoke("token_list", { broadcastNode }),
() => requestId === receiveTokenListRequestId && walletLoaded
);
if (requestId !== receiveTokenListRequestId || !walletLoaded) {
return;
}
(Array.isArray(tokens) ? tokens : []).forEach((token) => {
const ticker = String(token?.ticker || "").trim();
if (!ticker || ticker.toLowerCase() === activeBaseCoin().toLowerCase()) {
return;
}
const option = document.createElement("option");
option.value = ticker;
option.textContent = ticker;
paymentAssetSelect.appendChild(option);
});
paymentAssetSelect.value = [...paymentAssetSelect.options].some((option) => option.value === selected)
? selected
: activeBaseCoin();
} catch (error) {
setPaymentMessage(String(error), "error");
} finally {
updatePaymentRequest();
}
}
async function copyPaymentUri() {
const uri = paymentRequestUri();
if (!uri || !(await writeClipboardText(uri))) {
setPaymentMessage("Payment URI is not available to copy.", "error");
return;
}
setPaymentMessage("Payment URI copied.", "success");
}
async function copyPaymentQr() {
if (!paymentQrCanvas || !navigator.clipboard || typeof ClipboardItem === "undefined") {
await copyPaymentUri();
return;
}
paymentQrCanvas.toBlob(async (blob) => {
if (!blob) {
setPaymentMessage("Payment QR is not available to copy.", "error");
return;
}
try {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
]);
setPaymentMessage("Payment request QR copied.", "success");
} catch (_) {
await copyPaymentUri();
}
}, "image/png");
}
function refreshReceivePage() {
updateReceiveDetails();
updatePaymentRequest();
refreshReceiveTokenList();
}
[paymentAssetSelect, paymentAmountInput, paymentLabelInput].forEach((input) => {
input?.addEventListener("input", updatePaymentRequest);
input?.addEventListener("change", updatePaymentRequest);
});
copyPaymentUriButton?.addEventListener("click", copyPaymentUri);
copyPaymentQrButton?.addEventListener("click", copyPaymentQr);

View File

@ -0,0 +1,574 @@
let settingsLoaded = false;
function durationSettingMs(value) {
const text = String(value || "").trim().toLowerCase();
if (!text || text === "never") {
return 0;
}
const amount = Number.parseFloat(text);
if (!Number.isFinite(amount) || amount <= 0) {
return 0;
}
if (text.includes("second")) {
return amount * 1000;
}
if (text.includes("minute")) {
return amount * 60 * 1000;
}
return 0;
}
function resetAutoLockTimer() {
if (autoLockTimer) {
clearTimeout(autoLockTimer);
autoLockTimer = null;
}
if (!walletLoaded) {
return;
}
const timeoutMs = durationSettingMs(guiRuntimeSettings.auto_lock_timeout);
if (!timeoutMs) {
return;
}
autoLockTimer = setTimeout(async () => {
if (!walletLoaded) {
return;
}
try {
await window.__TAURI__?.core?.invoke?.("lock_wallet");
} catch (_) {
// Local UI lock is still the important user-facing behavior.
}
lockWalletSession();
}, timeoutMs);
}
function applyRuntimeGuiSettings(settings) {
guiRuntimeSettings = {
...guiRuntimeSettings,
auto_lock_timeout: settings?.auto_lock_timeout || guiRuntimeSettings.auto_lock_timeout,
clipboard_clear_timeout: settings?.clipboard_clear_timeout || guiRuntimeSettings.clipboard_clear_timeout,
require_key_before_signing: settings?.require_key_before_signing !== false
};
resetAutoLockTimer();
}
async function loadRuntimeGuiSettings() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
try {
applyRuntimeGuiSettings(await invoke("read_gui_settings"));
} catch (_) {
resetAutoLockTimer();
}
}
function resetAutoLockOnActivity() {
if (walletLoaded) {
resetAutoLockTimer();
}
}
function scheduleClipboardClear(copiedText) {
if (clipboardClearTimer) {
clearTimeout(clipboardClearTimer);
clipboardClearTimer = null;
}
lastClipboardText = copiedText || "";
const timeoutMs = durationSettingMs(guiRuntimeSettings.clipboard_clear_timeout);
if (!timeoutMs || !lastClipboardText || !navigator.clipboard) {
return;
}
clipboardClearTimer = setTimeout(async () => {
lastClipboardText = "";
try {
await navigator.clipboard.writeText("");
} catch (_) {
// Clipboard writes can fail outside a user gesture; never prompt or block.
}
}, timeoutMs);
}
async function clearWalletClipboard() {
if (clipboardClearTimer) {
clearTimeout(clipboardClearTimer);
clipboardClearTimer = null;
}
lastClipboardText = "";
const invoke = window.__TAURI__?.core?.invoke;
if (invoke) {
try {
await invoke("clear_system_clipboard");
return;
} catch (_) {
// Fall back to the Web Clipboard API when the native bridge is unavailable.
}
}
if (!navigator.clipboard) {
return;
}
try {
await navigator.clipboard.writeText("");
} catch (_) {}
}
async function writeClipboardText(text) {
if (!text || !navigator.clipboard) {
return false;
}
await navigator.clipboard.writeText(text);
scheduleClipboardClear(text);
return true;
}
function setSettingsMessage(message, mode = "") {
if (!settingsMessage) {
return;
}
settingsMessage.textContent = message;
settingsMessage.classList.toggle("error", mode === "error");
settingsMessage.classList.toggle("success", mode === "success");
}
function settingsFieldMap() {
return {
wallet_path: settingsWalletPath,
default_node: settingsDefaultNode,
unbroadcast_transactions_path: settingsUnbroadcastPath,
address_book_path: settingsAddressBookPath,
ipfs_gateway: settingsIpfsGateway,
auto_lock_timeout: settingsAutoLockTimeout,
clipboard_clear_timeout: settingsClipboardTimeout
};
}
function applyGuiSettings(settings) {
applyRuntimeGuiSettings(settings);
const fields = settingsFieldMap();
Object.entries(fields).forEach(([key, input]) => {
if (input) {
input.value = settings?.[key] || "";
}
});
if (settingsSavedNodes) {
settingsSavedNodes.value = Array.isArray(settings?.saved_nodes)
? settings.saved_nodes.join("\n")
: "";
}
if (settingsRequireKey) {
settingsRequireKey.checked = settings?.require_key_before_signing !== false;
}
}
function collectGuiSettings() {
return {
wallet_path: settingsWalletPath?.value?.trim() || "",
unbroadcast_transactions_path: settingsUnbroadcastPath?.value?.trim() || "",
address_book_path: settingsAddressBookPath?.value?.trim() || "",
default_node: settingsDefaultNode?.value?.trim() || "",
saved_nodes: (settingsSavedNodes?.value || "")
.split(/\r?\n/)
.map((node) => node.trim())
.filter(Boolean),
ipfs_gateway: settingsIpfsGateway?.value?.trim() || "",
auto_load_trusted_nft_images: true,
auto_lock_timeout: settingsAutoLockTimeout?.value || "15 minutes",
clipboard_clear_timeout: settingsClipboardTimeout?.value || "60 seconds",
require_key_before_signing: Boolean(settingsRequireKey?.checked),
settings_path: ""
};
}
async function loadSettingsPage({ force = false } = {}) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || (settingsLoaded && !force)) {
return;
}
try {
const settings = await invoke("read_gui_settings");
applyGuiSettings(settings);
settingsLoaded = true;
setSettingsMessage("");
} catch (error) {
setSettingsMessage(String(error), "error");
}
}
async function saveSettingsPage() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setSettingsMessage("Settings can only be saved from the desktop app.", "error");
return;
}
if (settingsSaveButton) {
settingsSaveButton.disabled = true;
}
setSettingsMessage("Saving settings...");
try {
await invoke("save_gui_settings", { settings: collectGuiSettings() });
settingsLoaded = false;
await loadSettingsPage({ force: true });
await populateBroadcastNodeList();
setSettingsMessage("Settings saved.", "success");
} catch (error) {
setSettingsMessage(String(error), "error");
} finally {
if (settingsSaveButton) {
settingsSaveButton.disabled = false;
}
}
}
async function browseSettingsPath(button) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
const key = button.dataset.settingsBrowse;
const input = settingsFieldMap()[key];
if (!input) {
return;
}
try {
const selected = await invoke("select_settings_path", {
kind: button.dataset.settingsBrowseKind || "folder",
currentPath: input.value || ""
});
if (selected) {
input.value = selected;
}
} catch (error) {
setSettingsMessage(String(error), "error");
}
}
function setNodeTestStatus(message, mode = "") {
if (!settingsNodeTestStatus) {
return;
}
settingsNodeTestStatus.textContent = message || "";
settingsNodeTestStatus.classList.toggle("hidden", !message);
settingsNodeTestStatus.classList.toggle("error", mode === "error");
settingsNodeTestStatus.classList.toggle("success", mode === "success");
}
async function testSelectedSettingsNode() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setNodeTestStatus("Node testing is only available in the desktop app.", "error");
return;
}
const broadcastNode = settingsDefaultNode?.value?.trim() || activeBroadcastNode();
if (!broadcastNode) {
setNodeTestStatus("Select a node before testing.", "error");
return;
}
settingsTestNodeButton && (settingsTestNodeButton.disabled = true);
setNodeTestStatus("Testing node...");
try {
const timestamp = await invoke("test_broadcast_node_time", { broadcastNode });
setNodeTestStatus(`Active - node time ${formatNetworkTime(timestamp)}`, "success");
} catch (error) {
setNodeTestStatus(String(error), "error");
} finally {
settingsTestNodeButton && (settingsTestNodeButton.disabled = false);
}
}
function setCacheClearMessage(message, mode = "") {
if (!cacheClearMessage) {
return;
}
cacheClearMessage.textContent = message || "";
cacheClearMessage.classList.toggle("error", mode === "error");
cacheClearMessage.classList.toggle("success", mode === "success");
}
function setCacheClearModalOpen(open) {
cacheClearModal?.classList.toggle("hidden", !open);
if (open) {
setCacheClearMessage("");
}
}
async function clearSelectedWalletCache(kind, button) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !walletLoaded) {
setCacheClearMessage("Load a wallet before clearing local cache.", "error");
return;
}
button && (button.disabled = true);
setCacheClearMessage("Clearing cache...");
try {
await invoke("clear_wallet_cache", { cacheKind: kind });
if (kind === "history") {
clearHistoryRows();
resetLatestDashboardTransactionsMemory();
if (typeof resetHistoryPage === "function") {
resetHistoryPage();
}
} else if (kind === "nft" || kind === "nft_details") {
if (typeof resetNftsPage === "function") {
resetNftsPage();
}
if (currentView === "nfts" && typeof prepareNftsPage === "function") {
prepareNftsPage({ force: true });
}
} else if (kind === "address_book") {
if (typeof resetAddressBookPage === "function") {
resetAddressBookPage();
}
if (currentView === "addressbook" && typeof prepareAddressBookPage === "function") {
prepareAddressBookPage();
}
}
setCacheClearMessage("Cache cleared.", "success");
} catch (error) {
setCacheClearMessage(String(error), "error");
} finally {
button && (button.disabled = false);
}
}
function setBackupKeyMessage(message, mode = "") {
if (!backupKeyMessage) {
return;
}
backupKeyMessage.textContent = message;
backupKeyMessage.classList.toggle("error", mode === "error");
backupKeyMessage.classList.toggle("success", mode === "success");
}
function closeBackupKeyModal(value = null) {
if (backupKeyModal) {
backupKeyModal.classList.add("hidden");
}
if (backupKeyInput) {
backupKeyInput.value = "";
}
setBackupKeyMessage("");
if (pendingBackupKeyRequest) {
pendingBackupKeyRequest.resolve(value);
pendingBackupKeyRequest = null;
}
}
function requestWalletBackupKey(copy, title = "Wallet Backup") {
if (!backupKeyModal) {
return Promise.resolve(null);
}
if (pendingBackupKeyRequest) {
closeBackupKeyModal(null);
}
backupKeyTitle && (backupKeyTitle.textContent = title);
backupKeyCopy && (backupKeyCopy.textContent = copy || "Enter the wallet encryption key.");
backupKeyInput && (backupKeyInput.value = "");
setBackupKeyMessage("");
backupKeyModal.classList.remove("hidden");
setTimeout(() => backupKeyInput?.focus(), 0);
return new Promise((resolve) => {
pendingBackupKeyRequest = { resolve };
});
}
async function requestSigningWalletKey() {
if (guiRuntimeSettings.require_key_before_signing === false) {
return null;
}
return requestWalletBackupKey(
"Enter the wallet encryption key to sign this transaction.",
"Sign Transaction"
);
}
function htmlEscape(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function printWalletBackup(data) {
const printFrame = document.createElement("iframe");
printFrame.setAttribute("title", "Contractless Wallet Backup Print");
printFrame.style.position = "fixed";
printFrame.style.right = "0";
printFrame.style.bottom = "0";
printFrame.style.width = "0";
printFrame.style.height = "0";
printFrame.style.border = "0";
printFrame.style.opacity = "0";
printFrame.style.pointerEvents = "none";
document.body.appendChild(printFrame);
const printDocument = printFrame.contentWindow?.document;
if (!printDocument) {
printFrame.remove();
setSettingsMessage("Unable to prepare print document.", "error");
return;
}
printDocument.open();
printDocument.write(`<!doctype html>
<html>
<head>
<title>Contractless Wallet Backup</title>
<style>
body { font-family: Arial, sans-serif; color: #111; padding: 28px; }
h1 { margin: 0 0 8px; font-size: 24px; }
p { margin: 0 0 18px; color: #444; }
section { margin-top: 18px; }
h2 { font-size: 15px; margin: 0 0 8px; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid #bbb; padding: 10px; border-radius: 6px; }
.warning { border: 2px solid #9b1c1c; padding: 10px; color: #7a1111; font-weight: 700; }
</style>
</head>
<body>
<h1>Contractless Wallet Backup</h1>
<p class="warning">Keep this page private. Anyone with the private key and encryption key can control this wallet.</p>
<section><h2>Wallet</h2><pre>${htmlEscape(data.wallet_name)}</pre></section>
<section><h2>Private Key</h2><pre>${htmlEscape(data.private_key)}</pre></section>
<section><h2>Encryption Key</h2><pre>${htmlEscape(data.encryption_key)}</pre></section>
</body>
</html>`);
printDocument.close();
const cleanup = () => {
setTimeout(() => printFrame.remove(), 500);
};
printFrame.contentWindow.onafterprint = cleanup;
setTimeout(() => {
try {
printFrame.contentWindow.focus();
printFrame.contentWindow.print();
setSettingsMessage("Print dialog opened.", "success");
} catch (error) {
cleanup();
setSettingsMessage(`Unable to open print dialog: ${error}`, "error");
}
}, 100);
}
async function backupWalletImage() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !walletLoaded) {
setSettingsMessage("Load a wallet before backing it up.", "error");
return;
}
try {
setSettingsMessage("Preparing wallet image backup...");
const savedPath = await invoke("backup_active_wallet_image");
setSettingsMessage(savedPath ? `Wallet image saved to ${savedPath}` : "Wallet image backup cancelled.", savedPath ? "success" : "");
} catch (error) {
setSettingsMessage(String(error), "error");
}
}
async function backupWalletPrivateKey() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !walletLoaded) {
setSettingsMessage("Load a wallet before backing it up.", "error");
return;
}
const walletKey = await requestWalletBackupKey("Enter the wallet encryption key to decrypt the private key for backup.");
if (!walletKey) {
setSettingsMessage("Private key backup cancelled.");
return;
}
try {
setSettingsMessage("Decrypting private key for backup...");
const savedPath = await invoke("backup_active_wallet_private_key", { walletKey });
setSettingsMessage(savedPath ? `Private key saved to ${savedPath}` : "Private key backup cancelled.", savedPath ? "success" : "");
} catch (error) {
setSettingsMessage(String(error), "error");
}
}
async function printWalletPrivateKeyBackup() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !walletLoaded) {
setSettingsMessage("Load a wallet before printing backup details.", "error");
return;
}
const walletKey = await requestWalletBackupKey("Enter the wallet encryption key to print the private key and encryption key.");
if (!walletKey) {
setSettingsMessage("Print backup cancelled.");
return;
}
try {
setSettingsMessage("Decrypting wallet backup for printing...");
const data = await invoke("printable_wallet_backup", { walletKey });
printWalletBackup(data);
} catch (error) {
setSettingsMessage(String(error), "error");
}
}
settingsSaveButton?.addEventListener("click", saveSettingsPage);
settingsTestNodeButton?.addEventListener("click", testSelectedSettingsNode);
settingsCacheOpenButton?.addEventListener("click", () => setCacheClearModalOpen(true));
cacheClearCloseButton?.addEventListener("click", () => setCacheClearModalOpen(false));
cacheClearModal?.addEventListener("click", (event) => {
if (event.target === cacheClearModal) {
setCacheClearModalOpen(false);
}
});
document.querySelectorAll("[data-cache-clear-kind]").forEach((button) => {
button.addEventListener("click", () => clearSelectedWalletCache(button.dataset.cacheClearKind, button));
});
settingsBrowseButtons.forEach((button) => {
button.addEventListener("click", () => browseSettingsPath(button));
});
backupKeyForm?.addEventListener("submit", (event) => {
event.preventDefault();
const walletKey = backupKeyInput?.value || "";
if (!walletKey.trim()) {
setBackupKeyMessage("Wallet encryption key cannot be empty.", "error");
backupKeyInput?.focus();
return;
}
closeBackupKeyModal(walletKey);
});
backupKeyCloseButton?.addEventListener("click", () => closeBackupKeyModal(null));
backupKeyCancelButton?.addEventListener("click", () => closeBackupKeyModal(null));
backupKeyModal?.addEventListener("click", (event) => {
if (event.target === backupKeyModal) {
closeBackupKeyModal(null);
}
});
document.querySelector("[data-backup-wallet-image]")?.addEventListener("click", backupWalletImage);
document.querySelector("[data-backup-private-key]")?.addEventListener("click", backupWalletPrivateKey);
document.querySelector("[data-print-private-key]")?.addEventListener("click", printWalletPrivateKeyBackup);
["click", "keydown", "mousemove", "pointerdown", "touchstart"].forEach((eventName) => {
document.addEventListener(eventName, resetAutoLockOnActivity, { passive: true });
});
loadRuntimeGuiSettings();

View File

@ -0,0 +1,384 @@
async function loadTauriStatus() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
try {
const status = await invoke("get_app_status");
if (bridgeStatus) {
setBridgeStatus(`${status.network === "mainnet" ? "Mainnet" : "Testnet"} disconnected`, "error");
}
} catch (error) {
setBridgeStatus(`${activeNetwork === "mainnet" ? "Mainnet" : "Testnet"} unavailable`, "error");
console.error(error);
}
}
loadTauriStatus();
networkButtons.forEach((button) => {
button.addEventListener("click", () => {
setActiveNetwork(button.dataset.networkToggle);
if (!loadWalletForm?.classList.contains("hidden")) {
refreshWalletList();
}
});
});
document.querySelectorAll("[data-start-mode]").forEach((button) => {
button.addEventListener("click", () => {
const mode = button.dataset.startMode;
setActiveStartMode(mode);
if (mode === "load") {
createWalletForm?.classList.add("hidden");
importKeyForm?.classList.add("hidden");
importImageForm?.classList.add("hidden");
loadWalletForm?.classList.remove("hidden");
setStartupMessage("");
setImportMessage("");
setImportImageMessage("");
loadWalletKeyInput?.focus();
refreshWalletList();
return;
}
if (mode === "import-key") {
createWalletForm?.classList.add("hidden");
loadWalletForm?.classList.add("hidden");
importImageForm?.classList.add("hidden");
importKeyForm?.classList.remove("hidden");
setStartupMessage("");
setLoadMessage("");
setImportImageMessage("");
setImportMessage("Paste the private key and choose a new encrypted wallet file name.");
importWalletNameInput?.focus();
return;
}
if (mode === "import-image") {
createWalletForm?.classList.add("hidden");
loadWalletForm?.classList.add("hidden");
importKeyForm?.classList.add("hidden");
importImageForm?.classList.remove("hidden");
setStartupMessage("");
setLoadMessage("");
setImportMessage("");
setImportImageMessage("Select the private key PNG image and choose a new encrypted wallet file name.");
importImageWalletNameInput?.focus();
return;
}
if (mode !== "create") {
createWalletForm?.classList.add("hidden");
loadWalletForm?.classList.add("hidden");
importKeyForm?.classList.add("hidden");
importImageForm?.classList.add("hidden");
setStartupMessage("This wallet action is not wired yet.", "error");
return;
}
loadWalletForm?.classList.add("hidden");
importKeyForm?.classList.add("hidden");
importImageForm?.classList.add("hidden");
createWalletForm?.classList.remove("hidden");
walletNameInput?.focus();
setLoadMessage("");
setImportMessage("");
setImportImageMessage("");
setStartupMessage("Name this wallet and choose the key that will encrypt it.");
});
});
document.querySelectorAll("[data-cancel-startup-form]").forEach((button) => {
button.addEventListener("click", () => {
createWalletForm?.classList.add("hidden");
loadWalletForm?.classList.add("hidden");
importKeyForm?.classList.add("hidden");
importImageForm?.classList.add("hidden");
setStartupMessage("");
setLoadMessage("");
setImportMessage("");
setImportImageMessage("");
});
});
createWalletForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const walletName = walletNameInput?.value || "";
const walletKey = walletKeyInput?.value || "";
const walletKeyConfirm = walletKeyConfirmInput?.value || "";
if (!walletName.trim()) {
setStartupMessage("Wallet name cannot be empty.", "error");
walletNameInput?.focus();
return;
}
if (!walletKey.trim()) {
setStartupMessage("Wallet key cannot be empty.", "error");
walletKeyInput?.focus();
return;
}
if (walletKey !== walletKeyConfirm) {
setStartupMessage("Wallet keys do not match.", "error");
walletKeyConfirmInput?.focus();
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setStartupMessage("Wallet creation requires the desktop app build.", "error");
return;
}
if (createWalletSubmit) {
createWalletSubmit.disabled = true;
}
setStartupMessage("Creating encrypted wallet...");
try {
const wallet = await invoke("create_wallet", {
network: activeNetwork,
walletName,
walletKey
});
updateActiveWallet(wallet);
walletNameInput.value = "";
walletKeyInput.value = "";
walletKeyConfirmInput.value = "";
setStartupMessage(`Wallet saved to ${wallet.wallet_path}`, "success");
document.body.classList.add("wallet-open");
} catch (error) {
setStartupMessage(String(error), "error");
} finally {
if (createWalletSubmit) {
createWalletSubmit.disabled = false;
}
}
});
document.querySelector("[data-refresh-wallet-list]")?.addEventListener("click", () => {
if (loadWalletKeyInput) {
loadWalletKeyInput.value = "";
}
refreshWalletList();
});
browseWalletButton?.addEventListener("click", async () => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setLoadMessage("Wallet browsing requires the desktop app build.", "error");
return;
}
setLoadMessage("Opening wallet file picker...");
try {
const wallet = await invoke("select_wallet_file", { network: activeNetwork });
if (!wallet) {
setLoadMessage("Wallet selection cancelled.");
return;
}
selectWalletOption(wallet);
setLoadMessage(`Selected ${wallet.wallet_name}.`);
loadWalletKeyInput?.focus();
} catch (error) {
setLoadMessage(String(error), "error");
}
});
browsePrivateKeyImageButton?.addEventListener("click", async () => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setImportImageMessage("Private key image browsing requires the desktop app build.", "error");
return;
}
setImportImageMessage("Opening private key image picker...");
try {
const selected = await invoke("select_private_key_image", { network: activeNetwork });
if (!selected) {
setImportImageMessage("Private key image selection cancelled.");
return;
}
importImagePathInput.value = selected.file_path;
setImportImageMessage(`Selected ${selected.file_name}.`);
importImageWalletKeyInput?.focus();
} catch (error) {
setImportImageMessage(String(error), "error");
}
});
loadWalletForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const walletPath = walletListSelect?.value || "";
const walletKey = loadWalletKeyInput?.value || "";
if (!walletPath.trim()) {
setLoadMessage("Choose a saved wallet or browse for a wallet file.", "error");
walletListSelect?.focus();
return;
}
if (!walletKey.trim()) {
setLoadMessage("Wallet key cannot be empty.", "error");
loadWalletKeyInput?.focus();
return;
}
if (loadWalletSubmit) {
loadWalletSubmit.disabled = true;
}
setLoadMessage("Loading wallet...");
try {
const wallet = await loadWalletByPath(walletPath, walletKey);
updateActiveWallet(wallet);
loadWalletKeyInput.value = "";
setLoadMessage(`Wallet loaded from ${wallet.wallet_path}`, "success");
document.body.classList.add("wallet-open");
} catch (error) {
setLoadMessage(String(error), "error");
} finally {
if (loadWalletSubmit) {
loadWalletSubmit.disabled = false;
}
}
});
importKeyForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const walletName = importWalletNameInput?.value || "";
const privateKey = importPrivateKeyInput?.value || "";
const walletKey = importWalletKeyInput?.value || "";
const walletKeyConfirm = importWalletKeyConfirmInput?.value || "";
if (!walletName.trim()) {
setImportMessage("Wallet name cannot be empty.", "error");
importWalletNameInput?.focus();
return;
}
if (!privateKey.trim()) {
setImportMessage("Private key cannot be empty.", "error");
importPrivateKeyInput?.focus();
return;
}
if (!walletKey.trim()) {
setImportMessage("Wallet key cannot be empty.", "error");
importWalletKeyInput?.focus();
return;
}
if (walletKey !== walletKeyConfirm) {
setImportMessage("Wallet keys do not match.", "error");
importWalletKeyConfirmInput?.focus();
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setImportMessage("Private key import requires the desktop app build.", "error");
return;
}
if (importKeySubmit) {
importKeySubmit.disabled = true;
}
setImportMessage("Importing encrypted wallet...");
try {
const wallet = await invoke("import_private_key_wallet", {
network: activeNetwork,
walletName,
walletKey,
privateKey
});
updateActiveWallet(wallet);
importWalletNameInput.value = "";
importPrivateKeyInput.value = "";
importWalletKeyInput.value = "";
importWalletKeyConfirmInput.value = "";
setImportMessage(`Wallet saved to ${wallet.wallet_path}`, "success");
document.body.classList.add("wallet-open");
} catch (error) {
setImportMessage(String(error), "error");
} finally {
if (importKeySubmit) {
importKeySubmit.disabled = false;
}
}
});
importImageForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const walletName = importImageWalletNameInput?.value || "";
const imagePath = importImagePathInput?.value || "";
const walletKey = importImageWalletKeyInput?.value || "";
const walletKeyConfirm = importImageWalletKeyConfirmInput?.value || "";
if (!walletName.trim()) {
setImportImageMessage("Wallet name cannot be empty.", "error");
importImageWalletNameInput?.focus();
return;
}
if (!imagePath.trim()) {
setImportImageMessage("Select a private key PNG image.", "error");
browsePrivateKeyImageButton?.focus();
return;
}
if (!walletKey.trim()) {
setImportImageMessage("Wallet key cannot be empty.", "error");
importImageWalletKeyInput?.focus();
return;
}
if (walletKey !== walletKeyConfirm) {
setImportImageMessage("Wallet keys do not match.", "error");
importImageWalletKeyConfirmInput?.focus();
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setImportImageMessage("Private key image import requires the desktop app build.", "error");
return;
}
if (importImageSubmit) {
importImageSubmit.disabled = true;
}
setImportImageMessage("Importing encrypted wallet from image...");
try {
const wallet = await invoke("import_private_key_image_wallet", {
network: activeNetwork,
walletName,
walletKey,
imagePath
});
updateActiveWallet(wallet);
importImageWalletNameInput.value = "";
importImagePathInput.value = "";
importImageWalletKeyInput.value = "";
importImageWalletKeyConfirmInput.value = "";
setImportImageMessage(`Wallet saved to ${wallet.wallet_path}`, "success");
document.body.classList.add("wallet-open");
} catch (error) {
setImportImageMessage(String(error), "error");
} finally {
if (importImageSubmit) {
importImageSubmit.disabled = false;
}
}
});
lockWalletButton?.addEventListener("click", async () => {
const invoke = window.__TAURI__?.core?.invoke;
try {
await invoke?.("lock_wallet");
} finally {
lockWalletSession();
}
});
registrationAction?.addEventListener("click", registerActiveWallet);

View File

@ -0,0 +1,403 @@
const SWAP_FEE_ATOMIC = 100000000n;
let swapOfferLoadPromise = null;
let swapNetworkAssets = [];
let swapNftKeys = new Set();
let swapOfferAssetsKey = "";
function setSwapOfferMessage(message, type = "") {
setScopedTransactionMessage("Swap", swapOfferMessage, message, type);
}
function swapAssetKey(asset) {
return `${asset.kind}|${asset.asset}|${Number(asset.nftSeries || 0)}`;
}
function swapAssetLabel(asset, includeBalance = false) {
const series = Number(asset.nftSeries || 0);
const name = asset.kind === "nft" && series > 0
? `${asset.asset} #${series}`
: asset.asset;
if (!includeBalance) {
return asset.kind === "nft" ? `${name} (NFT)` : name;
}
if (asset.kind === "nft") {
return `${name} (1 item)`;
}
return `${name} (${formatAtomicBalance(String(asset.balance || "0"))})`;
}
function selectedSwapAsset(select) {
const option = select?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
nftSeries: Number(option.dataset.nftSeries || 0),
kind: option.dataset.kind || "token",
balance: BigInt(option.dataset.balance || "0")
};
}
function swapMinimumTip(value, asset) {
if (asset?.kind === "nft") {
return 0n;
}
return (value + 99n) / 100n;
}
function populateSwapAssetSelect(select, assets, includeBalance) {
if (!select) {
return;
}
const previous = select.value;
select.innerHTML = "";
assets.forEach((asset) => {
const option = document.createElement("option");
option.value = swapAssetKey(asset);
option.textContent = swapAssetLabel(asset, includeBalance);
option.dataset.asset = asset.asset;
option.dataset.nftSeries = String(Number(asset.nftSeries || 0));
option.dataset.kind = asset.kind;
option.dataset.balance = String(asset.balance || "0");
select.appendChild(option);
});
if (!assets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = walletLoaded ? "No assets available" : "Load a wallet first";
select.appendChild(option);
} else if ([...select.options].some((option) => option.value === previous)) {
select.value = previous;
}
}
function swapRegistryNftKeys(nfts) {
return new Set(nfts.map((nft) => {
return `${String(nft.nft_name || "").trim().toLowerCase()}|${Number(nft.series || 0)}`;
}));
}
function refreshSwapOfferSelects() {
const offered = activeWalletBalances
.filter((balance) => String(balance.asset || "").trim() && balanceAtomicValue(balance) > 0n)
.map((balance) => {
const asset = String(balance.asset || "").trim();
const nftSeries = Number(balance.nft_series || 0);
const isNft = swapNftKeys.has(`${asset.toLowerCase()}|${nftSeries}`);
return {
asset,
nftSeries,
kind: isNft ? "nft" : (isBaseTransferAsset(asset) ? "coin" : "token"),
balance: String(balance.balance || "0")
};
});
offered.sort((left, right) => swapAssetLabel(left).localeCompare(swapAssetLabel(right)));
populateSwapAssetSelect(swapOfferAssetSelect, offered, true);
populateSwapAssetSelect(swapRequestAssetSelect, swapNetworkAssets, false);
updateSwapOfferDerivedFields();
}
async function loadSwapOfferAssets() {
if (swapOfferLoadPromise) {
return swapOfferLoadPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
return;
}
const assetsKey = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (swapOfferAssetsKey === assetsKey && swapNetworkAssets.length) {
refreshSwapOfferSelects();
return;
}
swapOfferLoadPromise = (async () => {
setSwapOfferMessage("Loading swap assets...");
const [balances, tokens, nfts] = await Promise.all([
activeWalletBalances.length
? Promise.resolve(activeWalletBalances)
: invoke("get_active_wallet_balances", { broadcastNode }),
invoke("token_list", { broadcastNode }),
invoke("nft_list", { broadcastNode })
]);
activeWalletBalances = Array.isArray(balances) ? balances : [];
const tokenRows = Array.isArray(tokens) ? tokens : [];
const nftRows = Array.isArray(nfts) ? nfts : [];
swapNftKeys = swapRegistryNftKeys(nftRows);
swapNetworkAssets = [
{ asset: activeBaseCoin(), nftSeries: 0, kind: "coin", balance: "0" },
...tokenRows.map((token) => ({
asset: String(token.ticker || "").trim(),
nftSeries: 0,
kind: "token",
balance: "0"
})),
...nftRows.map((nft) => ({
asset: String(nft.nft_name || "").trim(),
nftSeries: Number(nft.series || 0),
kind: "nft",
balance: "0"
}))
];
swapNetworkAssets.sort((left, right) => swapAssetLabel(left).localeCompare(swapAssetLabel(right)));
swapOfferAssetsKey = assetsKey;
refreshSwapOfferSelects();
setSwapOfferMessage("");
})().catch((error) => {
setSwapOfferMessage(String(error), "error");
throw error;
}).finally(() => {
swapOfferLoadPromise = null;
});
return swapOfferLoadPromise;
}
function updateSwapSide(asset, amountInput, tipInput) {
if (!asset) {
amountInput.disabled = false;
tipInput.disabled = false;
tipInput.value = "";
return;
}
if (asset.kind === "nft") {
amountInput.value = "1";
amountInput.disabled = true;
tipInput.value = "0";
tipInput.disabled = true;
return;
}
amountInput.disabled = false;
tipInput.disabled = false;
const amount = decimalToAtomic(amountInput.value);
if (!amount || amount <= 0n) {
if (!tipInput.value) {
tipInput.value = "";
}
return;
}
const minimum = swapMinimumTip(amount, asset);
const current = decimalToAtomic(tipInput.value);
if (current === null || current < minimum) {
tipInput.value = atomicToDecimal(minimum);
}
}
function updateSwapOfferDerivedFields() {
const offered = selectedSwapAsset(swapOfferAssetSelect);
const requested = selectedSwapAsset(swapRequestAssetSelect);
updateSwapSide(offered, swapOfferAmountInput, swapOfferTipInput);
updateSwapSide(requested, swapRequestAmountInput, swapRequestTipInput);
if (swapSender) {
swapSender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (swapAvailable) {
swapAvailable.textContent = offered
? (offered.kind === "nft"
? "1 NFT"
: `${formatAtomicBalance(String(offered.balance))} ${offered.asset}`)
: "--";
}
if (swapFees) {
swapFees.textContent = `${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} each`;
}
}
function validateSwapSide(asset, amountInput, tipInput, label) {
if (!asset) {
return `Select the asset ${label}.`;
}
const amount = asset.kind === "nft" ? 1n : decimalToAtomic(amountInput.value);
if (amount === null || amount <= 0n) {
return `Enter a valid amount ${label}.`;
}
const tip = decimalToAtomic(tipInput.value || "0");
if (tip === null) {
return `Enter a valid miner tip ${label}.`;
}
if (asset.kind === "nft" && tip !== 0n) {
return `NFT miner tips must be zero ${label}.`;
}
if (asset.kind !== "nft" && tip < swapMinimumTip(amount, asset)) {
return `Miner tip must be at least 1% of the amount ${label}.`;
}
return "";
}
function validateSwapOfferForm() {
if (!walletLoaded) {
return "Load a wallet before creating a swap offer.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a swap offer.";
}
const offered = selectedSwapAsset(swapOfferAssetSelect);
const requested = selectedSwapAsset(swapRequestAssetSelect);
let error = validateSwapSide(offered, swapOfferAmountInput, swapOfferTipInput, "you offer");
if (error) {
return error;
}
error = validateSwapSide(requested, swapRequestAmountInput, swapRequestTipInput, "you request");
if (error) {
return error;
}
const counterparty = String(swapCounterpartyInput?.value || "").trim();
if (!counterparty) {
return "Enter the counterparty wallet address.";
}
if (
counterparty.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase() ||
counterparty.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase()
) {
return "Counterparty cannot be the active wallet address.";
}
const expiration = Number.parseInt(swapExpirationInput?.value || "0", 10);
if (!Number.isInteger(expiration) || expiration < 1 || expiration > 720) {
return "Offer expiration must be between 1 and 720 hours.";
}
const amount = offered.kind === "nft" ? 1n : decimalToAtomic(swapOfferAmountInput.value);
const tip = offered.kind === "nft" ? 0n : decimalToAtomic(swapOfferTipInput.value);
if (amount + tip > offered.balance) {
return "Offered amount plus miner tip exceeds the available asset balance.";
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(offered.asset)) {
if (amount + tip + SWAP_FEE_ATOMIC > offered.balance) {
return `The offered amount, tip, and ${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`;
}
} else if (baseBalance < SWAP_FEE_ATOMIC) {
return `Creating this offer requires ${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} for your fee.`;
}
return "";
}
function swapOfferPayload() {
const offered = selectedSwapAsset(swapOfferAssetSelect);
const requested = selectedSwapAsset(swapRequestAssetSelect);
const value1 = offered.kind === "nft" ? 1n : decimalToAtomic(swapOfferAmountInput.value);
const value2 = requested.kind === "nft" ? 1n : decimalToAtomic(swapRequestAmountInput.value);
const tip1 = offered.kind === "nft" ? 0n : decimalToAtomic(swapOfferTipInput.value);
const tip2 = requested.kind === "nft" ? 0n : decimalToAtomic(swapRequestTipInput.value);
return {
ticker1: offered.asset,
nftSeries1: offered.nftSeries,
value1: String(value1),
ticker2: requested.asset,
nftSeries2: requested.nftSeries,
value2: String(value2),
sender2: String(swapCounterpartyInput.value || "").trim(),
tip1: String(tip1),
tip2: String(tip2),
expirationHours: Number.parseInt(swapExpirationInput.value, 10)
};
}
function clearSwapOfferForm() {
swapOfferAmountInput.value = "";
swapOfferTipInput.value = "";
swapRequestAmountInput.value = "";
swapRequestTipInput.value = "";
swapCounterpartyInput.value = "";
swapExpirationInput.value = "72";
updateSwapOfferDerivedFields();
}
function setSavedSwapOfferPath(path = "") {
if (swapSavedPathInput) {
swapSavedPathInput.value = path;
}
swapSavedPathPanel?.classList.toggle("hidden", !path);
}
async function saveSwapOffer() {
const validationError = validateSwapOfferForm();
if (validationError) {
setSwapOfferMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setSwapOfferMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setSwapOfferMessage("Signing cancelled.");
return;
}
const button = document.querySelector("[data-swap-offer-save]");
button.disabled = true;
setSavedSwapOfferPath("");
setSwapOfferMessage("Signing and saving swap offer...");
try {
const result = await invoke("save_swap_offer", {
...swapOfferPayload(),
broadcastNode: activeBroadcastNode(),
walletKey
});
clearSwapOfferForm();
setSavedSwapOfferPath(result.file_path || "");
setSwapOfferMessage(
`Swap offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the counterparty.`,
"success"
);
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} catch (error) {
setSwapOfferMessage(String(error), "error");
} finally {
button.disabled = false;
}
}
function prepareSwapOfferForm() {
setSwapOfferMessage("");
loadSwapOfferAssets().catch(() => {
// The form already displays the lookup error.
});
}
function resetSwapOfferSession() {
swapOfferLoadPromise = null;
swapNetworkAssets = [];
swapNftKeys = new Set();
swapOfferAssetsKey = "";
clearSwapOfferForm();
setSavedSwapOfferPath("");
if (swapOfferAssetSelect) {
swapOfferAssetSelect.innerHTML = "";
}
if (swapRequestAssetSelect) {
swapRequestAssetSelect.innerHTML = "";
}
setSwapOfferMessage("");
}
swapOfferAssetSelect?.addEventListener("change", updateSwapOfferDerivedFields);
swapRequestAssetSelect?.addEventListener("change", updateSwapOfferDerivedFields);
swapOfferAmountInput?.addEventListener("input", updateSwapOfferDerivedFields);
swapRequestAmountInput?.addEventListener("input", updateSwapOfferDerivedFields);
swapOfferTipInput?.addEventListener("input", () => setSwapOfferMessage(""));
swapRequestTipInput?.addEventListener("input", () => setSwapOfferMessage(""));
swapCounterpartyInput?.addEventListener("input", () => setSwapOfferMessage(""));
swapExpirationInput?.addEventListener("input", () => setSwapOfferMessage(""));
document.querySelector("[data-swap-offer-save]")?.addEventListener("click", saveSwapOffer);
swapCopyPathButton?.addEventListener("click", async () => {
const path = String(swapSavedPathInput?.value || "");
if (!path || !(await writeClipboardText(path))) {
setSwapOfferMessage("Unable to copy the saved offer path.", "error");
return;
}
const original = swapCopyPathButton.textContent;
swapCopyPathButton.textContent = "Copied";
setTimeout(() => {
swapCopyPathButton.textContent = original;
}, 1200);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,497 @@
const transferSaveButton = document.querySelector("[data-transfer-save]");
const transferBroadcastButton = document.querySelector("[data-transfer-broadcast]");
const ATOMIC_UNITS_PER_COIN = 100000000n;
const NON_BASE_TRANSFER_MIN_FEE_ATOMIC = 100000000n;
let transferBalancesRefreshPromise = null;
function setTransferMessage(message, type = "") {
setScopedTransactionMessage("Transfer", transferMessage, message, type);
}
function balanceAtomicValue(balance) {
try {
return BigInt(balance?.balance || "0");
} catch (_) {
return 0n;
}
}
function transferAssetKey(balance) {
return `${balance?.asset || ""}|${Number(balance?.nft_series || 0)}`;
}
function transferAssetLabel(balance) {
const amount = formatAtomicBalance(balance?.balance || "0");
const series = Number(balance?.nft_series || 0);
if (series > 0) {
return `${balance.asset} #${series} (${amount})`;
}
return `${balance.asset} (${amount})`;
}
function decimalToAtomic(value) {
const text = String(value || "").trim();
if (!/^\d+(\.\d{0,8})?$/.test(text)) {
return null;
}
const [whole, fraction = ""] = text.split(".");
const paddedFraction = fraction.padEnd(8, "0");
return BigInt(whole || "0") * ATOMIC_UNITS_PER_COIN + BigInt(paddedFraction || "0");
}
function atomicToDecimal(value) {
const atomic = BigInt(value || 0);
const whole = atomic / ATOMIC_UNITS_PER_COIN;
const fraction = String(atomic % ATOMIC_UNITS_PER_COIN).padStart(8, "0");
return `${whole}.${fraction}`;
}
function isBaseTransferAsset(asset) {
return String(asset || "").trim().toUpperCase() === activeBaseCoin();
}
function minimumTransferFee(amount, asset) {
if (isBaseTransferAsset(asset)) {
return (amount + 99n) / 100n;
}
return NON_BASE_TRANSFER_MIN_FEE_ATOMIC;
}
function baseCoinBalanceAtomic() {
const baseCoin = activeBaseCoin();
const baseBalance = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []).find((balance) => {
return String(balance.asset || "").trim().toUpperCase() === baseCoin && !Number(balance.nft_series || 0);
});
return balanceAtomicValue(baseBalance);
}
function selectedTransferAsset() {
if (!transferAssetSelect?.value) {
return null;
}
const option = transferAssetSelect.selectedOptions?.[0];
if (!option) {
return null;
}
return {
asset: option.dataset.asset || "",
nftSeries: Number(option.dataset.nftSeries || 0),
balance: BigInt(option.dataset.balance || "0")
};
}
function updateTransferDerivedFields() {
const selected = selectedTransferAsset();
const sender = activeWalletAddressShort || activeWalletAddressHex || "--";
if (transferSender) {
transferSender.textContent = sender;
}
if (!selected) {
if (transferCoin) {
transferCoin.textContent = "--";
}
if (transferNftSeries) {
transferNftSeries.textContent = "0";
}
if (transferBalance) {
transferBalance.textContent = "--";
}
if (transferAmountInput) {
transferAmountInput.disabled = false;
}
if (transferFeeInput) {
transferFeeInput.value = "";
}
return;
}
if (transferCoin) {
transferCoin.textContent = selected.asset || "--";
}
if (transferNftSeries) {
transferNftSeries.textContent = String(selected.nftSeries || 0);
}
if (transferBalance) {
const seriesLabel = selected.nftSeries > 0 ? ` #${selected.nftSeries}` : "";
transferBalance.textContent = `${formatAtomicBalance(String(selected.balance))} ${selected.asset}${seriesLabel}`;
}
if (transferAmountInput) {
if (selected.nftSeries > 0) {
transferAmountInput.value = "1";
transferAmountInput.disabled = true;
} else {
transferAmountInput.disabled = false;
}
}
updateTransferFee();
}
function updateTransferFee() {
if (!transferFeeInput) {
return;
}
const selected = selectedTransferAsset();
if (!selected) {
transferFeeInput.value = "";
return;
}
const amount = selected.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value);
if (amount === null || amount <= 0n) {
transferFeeInput.value = "";
return;
}
transferFeeInput.value = atomicToDecimal(minimumTransferFee(amount, selected.asset));
}
function refreshTransferAssetOptions() {
if (!transferAssetSelect) {
return;
}
const previous = transferAssetSelect.value;
transferAssetSelect.innerHTML = "";
const spendableBalances = (Array.isArray(activeWalletBalances) ? activeWalletBalances : [])
.filter((balance) => (balance.asset || "").trim() && balanceAtomicValue(balance) > 0n)
.sort((left, right) => {
const leftSeries = Number(left.nft_series || 0);
const rightSeries = Number(right.nft_series || 0);
if ((left.asset || "") !== (right.asset || "")) {
return (left.asset || "").localeCompare(right.asset || "");
}
return leftSeries - rightSeries;
});
if (!spendableBalances.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = walletLoaded ? "No spendable assets found" : "Load a wallet first";
transferAssetSelect.appendChild(option);
updateTransferDerivedFields();
return;
}
spendableBalances.forEach((balance) => {
const option = document.createElement("option");
option.value = transferAssetKey(balance);
option.textContent = transferAssetLabel(balance);
option.dataset.asset = balance.asset || "";
option.dataset.nftSeries = String(Number(balance.nft_series || 0));
option.dataset.balance = String(balance.balance || "0");
transferAssetSelect.appendChild(option);
});
if ([...transferAssetSelect.options].some((option) => option.value === previous)) {
transferAssetSelect.value = previous;
}
updateTransferDerivedFields();
}
async function refreshTransferBalancesIfNeeded() {
if (activeWalletBalances.length || !walletLoaded || !registrationRegistered) {
refreshTransferAssetOptions();
return;
}
if (transferBalancesRefreshPromise) {
return transferBalancesRefreshPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
refreshTransferAssetOptions();
return;
}
transferBalancesRefreshPromise = (async () => {
setTransferMessage("Loading spendable assets...");
try {
const balances = await invoke("get_active_wallet_balances", { broadcastNode });
activeWalletBalances = Array.isArray(balances) ? balances : [];
refreshTransferAssetOptions();
setTransferMessage("");
} catch (error) {
refreshTransferAssetOptions();
setTransferMessage(String(error), "error");
throw error;
} finally {
transferBalancesRefreshPromise = null;
}
})();
return transferBalancesRefreshPromise;
}
function parseTransferPaymentUri(value) {
const text = String(value || "").trim();
let uri;
try {
uri = new URL(text);
} catch (_) {
throw new Error("Enter a valid Contractless payment URI.");
}
if (uri.protocol.toLowerCase() !== "contractless:") {
throw new Error("Payment URI must begin with contractless:.");
}
const encodedAddress = uri.hostname || uri.pathname.replace(/^\/+/, "");
let receiver;
try {
receiver = decodeURIComponent(encodedAddress).trim();
} catch (_) {
throw new Error("Payment URI contains an invalid receiver address.");
}
if (!receiver) {
throw new Error("Payment URI does not contain a receiver address.");
}
const asset = (uri.searchParams.get("asset") || activeBaseCoin()).trim();
if (!asset) {
throw new Error("Payment URI does not contain a valid asset.");
}
const amount = (uri.searchParams.get("amount") || "").trim();
if (amount) {
const atomicAmount = decimalToAtomic(amount);
if (atomicAmount === null || atomicAmount <= 0n) {
throw new Error("Payment URI contains an invalid amount.");
}
}
const label = (uri.searchParams.get("label") || "").trim();
return { receiver, asset, amount, label };
}
async function loadTransferPaymentUri(value) {
const payment = parseTransferPaymentUri(value);
openTransactionType("Transfer");
await refreshTransferBalancesIfNeeded();
const matchingOption = [...(transferAssetSelect?.options || [])].find((option) => {
return (
String(option.dataset.asset || "").trim().toLowerCase() === payment.asset.toLowerCase() &&
Number(option.dataset.nftSeries || 0) === 0
);
});
if (!matchingOption) {
throw new Error(`The active wallet does not have a spendable ${payment.asset} balance.`);
}
transferAssetSelect.value = matchingOption.value;
transferReceiverInput.value = payment.receiver;
transferAmountInput.value = payment.amount;
if (typeof addPaymentUriAddressBookEntry === "function") {
await addPaymentUriAddressBookEntry(payment.receiver, payment.label);
}
updateTransferDerivedFields();
updateTransferFee();
setTransferMessage("");
}
function validateTransferForm() {
const selected = selectedTransferAsset();
if (!walletLoaded) {
return "Load a wallet before creating a transfer.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a transfer.";
}
if (!selected) {
return "Select a coin, token, or NFT to transfer.";
}
const receiver = String(transferReceiverInput?.value || "").trim();
if (!receiver) {
return "Enter a receiver address.";
}
if (
receiver.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase() ||
receiver.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase()
) {
return "Receiver cannot be the active wallet address.";
}
const amount = selected.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value);
if (amount === null || amount <= 0n) {
return "Enter a valid amount with up to 8 decimal places.";
}
if (selected.nftSeries > 0 && String(transferAmountInput?.value || "").trim() !== "1") {
return "NFT transfers must send exactly 1 item.";
}
if (amount > selected.balance) {
return "Amount exceeds the available balance for this asset.";
}
const expectedFee = minimumTransferFee(amount, selected.asset);
const fee = decimalToAtomic(transferFeeInput?.value || "0");
if (fee === null) {
return "Transaction fee could not be calculated.";
}
if (fee < expectedFee) {
return `Transaction fee must be at least ${atomicToDecimal(expectedFee)} ${activeBaseCoin()}.`;
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(selected.asset)) {
if (amount + fee > selected.balance) {
return "Amount plus transaction fee exceeds the available base coin balance.";
}
} else if (fee > baseBalance) {
return `This transfer needs ${atomicToDecimal(fee)} ${activeBaseCoin()} for the transaction fee.`;
}
return "";
}
function transferPayload() {
const selected = selectedTransferAsset();
const value = selected?.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value);
const fee = decimalToAtomic(transferFeeInput?.value || "0");
return {
receiver: String(transferReceiverInput?.value || "").trim(),
coin: selected?.asset || "",
nftSeries: Number(selected?.nftSeries || 0),
value: String(value || 0n),
txfee: String(fee || 0n)
};
}
function setTransferButtonsDisabled(disabled) {
if (transferSaveButton) {
transferSaveButton.disabled = disabled;
}
if (transferBroadcastButton) {
transferBroadcastButton.disabled = disabled;
}
}
function clearTransferForm() {
if (transferReceiverInput) {
transferReceiverInput.value = "";
}
if (transferAmountInput) {
transferAmountInput.value = "";
}
if (transferAssetSelect) {
transferAssetSelect.selectedIndex = -1;
}
updateTransferDerivedFields();
}
async function handleTransferAction(action) {
const error = validateTransferForm();
if (error) {
setTransferMessage(error, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setTransferMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setTransferMessage("Signing cancelled.");
return;
}
setTransferButtonsDisabled(true);
try {
if (action === "save") {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before saving.");
}
setTransferMessage("Saving transfer...");
const review = await invoke("save_transfer_transaction", {
...transferPayload(),
broadcastNode,
walletKey
});
clearTransferForm();
setTransferMessage(`Transfer saved to unbroadcast transactions: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setTransferMessage("Broadcasting transfer...");
const result = await invoke("broadcast_transfer_transaction", {
...transferPayload(),
broadcastNode,
walletKey
});
clearTransferForm();
setTransferMessage(result.response || "Transfer broadcast.", "success");
}
} catch (error) {
setTransferMessage(String(error), "error");
} finally {
setTransferButtonsDisabled(false);
}
}
function prepareTransferForm() {
setTransferMessage("");
refreshTransferBalancesIfNeeded().catch(() => {
// The form already displays the RPC error; keep navigation responsive.
});
}
transferAssetSelect?.addEventListener("change", () => {
updateTransferDerivedFields();
setTransferMessage("");
});
transferReceiverInput?.addEventListener("input", () => setTransferMessage(""));
transferAmountInput?.addEventListener("input", () => {
updateTransferFee();
setTransferMessage("");
});
transferSaveButton?.addEventListener("click", () => handleTransferAction("save"));
transferBroadcastButton?.addEventListener("click", () => handleTransferAction("broadcast"));
topbarPaymentUriInput?.addEventListener("input", () => {
topbarPaymentUriInput.setCustomValidity("");
});
topbarPaymentUriForm?.addEventListener("submit", async (event) => {
event.preventDefault();
topbarPaymentUriInput?.setCustomValidity("");
try {
await loadTransferPaymentUri(topbarPaymentUriInput?.value);
if (topbarPaymentUriInput) {
topbarPaymentUriInput.value = "";
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (topbarPaymentUriInput) {
topbarPaymentUriInput.setCustomValidity(message);
topbarPaymentUriInput.reportValidity();
}
if (currentView === "send") {
setTransferMessage(message, "error");
}
}
});

View File

@ -0,0 +1,631 @@
function setUnbroadcastListMessage(message, type = "") {
if (!unbroadcastListMessage) {
return;
}
unbroadcastListMessage.textContent = message;
unbroadcastListMessage.classList.toggle("error", type === "error");
unbroadcastListMessage.classList.toggle("success", type === "success");
unbroadcastListMessage.classList.toggle("hidden", !message);
}
function setUnbroadcastReviewMessage(message, type = "") {
if (!unbroadcastReviewMessage) {
return;
}
unbroadcastReviewMessage.textContent = message;
unbroadcastReviewMessage.classList.toggle("error", type === "error");
unbroadcastReviewMessage.classList.toggle("success", type === "success");
unbroadcastReviewMessage.classList.toggle("hidden", !message);
}
function unbroadcastStatusLabel(item) {
switch (item?.status) {
case "ready":
return "Ready";
case "needs_active_signature":
return "Needs Your Signature";
case "needs_other_signature":
return "Needs Other Signature";
case "invalid_hash":
return "Invalid Hash";
case "invalid_signature":
return "Invalid Signature";
default:
return item?.status || "Unknown";
}
}
function truncateMiddle(value, left = 12, right = 12) {
const text = String(value || "");
if (text.length <= left + right + 3) {
return text;
}
return `${text.slice(0, left)}...${text.slice(-right)}`;
}
function clearUnbroadcastReview() {
activeUnbroadcastTransaction = null;
if (unbroadcastReviewGrid) {
unbroadcastReviewGrid.innerHTML = "";
}
if (unbroadcastJsonPreview) {
unbroadcastJsonPreview.textContent = "";
}
setUnbroadcastReviewMessage("");
}
function renderUnbroadcastList(items = []) {
if (!transactionList) {
return;
}
transactionList.innerHTML = "";
if (!items.length) {
setUnbroadcastListMessage("No unbroadcast transactions found.");
return;
}
setUnbroadcastListMessage("");
items.forEach((item) => {
const button = document.createElement("button");
button.className = "local-transaction";
button.type = "button";
button.dataset.unbroadcastFile = item.file_name;
button.classList.toggle(
"active",
activeUnbroadcastTransaction?.file_name === item.file_name
);
const type = document.createElement("span");
type.className = "tx-kind";
type.textContent = item.type_name || "Transaction";
const summary = document.createElement("strong");
summary.textContent = item.summary || item.file_name;
const status = document.createElement("span");
status.textContent = item.status_message || unbroadcastStatusLabel(item);
const file = document.createElement("span");
file.className = "muted-file";
file.textContent = item.txid ? truncateMiddle(item.txid) : item.file_name;
button.append(type, summary, status, file);
button.addEventListener("click", () => loadUnbroadcastTransaction(item.file_name));
transactionList.appendChild(button);
});
}
async function refreshUnbroadcastTransactions() {
if (unbroadcastRefreshInFlight) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setUnbroadcastListMessage("Tauri bridge is not available.", "error");
return;
}
unbroadcastRefreshInFlight = true;
setUnbroadcastListMessage("Loading unbroadcast transactions...");
try {
const items = await invoke("list_unbroadcast_transactions");
renderUnbroadcastList(Array.isArray(items) ? items : []);
} catch (error) {
transactionList.innerHTML = "";
setUnbroadcastListMessage(String(error), "error");
} finally {
unbroadcastRefreshInFlight = false;
}
}
function appendReviewField(label, value, wide = false) {
if (!unbroadcastReviewGrid) {
return;
}
const row = document.createElement("div");
row.classList.toggle("wide", wide);
const key = document.createElement("span");
key.textContent = label;
const val = document.createElement("b");
val.textContent = value === null || value === undefined || value === "" ? "--" : String(value);
row.append(key, val);
unbroadcastReviewGrid.appendChild(row);
}
function primitiveTransactionFields(transaction) {
return Object.entries(transaction || {}).filter(([, value]) => {
return value === null || ["string", "number", "boolean"].includes(typeof value);
});
}
function atomicReviewAmount(value) {
try {
return atomicToDecimal(BigInt(value || 0));
} catch (_) {
return String(value || "0");
}
}
function swapReviewAsset(transaction, side) {
const ticker = String(transaction?.[`ticker${side}`] || "").trim();
const series = Number(transaction?.[`nft_series${side}`] || 0);
return series > 0 ? `${ticker} #${series}` : ticker;
}
function renderSwapReviewFields(review) {
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
const sender1 = String(transaction.sender1 || "").trim();
const sender2 = String(transaction.sender2 || "").trim();
const activeIsSender1 = active === sender1;
const activeIsSender2 = active === sender2;
const expiration = Number(transaction.offer_expiration || 0);
if (activeIsSender1 || activeIsSender2) {
const sendSide = activeIsSender2 ? 2 : 1;
const receiveSide = activeIsSender2 ? 1 : 2;
const counterparty = activeIsSender2 ? sender1 : sender2;
appendReviewField("Your Role", activeIsSender1 ? "Offer Creator (Sender 1)" : "Counterparty (Sender 2)");
appendReviewField(
activeIsSender1 ? "You Offer" : "You Send",
`${atomicReviewAmount(transaction[`value${sendSide}`])} ${swapReviewAsset(transaction, sendSide)}`
);
appendReviewField(
activeIsSender1 ? "You Request" : "You Receive",
`${atomicReviewAmount(transaction[`value${receiveSide}`])} ${swapReviewAsset(transaction, receiveSide)}`
);
appendReviewField(
"Your Miner Tip",
`${atomicReviewAmount(transaction[`tip${sendSide}`])} ${swapReviewAsset(transaction, sendSide)}`
);
appendReviewField(
"Your Fee",
`${atomicReviewAmount(transaction[`txfee${sendSide}`])} ${activeBaseCoin()}`
);
appendReviewField("Counterparty", counterparty, true);
} else {
appendReviewField("Viewer Role", "Not a signer");
appendReviewField("Sender 1", sender1, true);
appendReviewField(
"Sender 1 Offers",
`${atomicReviewAmount(transaction.value1)} ${swapReviewAsset(transaction, 1)}`
);
appendReviewField("Sender 2", sender2, true);
appendReviewField(
"Sender 2 Offers",
`${atomicReviewAmount(transaction.value2)} ${swapReviewAsset(transaction, 2)}`
);
}
appendReviewField(
"Sender 1 Signature",
transaction.signature1
? (activeIsSender1 ? "Your signature is present" : "Signature supplied")
: "Missing"
);
appendReviewField(
"Sender 2 Signature",
transaction.signature2
? (activeIsSender2 ? "Your signature is present" : "Signature supplied")
: (activeIsSender2 ? "Awaiting your signature" : "Awaiting sender2 signature")
);
appendReviewField(
"Expires",
expiration ? new Date(expiration * 1000).toLocaleString() : "--"
);
}
function loanReviewPeriod(period) {
return {
d: "Daily",
w: "Weekly",
m: "Monthly"
}[String(period || "").trim()] || "Unknown";
}
function loanReviewCollateral(transaction) {
return String(transaction?.collateral || "").trim();
}
function renderLoanReviewFields(review) {
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
const lender = String(transaction.lender || "").trim();
const borrower = String(transaction.borrower || "").trim();
const activeIsLender = active === lender;
const activeIsBorrower = active === borrower;
const startTimestamp = Number(transaction.timestamp || transaction.time || 0);
const paymentCount = Number(transaction.payment_number || 0);
const paymentAmount = BigInt(transaction.payment_amount || 0);
const scheduledTotal = paymentAmount * BigInt(paymentCount);
appendReviewField(
"Your Role",
activeIsLender ? "Offer Creator (Lender)" : activeIsBorrower ? "Borrower" : "Not a signer"
);
appendReviewField("Lender", lender, true);
appendReviewField("Borrower", borrower, true);
appendReviewField(
"Loan",
`${atomicReviewAmount(transaction.loan_amount)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField(
"Collateral",
`${atomicReviewAmount(transaction.collateral_amount)} ${loanReviewCollateral(transaction)}`
);
appendReviewField(
"Start Date (UTC)",
startTimestamp ? new Date(startTimestamp * 1000).toLocaleString([], { timeZone: "UTC" }) : "--"
);
appendReviewField("Payment Period", loanReviewPeriod(transaction.payment_period));
appendReviewField("Number of Payments", paymentCount);
appendReviewField(
"Amount per Payment",
`${atomicReviewAmount(transaction.payment_amount)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField(
"Total Scheduled Repayment",
`${atomicReviewAmount(scheduledTotal)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField("Missed Payments Allowed", Number(transaction.grace_period || 0));
appendReviewField(
"Maximum Overdue Value",
`${atomicReviewAmount(transaction.max_late_value)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField(
"Lender Fee",
`${atomicReviewAmount(transaction.txfee)} ${activeBaseCoin()}`
);
appendReviewField(
"Collateral Claim Rule",
"The missed-payment allowance and maximum overdue value must both be exceeded.",
true
);
appendReviewField(
"Lender Signature",
transaction.signature1
? (activeIsLender ? "Your signature is present" : "Signature supplied")
: (activeIsLender ? "Awaiting your signature" : "Missing")
);
appendReviewField(
"Borrower Signature",
transaction.signature2
? (activeIsBorrower ? "Your signature is present" : "Signature supplied")
: (activeIsBorrower ? "Awaiting your signature" : "Awaiting borrower signature")
);
}
function renderUnbroadcastReview(review) {
activeUnbroadcastTransaction = review;
if (transactionTitle) {
transactionTitle.textContent = `${review.type_name || "Transaction"} Review`;
}
if (transactionCopy) {
transactionCopy.textContent = review.status_message || "Inspect this local transaction before broadcasting.";
}
if (transactionList) {
transactionList.classList.add("hidden");
}
formPanel?.classList.add("hidden");
reviewPanel?.classList.remove("hidden");
if (unbroadcastReviewGrid) {
unbroadcastReviewGrid.innerHTML = "";
}
appendReviewField("Type", review.type_name);
appendReviewField("Status", unbroadcastStatusLabel(review));
appendReviewField("File", review.file_name, true);
appendReviewField("Transaction Hash", review.txid || review.transaction?.hash || "--", true);
appendReviewField("Summary", review.summary, true);
if (Number(review.transaction_type) === 6) {
renderSwapReviewFields(review);
} else if (Number(review.transaction_type) === 7) {
renderLoanReviewFields(review);
} else {
primitiveTransactionFields(review.transaction)
.filter(([key]) => !["txtype", "hash"].includes(key))
.forEach(([key, value]) => {
const isSignature = key.toLowerCase().includes("signature");
appendReviewField(
key,
isSignature ? (value ? truncateMiddle(value, 18, 18) : "Missing") : value,
String(value || "").length > 28
);
});
}
if (unbroadcastJsonPreview) {
unbroadcastJsonPreview.textContent = JSON.stringify(review.transaction || {}, null, 2);
}
if (unbroadcastSignButton) {
unbroadcastSignButton.disabled = !review.can_sign;
unbroadcastSignButton.classList.toggle("hidden", !review.can_sign);
unbroadcastSignButton.textContent =
[6, 7].includes(Number(review.transaction_type))
? "Sign and Broadcast"
: "Sign Transaction";
}
if (unbroadcastBroadcastButton) {
unbroadcastBroadcastButton.disabled = !review.can_broadcast;
}
setUnbroadcastReviewMessage(review.status_message || "");
}
async function validateImportedSwapBalance(review) {
if (Number(review?.transaction_type) !== 6) {
return;
}
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
if (active !== String(transaction.sender2 || "").trim()) {
throw new Error(`This swap requires sender2 wallet ${transaction.sender2}.`);
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
const [balances, nfts] = await Promise.all([
invoke("get_active_wallet_balances", { broadcastNode }),
invoke("nft_list", { broadcastNode })
]);
activeWalletBalances = Array.isArray(balances) ? balances : [];
const nftKeys = new Set((Array.isArray(nfts) ? nfts : []).map((nft) => {
return `${String(nft.nft_name || "").trim().toLowerCase()}|${Number(nft.series || 0)}`;
}));
const asset = String(transaction.ticker2 || "").trim();
const series = Number(transaction.nft_series2 || 0);
const isNft = nftKeys.has(`${asset.toLowerCase()}|${series}`);
const balance = activeWalletBalances.find((row) => {
return (
String(row.asset || "").trim().toLowerCase() === asset.toLowerCase() &&
Number(row.nft_series || 0) === series
);
});
const available = balanceAtomicValue(balance);
const amount = BigInt(transaction.value2 || 0);
const tip = BigInt(transaction.tip2 || 0);
const fee = BigInt(transaction.txfee2 || 0);
if (isNft && tip !== 0n) {
throw new Error("NFT swap tips must be zero.");
}
if (!isNft && tip < (amount + 99n) / 100n) {
throw new Error("The counterparty miner tip must be at least 1% of the swap amount.");
}
if (amount + tip > available) {
throw new Error(`This wallet does not have enough ${swapReviewAsset(transaction, 2)} for the swap.`);
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(asset)) {
if (amount + tip + fee > available) {
throw new Error(`This wallet cannot cover the requested amount, tip, and ${activeBaseCoin()} fee.`);
}
} else if (fee > baseBalance) {
throw new Error(`This wallet does not have enough ${activeBaseCoin()} for the swap fee.`);
}
}
async function validateImportedLoanBalance(review) {
if (Number(review?.transaction_type) !== 7) {
return;
}
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
const borrower = String(transaction.borrower || "").trim();
if (active !== borrower) {
throw new Error(`This loan requires borrower wallet ${borrower}.`);
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
const balances = await invoke("get_active_wallet_balances", { broadcastNode });
activeWalletBalances = Array.isArray(balances) ? balances : [];
const collateral = loanReviewCollateral(transaction);
const split = collateral.match(/^(.*)_(\d{1,5})$/);
const asset = split ? split[1] : collateral;
const series = split ? Number(split[2]) : 0;
const balance = activeWalletBalances.find((row) => {
return (
String(row.asset || "").trim().toLowerCase() === asset.toLowerCase()
&& Number(row.nft_series || 0) === series
);
});
const available = balanceAtomicValue(balance);
const required = BigInt(transaction.collateral_amount || 0);
if (required > available) {
const label = series > 0 ? `${asset} #${series}` : asset;
throw new Error(
`This wallet requires ${atomicReviewAmount(required)} ${label} as collateral, but only ${atomicReviewAmount(available)} is available.`
);
}
}
async function importUnbroadcastTransaction() {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setUnbroadcastListMessage("Select a broadcast node before importing.", "error");
return;
}
if (!walletLoaded) {
setUnbroadcastListMessage("Load a wallet before importing a transaction.", "error");
return;
}
setUnbroadcastListMessage("Validating imported transaction...");
try {
const review = await invoke("import_unbroadcast_transaction", { broadcastNode });
if (!review) {
setUnbroadcastListMessage("");
return;
}
renderUnbroadcastReview(review);
refreshUnbroadcastTransactions();
} catch (error) {
setUnbroadcastListMessage(String(error), "error");
}
}
async function loadUnbroadcastTransaction(fileName) {
if (unbroadcastReviewInFlight) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setUnbroadcastListMessage("Tauri bridge is not available.", "error");
return;
}
unbroadcastReviewInFlight = true;
setUnbroadcastListMessage("");
try {
const review = await invoke("load_unbroadcast_transaction", { fileName });
renderUnbroadcastReview(review);
refreshUnbroadcastTransactions();
} catch (error) {
setUnbroadcastListMessage(String(error), "error");
} finally {
unbroadcastReviewInFlight = false;
}
}
function showUnbroadcastList() {
if (transactionTitle) {
transactionTitle.textContent = "Unbroadcast Transactions";
}
if (transactionCopy) {
transactionCopy.textContent = "Transactions saved locally and waiting for review.";
}
transactionList?.classList.remove("hidden");
formPanel?.classList.add("hidden");
reviewPanel?.classList.add("hidden");
clearUnbroadcastReview();
refreshUnbroadcastTransactions();
}
async function signActiveUnbroadcastTransaction() {
if (!activeUnbroadcastTransaction?.file_name) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setUnbroadcastReviewMessage("Select a broadcast node before signing.", "error");
return;
}
try {
await validateImportedSwapBalance(activeUnbroadcastTransaction);
await validateImportedLoanBalance(activeUnbroadcastTransaction);
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setUnbroadcastReviewMessage("Signing cancelled.");
return;
}
unbroadcastSignButton.disabled = true;
const transactionType = Number(activeUnbroadcastTransaction.transaction_type);
const signAndBroadcast = [6, 7].includes(transactionType);
const signingType = transactionType === 7 ? "loan" : transactionType === 6 ? "swap" : "transaction";
setUnbroadcastReviewMessage(`Signing ${signingType}...`);
try {
const review = await invoke("sign_unbroadcast_transaction", {
walletKey,
broadcastNode,
fileName: activeUnbroadcastTransaction.file_name
});
renderUnbroadcastReview(review);
refreshUnbroadcastTransactions();
if (signAndBroadcast) {
const typeName = transactionType === 7 ? "Loan" : "Swap";
setUnbroadcastReviewMessage(`${typeName} signed. Broadcasting...`);
await broadcastActiveUnbroadcastTransaction();
} else {
setUnbroadcastReviewMessage("Transaction signed.", "success");
}
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
} finally {
unbroadcastSignButton.disabled = !activeUnbroadcastTransaction?.can_sign;
}
}
async function broadcastActiveUnbroadcastTransaction() {
if (!activeUnbroadcastTransaction?.file_name) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setUnbroadcastReviewMessage("No broadcast node is selected.", "error");
return;
}
unbroadcastBroadcastButton.disabled = true;
setUnbroadcastReviewMessage("Broadcasting transaction...");
try {
const broadcastedTransaction = activeUnbroadcastTransaction?.transaction;
const result = await invoke("broadcast_unbroadcast_transaction", {
broadcastNode,
fileName: activeUnbroadcastTransaction.file_name
});
if (typeof applyActiveVanityDisplayFromTransaction === "function") {
applyActiveVanityDisplayFromTransaction(broadcastedTransaction);
}
const successMessage = result?.response || "Broadcast completed.";
transactionTitle.textContent = "Unbroadcast Transactions";
transactionCopy.textContent = "Transactions saved locally and waiting for review.";
transactionList?.classList.remove("hidden");
formPanel?.classList.add("hidden");
reviewPanel?.classList.add("hidden");
clearUnbroadcastReview();
await refreshUnbroadcastTransactions();
setUnbroadcastListMessage(successMessage, "success");
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
} finally {
unbroadcastBroadcastButton.disabled = !activeUnbroadcastTransaction?.can_broadcast;
}
}
async function deleteActiveUnbroadcastTransaction() {
if (!activeUnbroadcastTransaction?.file_name) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setUnbroadcastReviewMessage("Tauri bridge is not available.", "error");
return;
}
const fileName = activeUnbroadcastTransaction.file_name;
setUnbroadcastReviewMessage("Deleting local copy...");
try {
await invoke("delete_unbroadcast_transaction", { fileName });
showUnbroadcastList();
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
}
}
unbroadcastBackButton?.addEventListener("click", showUnbroadcastList);
unbroadcastSignButton?.addEventListener("click", signActiveUnbroadcastTransaction);
unbroadcastBroadcastButton?.addEventListener("click", broadcastActiveUnbroadcastTransaction);
unbroadcastDeleteButton?.addEventListener("click", deleteActiveUnbroadcastTransaction);
document
.querySelector("[data-import-transaction]")
?.addEventListener("click", importUnbroadcastTransaction);

View File

@ -0,0 +1,241 @@
const VANITY_ADDRESS_FEE_ATOMIC = 500000000n;
function setVanityMessage(message, type = "") {
setScopedTransactionMessage("Vanity Address", vanityMessage, message, type);
}
function vanityNetworkSuffix() {
return activeBaseCoin().toLowerCase() || "cltc";
}
function normalizedVanityName() {
return String(vanityNameInput?.value || "").trim().toLowerCase();
}
function vanityPreviewValue() {
const name = normalizedVanityName();
return name ? `${name}.${vanityNetworkSuffix()}` : "--";
}
function displayVanityAddress(vanityAddress) {
const value = String(vanityAddress || "").trim();
const separator = value.lastIndexOf(".");
if (separator < 1) {
return value || "N/A";
}
const payload = value.slice(0, separator).trimStart();
const suffix = value.slice(separator + 1);
return payload ? `${payload}.${suffix}` : "N/A";
}
function applyActiveVanityDisplay(vanityAddress) {
const display = displayVanityAddress(vanityAddress);
document.querySelectorAll("[data-active-vanity]").forEach((item) => {
item.textContent = display;
});
document.querySelectorAll("[data-copy-vanity]").forEach((button) => {
button.disabled = display === "N/A";
button.textContent = "Copy";
});
}
function applyActiveVanityDisplayFromTransaction(transaction) {
const vanityAddress = transaction?.vanity_address;
const owner = transaction?.address;
const active = activeWalletAddressShort || activeWalletAddressHex || "";
if (vanityAddress && owner && String(owner).trim().toLowerCase() === active.toLowerCase()) {
applyActiveVanityDisplay(vanityAddress);
}
}
function updateVanityDerivedFields() {
if (vanityFeeInput) {
vanityFeeInput.value = atomicToDecimal(VANITY_ADDRESS_FEE_ATOMIC);
}
if (vanityOwner) {
vanityOwner.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (vanityPreview) {
vanityPreview.textContent = vanityPreviewValue();
}
if (vanityFeeLabel) {
vanityFeeLabel.textContent = `${atomicToDecimal(VANITY_ADDRESS_FEE_ATOMIC)} ${activeBaseCoin()}`;
}
}
function validateVanityForm() {
if (!walletLoaded) {
return "Load a wallet before creating a vanity address.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a vanity address.";
}
const name = String(vanityNameInput?.value || "").trim();
if (!name) {
return "Vanity name is required.";
}
if (name.length > 20) {
return "Vanity name cannot exceed 20 letters.";
}
if (!/^[A-Za-z]+$/.test(name)) {
return "Vanity name can only contain letters.";
}
if (baseCoinBalanceAtomic() < VANITY_ADDRESS_FEE_ATOMIC) {
return `Vanity address registration requires ${atomicToDecimal(VANITY_ADDRESS_FEE_ATOMIC)} ${activeBaseCoin()} for the fee.`;
}
return "";
}
function validateVanityAvailabilityCheck() {
if (!walletLoaded) {
return "Load a wallet before checking a vanity address.";
}
if (!activeBroadcastNode()) {
return "Select a broadcast node before checking a vanity address.";
}
const name = String(vanityNameInput?.value || "").trim();
if (!name) {
return "Vanity name is required.";
}
if (name.length > 20) {
return "Vanity name cannot exceed 20 letters.";
}
if (!/^[A-Za-z]+$/.test(name)) {
return "Vanity name can only contain letters.";
}
return "";
}
function vanityPayload() {
return {
vanityName: String(vanityNameInput.value || "").trim(),
txfee: String(VANITY_ADDRESS_FEE_ATOMIC)
};
}
function clearVanityForm() {
if (vanityNameInput) {
vanityNameInput.value = "";
}
updateVanityDerivedFields();
}
function setVanityButtonsDisabled(disabled) {
document.querySelector("[data-vanity-save]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-vanity-broadcast]")?.toggleAttribute("disabled", disabled);
document.querySelector("[data-vanity-check]")?.toggleAttribute("disabled", disabled);
}
async function checkVanityAvailability() {
const validationError = validateVanityAvailabilityCheck();
if (validationError) {
setVanityMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setVanityMessage("Wallet bridge is not available.", "error");
return;
}
setVanityButtonsDisabled(true);
try {
setVanityMessage("Checking vanity address availability...");
const result = await invoke("check_vanity_address_availability", {
broadcastNode: activeBroadcastNode(),
vanityName: String(vanityNameInput.value || "").trim()
});
if (result?.available) {
setVanityMessage(`${result.vanity_address} is available.`, "success");
} else {
setVanityMessage(`${result?.vanity_address || vanityPreviewValue()} is already registered.`, "error");
}
} catch (error) {
setVanityMessage(String(error), "error");
} finally {
setVanityButtonsDisabled(false);
}
}
async function handleVanityAction(action) {
const validationError = validateVanityForm();
if (validationError) {
setVanityMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setVanityMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setVanityMessage("Signing cancelled.");
return;
}
setVanityButtonsDisabled(true);
try {
if (action === "save") {
setVanityMessage("Saving vanity address transaction...");
const review = await invoke("save_vanity_address_transaction", {
...vanityPayload(),
walletKey
});
clearVanityForm();
setVanityMessage(`Vanity address transaction saved: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setVanityMessage("Broadcasting vanity address transaction...");
const result = await invoke("broadcast_vanity_address_transaction", {
...vanityPayload(),
broadcastNode,
walletKey
});
applyActiveVanityDisplay(vanityPreviewValue());
clearVanityForm();
setVanityMessage(result.response || "Vanity address transaction broadcast.", "success");
}
} catch (error) {
setVanityMessage(String(error), "error");
} finally {
setVanityButtonsDisabled(false);
}
}
function prepareVanityAddressForm() {
updateVanityDerivedFields();
setVanityMessage("");
}
function resetVanityAddressSession() {
clearVanityForm();
setVanityMessage("");
}
vanityNameInput?.addEventListener("input", () => {
setVanityMessage("");
updateVanityDerivedFields();
});
document
.querySelector("[data-vanity-save]")
?.addEventListener("click", () => handleVanityAction("save"));
document
.querySelector("[data-vanity-broadcast]")
?.addEventListener("click", () => handleVanityAction("broadcast"));
document
.querySelector("[data-vanity-check]")
?.addEventListener("click", checkVanityAvailability);

View File

@ -0,0 +1,371 @@
function setStartupMessage(message, type = "") {
if (!startupMessage) {
return;
}
startupMessage.textContent = message;
startupMessage.classList.toggle("error", type === "error");
startupMessage.classList.toggle("success", type === "success");
}
function setLoadMessage(message, type = "") {
if (!loadMessage) {
return;
}
loadMessage.textContent = message;
loadMessage.classList.toggle("error", type === "error");
loadMessage.classList.toggle("success", type === "success");
}
function setWalletSwitchMessage(message, type = "") {
if (!walletSwitchMessage) {
return;
}
walletSwitchMessage.textContent = message;
walletSwitchMessage.classList.toggle("error", type === "error");
walletSwitchMessage.classList.toggle("success", type === "success");
}
function setImportMessage(message, type = "") {
if (!importMessage) {
return;
}
importMessage.textContent = message;
importMessage.classList.toggle("error", type === "error");
importMessage.classList.toggle("success", type === "success");
}
function setImportImageMessage(message, type = "") {
if (!importImageMessage) {
return;
}
importImageMessage.textContent = message;
importImageMessage.classList.toggle("error", type === "error");
importImageMessage.classList.toggle("success", type === "success");
}
function updateActiveWallet(wallet) {
balanceRefreshRequestId += 1;
registrationRequestId += 1;
balanceRefreshInFlight = false;
registrationCheckInFlight = false;
stopBalanceRefresh();
stopBackgroundTransactionSync();
stopLatestDashboardTransactionsPolling();
stopHistoryRefresh();
clearHistoryRows();
if (typeof resetTransactionForms === "function") {
resetTransactionForms();
}
if (currentView === "send" && typeof showTransactionList === "function") {
setActiveTransactionType("Transfer");
showTransactionList();
}
if (typeof resetMarketingCampaignPage === "function") {
resetMarketingCampaignPage();
}
if (typeof resetAddressBookPage === "function") {
resetAddressBookPage();
}
if (typeof resetAssetsPage === "function") {
resetAssetsPage();
}
if (typeof resetNftsPage === "function") {
resetNftsPage();
}
if (typeof resetLoansPage === "function") {
resetLoansPage();
}
resetLatestDashboardTransactionsMemory();
dashboardLastBalanceStatus = "";
resetBalances(`0 ${activeBaseCoin()}`, "Checking balances for this wallet.");
activeWalletPath = wallet.wallet_path || "";
const shortAddress = wallet.short_address;
const vanityAddress = wallet.vanity_address || "N/A";
const walletName = wallet.wallet_name || shortenAddress(shortAddress);
walletLoaded = true;
activeWalletAddressHex = (wallet.short_address_hex || "").toLowerCase();
activeWalletAddressShort = (wallet.short_address || "").toLowerCase();
setWalletImageMarks(wallet.private_key_image);
document.querySelectorAll("[data-active-address]").forEach((item) => {
item.textContent = shortAddress;
});
document.querySelectorAll("[data-active-vanity]").forEach((item) => {
item.textContent = vanityAddress;
});
document.querySelectorAll("[data-copy-vanity]").forEach((button) => {
button.disabled = vanityAddress === "N/A";
button.textContent = "Copy";
});
document.querySelectorAll("[data-active-wallet-short]").forEach((item) => {
item.textContent = shortenAddress(shortAddress).replace(".cltc", "");
});
registrationRegistered = false;
setRegistrationState("checking");
checkWalletRegistrationOnce();
resetAutoLockTimer();
populateTopWalletList();
updateHistoryRefreshState();
if (typeof refreshAddressBookVanitiesOnce === "function") {
refreshAddressBookVanitiesOnce();
}
}
function setActiveNetwork(network) {
activeNetwork = network;
const label = network === "mainnet" ? "Mainnet" : "Testnet";
networkButtons.forEach((button) => {
button.classList.toggle("active", button.dataset.networkToggle === network);
});
networkLabels.forEach((item) => {
item.textContent = label;
});
resetBalances(walletLoaded ? `0 ${activeBaseCoin()}` : "Load wallet");
updateBalanceRefreshState();
updateHistoryRefreshState();
resetNetworkInfoDisplays();
updateNetworkInfoRefreshState();
}
function clearActiveWallet() {
balanceRefreshRequestId += 1;
registrationRequestId += 1;
balanceRefreshInFlight = false;
walletLoaded = false;
resetAutoLockTimer();
activeWalletAddressHex = "";
activeWalletAddressShort = "";
latestKnownBlockHeight = 0;
registrationRegistered = false;
registrationCheckInFlight = false;
stopBalanceRefresh();
stopNetworkInfoRefresh();
stopNodeInfoRefresh();
stopHistoryRefresh();
if (typeof resetTransactionForms === "function") {
resetTransactionForms();
}
if (typeof resetIssueTokenSession === "function") {
resetIssueTokenSession();
}
if (typeof resetCreateNftSession === "function") {
resetCreateNftSession();
}
if (typeof resetSwapOfferSession === "function") {
resetSwapOfferSession();
}
if (typeof resetLoanOfferSession === "function") {
resetLoanOfferSession();
}
if (typeof resetLoanPaymentSession === "function") {
resetLoanPaymentSession();
}
if (typeof resetCollateralClaimSession === "function") {
resetCollateralClaimSession();
}
if (typeof resetBurnSession === "function") {
resetBurnSession();
}
if (typeof resetMarketingSession === "function") {
resetMarketingSession();
}
if (typeof resetVanityAddressSession === "function") {
resetVanityAddressSession();
}
if (typeof resetMarketingCampaignPage === "function") {
resetMarketingCampaignPage();
}
if (typeof resetAddressBookPage === "function") {
resetAddressBookPage();
}
if (typeof resetAssetsPage === "function") {
resetAssetsPage();
}
if (typeof resetNftsPage === "function") {
resetNftsPage();
}
if (typeof resetLoansPage === "function") {
resetLoansPage();
}
clearHistoryRows();
setWalletImageMarks("");
refreshReceivePage();
resetBalances();
resetNetworkInfoDisplays();
setRegistrationState("unregistered");
setRecentTransactionsForWalletState("none");
setHistoryState("Load a wallet to view transaction history.");
document.querySelectorAll("[data-active-address]").forEach((item) => {
item.textContent = "No wallet loaded";
});
document.querySelectorAll("[data-active-vanity]").forEach((item) => {
item.textContent = "N/A";
});
document.querySelectorAll("[data-copy-vanity]").forEach((button) => {
button.disabled = true;
button.textContent = "Copy";
});
document.querySelectorAll("[data-active-wallet-short]").forEach((item) => {
item.textContent = "No wallet";
});
const walletLabel = document.querySelector("[data-active-wallet-label]");
if (walletLabel) {
walletLabel.innerHTML = "No wallet <span>v</span>";
}
refreshReceivePage();
}
function lockWalletSession() {
clearWalletClipboard();
walletNameInput && (walletNameInput.value = "");
walletKeyInput && (walletKeyInput.value = "");
walletKeyConfirmInput && (walletKeyConfirmInput.value = "");
loadWalletKeyInput && (loadWalletKeyInput.value = "");
importWalletNameInput && (importWalletNameInput.value = "");
importPrivateKeyInput && (importPrivateKeyInput.value = "");
importWalletKeyInput && (importWalletKeyInput.value = "");
importWalletKeyConfirmInput && (importWalletKeyConfirmInput.value = "");
importImageWalletNameInput && (importImageWalletNameInput.value = "");
importImagePathInput && (importImagePathInput.value = "");
importImageWalletKeyInput && (importImageWalletKeyInput.value = "");
importImageWalletKeyConfirmInput && (importImageWalletKeyConfirmInput.value = "");
createWalletForm?.classList.add("hidden");
loadWalletForm?.classList.add("hidden");
importKeyForm?.classList.add("hidden");
importImageForm?.classList.add("hidden");
setStartupMessage("");
setLoadMessage("");
setImportMessage("");
setImportImageMessage("");
toggleNodeModal(false);
closeWalletSwitchModal({ restoreSelection: false });
clearActiveWallet();
setActiveView("dashboard");
document.body.classList.remove("wallet-open");
}
async function refreshWalletList() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !walletListSelect) {
setLoadMessage("Wallet loading requires the desktop app build.", "error");
return;
}
setLoadMessage("Checking saved wallets...");
try {
const wallets = await invoke("list_wallets", { network: activeNetwork });
walletListSelect.innerHTML = "";
if (!wallets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No wallets found";
walletListSelect.appendChild(option);
setLoadMessage("No app-managed wallets found. You can load an external wallet path.");
return;
}
wallets.forEach((wallet) => {
const option = document.createElement("option");
option.value = wallet.wallet_path;
option.textContent = wallet.wallet_name;
walletListSelect.appendChild(option);
});
setLoadMessage("Choose a saved wallet or enter an external wallet path.");
} catch (error) {
setLoadMessage(String(error), "error");
}
await populateTopWalletList();
}
async function loadWalletByPath(walletPath, walletKey) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error("Wallet loading requires the desktop app build.");
}
return invoke("load_wallet", { walletPath, walletKey });
}
function restoreActiveWalletSelection() {
if (topWalletListSelect) {
topWalletListSelect.value = activeWalletPath || "";
}
}
function closeWalletSwitchModal({ restoreSelection = true } = {}) {
walletSwitchModal?.classList.add("hidden");
pendingWalletSwitch = null;
if (walletSwitchSubmit) {
walletSwitchSubmit.disabled = false;
}
if (walletSwitchKeyInput) {
walletSwitchKeyInput.value = "";
}
setWalletSwitchMessage("");
if (restoreSelection) {
restoreActiveWalletSelection();
}
}
function openWalletSwitchModal(walletPath) {
if (!walletPath || walletPath === activeWalletPath) {
restoreActiveWalletSelection();
return;
}
const selectedOption = topWalletListSelect?.selectedOptions?.[0];
pendingWalletSwitch = {
walletPath,
walletName: selectedOption?.textContent?.trim() || "selected wallet"
};
if (walletSwitchName) {
walletSwitchName.textContent = `Unlock ${pendingWalletSwitch.walletName}`;
}
setWalletSwitchMessage("");
if (walletSwitchKeyInput) {
walletSwitchKeyInput.value = "";
}
walletSwitchModal?.classList.remove("hidden");
setTimeout(() => walletSwitchKeyInput?.focus(), 0);
}
function selectWalletOption(wallet) {
if (!walletListSelect || !wallet) {
return;
}
const existing = Array.from(walletListSelect.options).find((option) => option.value === wallet.wallet_path);
if (existing) {
existing.selected = true;
return;
}
const placeholder = Array.from(walletListSelect.options).find((option) => !option.value);
if (placeholder) {
placeholder.remove();
}
const option = document.createElement("option");
option.value = wallet.wallet_path;
option.textContent = wallet.wallet_name;
option.selected = true;
walletListSelect.appendChild(option);
}

3442
frontend/styles.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default wallet window permissions","local":true,"windows":["main"],"permissions":["core:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

221
src/main.rs Normal file
View File

@ -0,0 +1,221 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use blockchain::blocks::burn::{BurnTransaction, UnsignedBurnTransaction};
use blockchain::blocks::collateral::{
CollateralClaimTransaction, UnsignedCollateralClaimTransaction,
};
use blockchain::blocks::genesis::GenesisTransaction;
use blockchain::blocks::issue_token::{IssueTokenTransaction, UnsignedIssueTokenTransaction};
use blockchain::blocks::loan_payment::{
ContractPaymentTransaction, UnsignedContractPaymentTransaction,
};
use blockchain::blocks::loans::{LoanContractTransaction, UnsignedLoanContractTransaction};
use blockchain::blocks::marketing::{MarketingTransaction, UnsignedMarketingTransaction};
use blockchain::blocks::nft::{CreateNftTransaction, UnsignedCreateNftTransaction};
use blockchain::blocks::rewards::RewardsTransaction;
use blockchain::blocks::swap::{SwapTransaction, UnsignedSwapTransaction};
use blockchain::blocks::token::{CreateTokenTransaction, UnsignedCreateTokenTransaction};
use blockchain::blocks::transfer::{TransferTransaction, UnsignedTransferTransaction};
use blockchain::blocks::vanity::{UnsignedVanityAddressTransaction, VanityAddressTransaction};
use blockchain::common::skein::skein_256_hash_bytes;
use blockchain::common::types::{
minimum_transfer_fee, BORROWER_FEE, BURN_FEE, COLLATERAL_FEE, CREATE_NFT_FEE, CREATE_TOKEN_FEE,
ISSUE_TOKEN_FEE, LENDER_FEE, MARKETING_FEE, SWAP_FEE, VANITY_ADDRESS_FEE,
};
use blockchain::records::memory::mempool::BASECOIN;
use blockchain::records::memory::response_channels::generate_uid;
use blockchain::rpc::command_maps::{
RPC_ADDRESS_HISTORY, RPC_ADDRESS_TOTAL_BALANCE, RPC_BLOCK_HEIGHT, RPC_CONTRACT_BY_ADDRESS,
RPC_LATEST_ADDRESS_TRANSACTIONS, RPC_LOAN_CONTRACT, RPC_MARKETING_CAMPAIGN_HISTORY,
RPC_NETWORK_INFO, RPC_NFT_DETAILS, RPC_NFT_LIST, RPC_REGISTER_WALLET, RPC_TIME,
RPC_TOKEN_CATALOG, RPC_TOKEN_DETAILS, RPC_TOKEN_LIST, RPC_TRANSACTION_BY_TXID,
RPC_VALIDATE_MESSAGE, RPC_VANITY_LOOKUP, RPC_VANITY_OWNER_LOOKUP,
RPC_WALLET_REGISTRATION_STATUS,
};
use blockchain::standalone_tools::connections::handshake::{self, HandshakeWallet};
use blockchain::tokio::fs::{
create_dir_all, metadata, read, read_dir, read_to_string, remove_dir_all, remove_file,
};
use blockchain::tokio::sync::{oneshot, Mutex as AsyncMutex};
use blockchain::tokio::time::sleep;
use blockchain::wallets::structures::{SavedWallet, Wallet};
use blockchain::{
create_img, decode_image_and_extract_text, decrypts, encode, encrypts, to_string_pretty,
};
use native_dialog::FileDialog;
use reqwest;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap};
use std::env;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::State;
include!("wallet_app/types.rs");
include!("wallet_app/transaction_decode.rs");
include!("wallet_app/state.rs");
include!("wallet_app/rpc_scheduler.rs");
include!("wallet_app/wallet_files.rs");
include!("wallet_app/shared_rpc.rs");
include!("wallet_app/commands_wallet.rs");
include!("wallet_app/commands_balance_registration.rs");
include!("wallet_app/commands_transactions.rs");
include!("wallet_app/commands_network_cache.rs");
fn clear_system_clipboard_now() {
#[cfg(windows)]
{
if let Err(err) = clipboard_win::set_clipboard_string("") {
eprintln!("Failed to clear system clipboard: {err}");
}
}
}
#[tauri::command]
fn clear_system_clipboard() {
clear_system_clipboard_now();
}
#[tauri::command]
fn open_external_url(url: String) -> Result<(), String> {
const ALLOWED_URLS: [&str; 4] = [
"https://www.pinata.cloud/",
"https://filebase.com/",
"https://thirdweb.com/storage",
"https://www.lighthouse.storage/",
];
if !ALLOWED_URLS.contains(&url.as_str()) {
return Err("External URL is not allowed.".to_string());
}
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("rundll32");
command.args(["url.dll,FileProtocolHandler", &url]);
command
};
#[cfg(target_os = "macos")]
let mut command = {
let mut command = Command::new("open");
command.arg(&url);
command
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut command = {
let mut command = Command::new("xdg-open");
command.arg(&url);
command
};
command
.spawn()
.map(|_| ())
.map_err(|err| format!("Failed to open browser: {err}"))
}
fn main() {
tauri::Builder::default()
.manage(WalletState {
active_wallet: Mutex::new(None),
rpc_scheduler: WalletRpcScheduler::new(),
})
.on_window_event(|_, event| {
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
clear_system_clipboard_now();
}
})
.invoke_handler(tauri::generate_handler![
get_app_status,
create_wallet,
list_wallets,
select_wallet_file,
select_private_key_image,
load_wallet,
import_private_key_wallet,
import_private_key_image_wallet,
lock_wallet,
list_broadcast_nodes,
read_gui_settings,
save_gui_settings,
select_settings_path,
backup_active_wallet_image,
backup_active_wallet_private_key,
printable_wallet_backup,
clear_system_clipboard,
open_external_url,
active_wallet,
get_active_wallet_balances,
check_wallet_registration,
register_active_wallet,
latest_address_transactions,
marketing_campaign_history,
paged_address_history,
transaction_by_txid,
token_list,
token_catalog,
nft_list,
nft_details,
fetch_ipfs_metadata,
issue_token_details,
current_block_height,
network_info,
read_local_transaction_cache,
write_local_transaction_cache,
read_local_transaction_index,
write_local_transaction_index,
read_local_transaction_record,
write_local_transaction_record,
read_nft_cache,
write_nft_cache,
prune_nft_cache,
test_broadcast_node_time,
clear_wallet_cache,
read_address_book,
write_address_book,
lookup_address_vanity,
check_vanity_address_availability,
save_history_export,
list_unbroadcast_transactions,
save_transfer_transaction,
broadcast_transfer_transaction,
save_create_token_transaction,
broadcast_create_token_transaction,
save_issue_token_transaction,
broadcast_issue_token_transaction,
save_create_nft_transaction,
broadcast_create_nft_transaction,
save_burn_transaction,
broadcast_burn_transaction,
save_marketing_transaction,
broadcast_marketing_transaction,
save_vanity_address_transaction,
broadcast_vanity_address_transaction,
save_swap_offer,
save_loan_offer,
active_loan_contracts,
loan_contracts,
save_loan_payment_transaction,
broadcast_loan_payment_transaction,
claimable_collateral_contracts,
save_collateral_claim_transaction,
broadcast_collateral_claim_transaction,
import_unbroadcast_transaction,
load_unbroadcast_transaction,
sign_unbroadcast_transaction,
broadcast_unbroadcast_transaction,
delete_unbroadcast_transaction
])
.run(tauri::generate_context!())
.expect("error while running Contractless Wallet");
}

View File

@ -0,0 +1,101 @@
#[tauri::command]
async fn get_active_wallet_balances(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<Vec<BalanceView>, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let response = serialized_wallet_rpc(
&state,
socket_address,
short_address,
RPC_ADDRESS_TOTAL_BALANCE as usize,
public_key,
private_key,
"Balance lookup failed",
)
.await?;
parse_balance_response(response)
}
#[tauri::command]
async fn check_wallet_registration(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<RegistrationStatusView, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let response = serialized_wallet_rpc(
&state,
socket_address,
short_address,
RPC_WALLET_REGISTRATION_STATUS as usize,
public_key,
private_key,
"Wallet registration status lookup failed",
)
.await?;
let response_text = String::from_utf8_lossy(&response).trim().to_string();
if response_text.starts_with("error:") {
return Err(response_text);
}
Ok(RegistrationStatusView {
registered: response_text == "1",
})
}
#[tauri::command]
async fn register_active_wallet(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<RegistrationStatusView, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let short_address_bytes = Wallet::short_address_to_bytes(&short_address)
.ok_or_else(|| "Active wallet short address is invalid.".to_string())?;
let public_key_bytes = Wallet::normalize_saved_public_key_bytes(&public_key)
.ok_or_else(|| "Active wallet public key is invalid.".to_string())?;
let mut signed_payload =
Vec::with_capacity(1 + Wallet::SHORT_ADDRESS_BYTES_LENGTH + Wallet::PUBLIC_KEY_LENGTH);
signed_payload.push(RPC_REGISTER_WALLET);
signed_payload.extend_from_slice(&short_address_bytes);
signed_payload.extend_from_slice(&public_key_bytes);
let payload_hash = skein_256_hash_bytes(&signed_payload);
let signature = Wallet::sign_transaction(&payload_hash, &private_key).await;
let public_key_hex = encode(&public_key_bytes);
let command_input = format!("{short_address}|{public_key_hex}|{signature}");
let response = serialized_wallet_rpc(
&state,
socket_address,
command_input,
RPC_REGISTER_WALLET as usize,
public_key,
private_key,
"Wallet registration failed",
)
.await?;
let response_text = String::from_utf8_lossy(&response).trim().to_string();
if response_text.starts_with("error:") {
return Err(response_text);
}
Ok(RegistrationStatusView {
registered: response_text == "1",
})
}

View File

@ -0,0 +1,603 @@
#[tauri::command]
async fn current_block_height(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<u32, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let response = serialized_wallet_rpc(
&state,
socket_address,
String::new(),
RPC_BLOCK_HEIGHT as usize,
public_key,
private_key,
"Block height lookup failed",
)
.await?;
if let Some(error) = parse_error_response(&response) {
return Err(error);
}
if response.len() != 4 {
return Err("Block height response had an unexpected byte length.".to_string());
}
Ok(u32::from_le_bytes(response[0..4].try_into().map_err(
|_| "Failed to parse block height response.".to_string(),
)?))
}
#[tauri::command]
async fn network_info(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<NetworkInfoView, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let response = serialized_wallet_rpc(
&state,
socket_address,
String::new(),
RPC_NETWORK_INFO as usize,
public_key,
private_key,
"Network info lookup failed",
)
.await?;
parse_network_info_response(response)
}
#[tauri::command]
async fn test_broadcast_node_time(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<u32, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let response = serialized_wallet_rpc(
&state,
socket_address,
String::new(),
RPC_TIME as usize,
public_key,
private_key,
"Node time lookup failed",
)
.await?;
if let Some(error) = parse_error_response(&response) {
return Err(error);
}
if response.len() != 4 {
return Err("Node time response had an unexpected byte length.".to_string());
}
Ok(u32::from_le_bytes(
response[0..4]
.try_into()
.map_err(|_| "Failed to parse node time response.".to_string())?,
))
}
#[tauri::command]
async fn read_local_transaction_cache(state: State<'_, WalletState>) -> Result<String, String> {
let cache_path = active_wallet_cache_path(&state)?;
match read(&cache_path).await {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|_| "Local transaction cache is not valid UTF-8.".to_string()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()),
Err(err) => Err(format!("Unable to read local transaction cache: {err}")),
}
}
#[tauri::command]
async fn write_local_transaction_cache(
state: State<'_, WalletState>,
cache_json: String,
) -> Result<(), String> {
let cache_path = active_wallet_cache_path(&state)?;
let parsed: JsonValue = serde_json::from_str(&cache_json)
.map_err(|err| format!("Transaction cache must be valid JSON: {err}"))?;
let normalized = serde_json::to_string_pretty(&parsed)
.map_err(|err| format!("Unable to serialize transaction cache: {err}"))?;
blockchain::tokio::fs::write(&cache_path, normalized)
.await
.map_err(|err| format!("Unable to write local transaction cache: {err}"))
}
#[tauri::command]
async fn read_local_transaction_index(state: State<'_, WalletState>) -> Result<String, String> {
let history_dir = active_wallet_history_dir(&state)?;
let index_path = history_dir.join("index.json");
match read(&index_path).await {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|_| "Local transaction index is not valid UTF-8.".to_string()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()),
Err(err) => Err(format!("Unable to read local transaction index: {err}")),
}
}
#[tauri::command]
async fn write_local_transaction_index(
state: State<'_, WalletState>,
index_json: String,
) -> Result<(), String> {
let history_dir = active_wallet_history_dir(&state)?;
create_dir_all(&history_dir)
.await
.map_err(|err| format!("Unable to create transaction history directory: {err}"))?;
let parsed: JsonValue = serde_json::from_str(&index_json)
.map_err(|err| format!("Transaction index must be valid JSON: {err}"))?;
let normalized = serde_json::to_string_pretty(&parsed)
.map_err(|err| format!("Unable to serialize transaction index: {err}"))?;
blockchain::tokio::fs::write(history_dir.join("index.json"), normalized)
.await
.map_err(|err| format!("Unable to write local transaction index: {err}"))
}
#[tauri::command]
async fn read_local_transaction_record(
state: State<'_, WalletState>,
file_name: String,
) -> Result<String, String> {
let history_dir = active_wallet_history_dir(&state)?;
let file_name = validate_transaction_record_file(&file_name)?;
match read(history_dir.join(file_name)).await {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|_| "Local transaction record is not valid UTF-8.".to_string()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()),
Err(err) => Err(format!("Unable to read local transaction record: {err}")),
}
}
#[tauri::command]
async fn write_local_transaction_record(
state: State<'_, WalletState>,
file_name: String,
record_json: String,
) -> Result<(), String> {
let history_dir = active_wallet_history_dir(&state)?;
create_dir_all(&history_dir)
.await
.map_err(|err| format!("Unable to create transaction history directory: {err}"))?;
let file_name = validate_transaction_record_file(&file_name)?;
let parsed: JsonValue = serde_json::from_str(&record_json)
.map_err(|err| format!("Transaction record must be valid JSON: {err}"))?;
let normalized = serde_json::to_string_pretty(&parsed)
.map_err(|err| format!("Unable to serialize transaction record: {err}"))?;
blockchain::tokio::fs::write(history_dir.join(file_name), normalized)
.await
.map_err(|err| format!("Unable to write local transaction record: {err}"))
}
fn validate_nft_cache_key(nft_name: &str, series: u32) -> Result<String, String> {
let name = nft_name.trim().to_ascii_lowercase();
if name.is_empty() {
return Err("NFT cache name cannot be empty.".to_string());
}
if !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
return Err("NFT cache name contains unsupported characters.".to_string());
}
Ok(format!("{name}_{series}"))
}
fn nft_cache_record_from_parts(
detail: JsonValue,
metadata: JsonValue,
image_bytes: Option<Vec<u8>>,
image_mime: Option<String>,
) -> NftCacheRecordView {
let image_data_url = image_bytes.map(|bytes| {
let mime = image_mime
.filter(|value| value.starts_with("image/"))
.unwrap_or_else(|| "image/png".to_string());
format!("data:{mime};base64,{}", STANDARD.encode(bytes))
});
NftCacheRecordView {
detail,
metadata,
image_data_url,
}
}
async fn read_cached_nft_record(record_dir: &Path) -> Result<NftCacheRecordView, String> {
let record_path = record_dir.join("record.json");
let record_bytes = read(&record_path)
.await
.map_err(|err| format!("Unable to read NFT cache record: {err}"))?;
let record: JsonValue = serde_json::from_slice(&record_bytes)
.map_err(|err| format!("NFT cache record is not valid JSON: {err}"))?;
let detail = record.get("detail").cloned().unwrap_or(JsonValue::Null);
let metadata = record.get("metadata").cloned().unwrap_or(JsonValue::Null);
let image_mime = record
.get("image_mime")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let image_bytes = match read(record_dir.join("image.bin")).await {
Ok(bytes) => Some(bytes),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(format!("Unable to read NFT cached image: {err}")),
};
Ok(nft_cache_record_from_parts(
detail,
metadata,
image_bytes,
image_mime,
))
}
#[tauri::command]
async fn read_nft_cache(
state: State<'_, WalletState>,
nft_name: String,
series: u32,
) -> Result<Option<NftCacheRecordView>, String> {
let cache_dir = active_wallet_nft_dir(&state)?;
let key = validate_nft_cache_key(&nft_name, series)?;
let record_dir = cache_dir.join(key);
match read_cached_nft_record(&record_dir).await {
Ok(record) => Ok(Some(record)),
Err(err) if err.contains("os error 2") || err.contains("not find") => Ok(None),
Err(err) => Err(err),
}
}
#[tauri::command]
async fn write_nft_cache(
state: State<'_, WalletState>,
nft_name: String,
series: u32,
detail_json: String,
metadata_json: String,
image_url: String,
) -> Result<NftCacheRecordView, String> {
let cache_dir = active_wallet_nft_dir(&state)?;
let key = validate_nft_cache_key(&nft_name, series)?;
let record_dir = cache_dir.join(key);
create_dir_all(&record_dir)
.await
.map_err(|err| format!("Unable to create NFT cache directory: {err}"))?;
let detail: JsonValue = serde_json::from_str(&detail_json)
.map_err(|err| format!("NFT detail cache must be valid JSON: {err}"))?;
let metadata: JsonValue = serde_json::from_str(&metadata_json)
.map_err(|err| format!("NFT metadata cache must be valid JSON: {err}"))?;
let mut image_mime = None;
let mut image_cached = false;
let image_url = image_url.trim();
if image_url.starts_with("http://")
|| image_url.starts_with("https://")
|| image_url.starts_with("data:image/")
{
if image_url.starts_with("data:image/") {
if let Some((header, encoded)) = image_url.split_once(',') {
image_mime = header
.strip_prefix("data:")
.and_then(|header| header.split_once(';').map(|(mime, _)| mime.to_string()));
if let Ok(bytes) = base64::Engine::decode(&STANDARD, encoded) {
if bytes.len() <= 10_000_000 {
blockchain::tokio::fs::write(record_dir.join("image.bin"), bytes)
.await
.map_err(|err| format!("Unable to write NFT cached image: {err}"))?;
image_cached = true;
}
}
}
} else {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|err| format!("Failed to prepare NFT image request: {err}"))?;
let response = client
.get(image_url)
.send()
.await
.map_err(|err| format!("Failed to fetch NFT image: {err}"))?
.error_for_status()
.map_err(|err| format!("NFT image gateway returned an error: {err}"))?;
image_mime = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).to_string());
let bytes = response
.bytes()
.await
.map_err(|err| format!("Failed to read NFT image response: {err}"))?;
if bytes.len() <= 10_000_000 {
blockchain::tokio::fs::write(record_dir.join("image.bin"), &bytes)
.await
.map_err(|err| format!("Unable to write NFT cached image: {err}"))?;
image_cached = true;
}
}
}
let record = serde_json::json!({
"detail": detail,
"metadata": metadata,
"image_mime": image_mime,
"image_cached": image_cached,
});
let normalized = serde_json::to_string_pretty(&record)
.map_err(|err| format!("Unable to serialize NFT cache record: {err}"))?;
blockchain::tokio::fs::write(record_dir.join("record.json"), normalized)
.await
.map_err(|err| format!("Unable to write NFT cache record: {err}"))?;
read_cached_nft_record(&record_dir).await
}
#[tauri::command]
async fn prune_nft_cache(
state: State<'_, WalletState>,
keep_keys: Vec<String>,
) -> Result<(), String> {
let cache_dir = active_wallet_nft_dir(&state)?;
let keep: std::collections::HashSet<String> = keep_keys
.into_iter()
.map(|key| key.trim().to_ascii_lowercase())
.filter(|key| !key.is_empty())
.collect();
let mut entries = match read_dir(&cache_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(format!("Unable to read NFT cache directory: {err}")),
};
while let Ok(Some(entry)) = entries.next_entry().await {
let file_type = match entry.file_type().await {
Ok(file_type) => file_type,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let key = entry.file_name().to_string_lossy().to_ascii_lowercase();
if !keep.contains(&key) {
blockchain::tokio::fs::remove_dir_all(entry.path())
.await
.map_err(|err| format!("Unable to remove stale NFT cache entry: {err}"))?;
}
}
Ok(())
}
async fn remove_file_if_exists(path: &Path) -> Result<(), String> {
match remove_file(path).await {
Ok(_) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(format!("Unable to remove {}: {err}", path.display())),
}
}
async fn remove_dir_if_exists(path: &Path) -> Result<(), String> {
match remove_dir_all(path).await {
Ok(_) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(format!("Unable to remove {}: {err}", path.display())),
}
}
async fn clear_nft_cache_files(cache_dir: &Path, file_name: &str) -> Result<(), String> {
let mut entries = match read_dir(cache_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(format!("Unable to read NFT cache directory: {err}")),
};
while let Ok(Some(entry)) = entries.next_entry().await {
let file_type = match entry.file_type().await {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_dir() {
remove_file_if_exists(&entry.path().join(file_name)).await?;
}
}
Ok(())
}
#[tauri::command]
async fn clear_wallet_cache(state: State<'_, WalletState>, cache_kind: String) -> Result<(), String> {
match cache_kind.trim().to_ascii_lowercase().as_str() {
"history" => {
remove_file_if_exists(&active_wallet_cache_path(&state)?).await?;
remove_dir_if_exists(&active_wallet_history_dir(&state)?).await
}
"nft" | "nft_images" | "nft_media" => {
clear_nft_cache_files(&active_wallet_nft_dir(&state)?, "image.bin").await
}
"nft_details" => {
clear_nft_cache_files(&active_wallet_nft_dir(&state)?, "record.json").await
}
"address_book" => remove_file_if_exists(&active_wallet_address_book_path(&state)?).await,
_ => Err("Unknown cache type.".to_string()),
}
}
#[tauri::command]
async fn read_address_book(state: State<'_, WalletState>) -> Result<String, String> {
let address_book_path = active_wallet_address_book_path(&state)?;
match read(&address_book_path).await {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|_| "Address book is not valid UTF-8.".to_string()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok("{\"entries\":{}}".to_string())
}
Err(err) => Err(format!("Unable to read address book: {err}")),
}
}
#[tauri::command]
async fn write_address_book(
state: State<'_, WalletState>,
address_book_json: String,
) -> Result<(), String> {
let address_book_path = active_wallet_address_book_path(&state)?;
let parsed: JsonValue = serde_json::from_str(&address_book_json)
.map_err(|err| format!("Address book must be valid JSON: {err}"))?;
let normalized = serde_json::to_string_pretty(&parsed)
.map_err(|err| format!("Unable to serialize address book: {err}"))?;
blockchain::tokio::fs::write(&address_book_path, normalized)
.await
.map_err(|err| format!("Unable to write address book: {err}"))
}
#[tauri::command]
async fn lookup_address_vanity(
state: State<'_, WalletState>,
broadcast_node: String,
address: String,
) -> Result<Option<String>, String> {
ensure_gui_settings_file().await?;
let owner_address = address.trim();
if owner_address.is_empty() {
return Err("Address cannot be empty.".to_string());
}
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let response = serialized_wallet_rpc(
&state,
socket_address,
owner_address.to_string(),
RPC_VANITY_LOOKUP as usize,
public_key,
private_key,
"Vanity address lookup failed",
)
.await?;
if let Some(error) = parse_error_response(&response) {
return Err(error);
}
let vanity = String::from_utf8_lossy(&response).trim().to_string();
if vanity.is_empty() {
Ok(None)
} else {
Ok(Some(vanity))
}
}
#[tauri::command]
async fn check_vanity_address_availability(
state: State<'_, WalletState>,
broadcast_node: String,
vanity_name: String,
) -> Result<VanityAvailabilityView, String> {
ensure_gui_settings_file().await?;
let socket_address = active_broadcast_socket(&broadcast_node)?;
let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?;
let vanity_address = vanity_address_for_wallet(&vanity_name, &short_address)?;
let display_vanity = display_vanity_address(&vanity_address)
.ok_or_else(|| "Failed to build vanity address display.".to_string())?;
let response = serialized_wallet_rpc(
&state,
socket_address,
vanity_address,
RPC_VANITY_OWNER_LOOKUP as usize,
public_key,
private_key,
"Vanity availability lookup failed",
)
.await?;
if let Some(error) = parse_error_response(&response) {
return Err(error);
}
let owner = String::from_utf8_lossy(&response).trim().to_string();
if owner.is_empty() {
Ok(VanityAvailabilityView {
vanity_address: display_vanity,
available: true,
owner: None,
})
} else {
Ok(VanityAvailabilityView {
vanity_address: display_vanity,
available: false,
owner: Some(owner),
})
}
}
#[tauri::command]
async fn save_history_export(
default_file_name: String,
format: String,
contents: String,
) -> Result<Option<String>, String> {
let extension = match format.trim().to_ascii_lowercase().as_str() {
"json" => "json",
"xml" => "xml",
"csv" => "csv",
_ => return Err("Export format must be json, xml, or csv.".to_string()),
};
let mut file_name = default_file_name.trim().to_string();
if file_name.is_empty() {
file_name = format!("contractless-history.{extension}");
}
if !file_name
.to_ascii_lowercase()
.ends_with(&format!(".{extension}"))
{
file_name.push('.');
file_name.push_str(extension);
}
let selected = FileDialog::new()
.set_filename(&file_name)
.add_filter(
match extension {
"json" => "JSON",
"xml" => "XML",
"csv" => "CSV",
_ => "Export",
},
&[extension],
)
.show_save_single_file()
.map_err(|err| format!("Unable to open export save dialog: {err}"))?;
let Some(mut path) = selected else {
return Ok(None);
};
if path.extension().is_none() {
path.set_extension(extension);
}
blockchain::tokio::fs::write(&path, contents)
.await
.map_err(|err| format!("Unable to save history export: {err}"))?;
Ok(Some(path.to_string_lossy().into_owned()))
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,763 @@
#[tauri::command]
fn get_app_status() -> AppStatus {
AppStatus {
app_name: "Contractless Wallet",
network: "testnet",
bridge: "ready",
}
}
#[tauri::command]
async fn create_wallet(
state: State<'_, WalletState>,
network: String,
wallet_name: String,
wallet_key: String,
) -> Result<WalletView, String> {
let wallet_key = wallet_key.trim().to_string();
if wallet_key.is_empty() {
return Err("Wallet key cannot be empty.".to_string());
}
let wallet_filename = normalize_wallet_filename(&wallet_name)?;
let network = GuiNetwork::parse(&network)?;
let wallet_dir = app_wallet_dir(network)?;
create_dir_all(&wallet_dir)
.await
.map_err(|err| format!("Unable to create wallet directory: {err}"))?;
let wallet_path = wallet_dir.join(&wallet_filename);
if metadata(&wallet_path).await.is_ok() {
return Err(format!("A wallet named {wallet_filename} already exists."));
}
let wallet_path_string = wallet_path.to_string_lossy().into_owned();
let wallet = create_wallet_file(&wallet_path, wallet_key)
.await
.map_err(|err| format!("Wallet creation failed: {err}"))?;
let view = wallet_view(wallet_filename.clone(), wallet_path_string.clone(), &wallet).await;
state.set_active(ActiveWallet {
wallet_name: wallet_filename,
wallet_path: wallet_path_string,
wallet,
})?;
Ok(view)
}
#[tauri::command]
async fn list_wallets(network: String) -> Result<Vec<WalletListItem>, String> {
let network = GuiNetwork::parse(&network)?;
let wallet_dir = app_wallet_dir(network)?;
create_dir_all(&wallet_dir)
.await
.map_err(|err| format!("Unable to create wallet directory: {err}"))?;
let mut entries = read_dir(&wallet_dir)
.await
.map_err(|err| format!("Unable to read wallet directory: {err}"))?;
let mut wallets = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|err| format!("Unable to read wallet entry: {err}"))?
{
let path = entry.path();
if path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("wallet"))
{
wallets.push(WalletListItem {
wallet_name: wallet_name_from_path(&path)?,
wallet_path: path.to_string_lossy().into_owned(),
});
}
}
wallets.sort_by(|left, right| {
left.wallet_name
.to_ascii_lowercase()
.cmp(&right.wallet_name.to_ascii_lowercase())
});
Ok(wallets)
}
#[tauri::command]
async fn select_wallet_file(network: String) -> Result<Option<WalletListItem>, String> {
let network = GuiNetwork::parse(&network)?;
let wallet_dir = app_wallet_dir(network)?;
create_dir_all(&wallet_dir)
.await
.map_err(|err| format!("Unable to create wallet directory: {err}"))?;
let selected = FileDialog::new()
.set_location(&wallet_dir)
.add_filter("Contractless Wallet", &["wallet"])
.show_open_single_file()
.map_err(|err| format!("Unable to open wallet file picker: {err}"))?;
selected
.map(|path| {
Ok(WalletListItem {
wallet_name: wallet_name_from_path(&path)?,
wallet_path: path.to_string_lossy().into_owned(),
})
})
.transpose()
}
#[tauri::command]
async fn select_private_key_image(network: String) -> Result<Option<SelectedFile>, String> {
let network = GuiNetwork::parse(&network)?;
let wallet_dir = app_wallet_dir(network)?;
create_dir_all(&wallet_dir)
.await
.map_err(|err| format!("Unable to create wallet directory: {err}"))?;
let selected = FileDialog::new()
.set_location(&wallet_dir)
.add_filter("PNG Image", &["png"])
.show_open_single_file()
.map_err(|err| format!("Unable to open private key image picker: {err}"))?;
selected
.map(|path| {
Ok(SelectedFile {
file_name: wallet_name_from_path(&path)?,
file_path: path.to_string_lossy().into_owned(),
})
})
.transpose()
}
#[tauri::command]
async fn load_wallet(
state: State<'_, WalletState>,
wallet_path: String,
wallet_key: String,
) -> Result<WalletView, String> {
let wallet_path = wallet_path.trim().to_string();
let wallet_key = wallet_key.trim().to_string();
if wallet_path.is_empty() {
return Err("Wallet path cannot be empty.".to_string());
}
if wallet_key.is_empty() {
return Err("Wallet key cannot be empty.".to_string());
}
let path = PathBuf::from(&wallet_path);
if metadata(&path).await.is_err() {
return Err("Wallet file was not found.".to_string());
}
let wallet = Wallet::private_key_from_wallet(&path, wallet_key)
.await
.map_err(|err| format!("Wallet loading failed: {err}"))?;
let wallet_name = wallet_name_from_path(&path)?;
let view = wallet_view(wallet_name.clone(), wallet_path.clone(), &wallet).await;
state.set_active(ActiveWallet {
wallet_name,
wallet_path,
wallet,
})?;
Ok(view)
}
#[tauri::command]
async fn import_private_key_wallet(
state: State<'_, WalletState>,
network: String,
wallet_name: String,
wallet_key: String,
private_key: String,
) -> Result<WalletView, String> {
let wallet_key = wallet_key.trim().to_string();
if wallet_key.is_empty() {
return Err("Wallet key cannot be empty.".to_string());
}
let wallet_filename = normalize_wallet_filename(&wallet_name)?;
let network = GuiNetwork::parse(&network)?;
let wallet_dir = app_wallet_dir(network)?;
create_dir_all(&wallet_dir)
.await
.map_err(|err| format!("Unable to create wallet directory: {err}"))?;
let wallet_path = wallet_dir.join(&wallet_filename);
if metadata(&wallet_path).await.is_ok() {
return Err(format!("A wallet named {wallet_filename} already exists."));
}
let wallet_path_string = wallet_path.to_string_lossy().into_owned();
let wallet = import_private_key_file(&wallet_path, wallet_key, private_key)
.await
.map_err(|err| format!("Private key import failed: {err}"))?;
let view = wallet_view(wallet_filename.clone(), wallet_path_string.clone(), &wallet).await;
state.set_active(ActiveWallet {
wallet_name: wallet_filename,
wallet_path: wallet_path_string,
wallet,
})?;
Ok(view)
}
#[tauri::command]
async fn import_private_key_image_wallet(
state: State<'_, WalletState>,
network: String,
wallet_name: String,
wallet_key: String,
image_path: String,
) -> Result<WalletView, String> {
let wallet_key = wallet_key.trim().to_string();
let image_path = image_path.trim().to_string();
if wallet_key.is_empty() {
return Err("Wallet key cannot be empty.".to_string());
}
if image_path.is_empty() {
return Err("Private key image path cannot be empty.".to_string());
}
let wallet_filename = normalize_wallet_filename(&wallet_name)?;
let network = GuiNetwork::parse(&network)?;
let wallet_dir = app_wallet_dir(network)?;
create_dir_all(&wallet_dir)
.await
.map_err(|err| format!("Unable to create wallet directory: {err}"))?;
let wallet_path = wallet_dir.join(&wallet_filename);
if metadata(&wallet_path).await.is_ok() {
return Err(format!("A wallet named {wallet_filename} already exists."));
}
let image_path = PathBuf::from(&image_path);
if metadata(&image_path).await.is_err() {
return Err("Private key image file was not found.".to_string());
}
let private_key = private_key_from_png_image(&image_path, &wallet_key).await?;
let wallet_path_string = wallet_path.to_string_lossy().into_owned();
let wallet = import_private_key_file(&wallet_path, wallet_key, private_key)
.await
.map_err(|err| format!("Private key image import failed: {err}"))?;
let view = wallet_view(wallet_filename.clone(), wallet_path_string.clone(), &wallet).await;
state.set_active(ActiveWallet {
wallet_name: wallet_filename,
wallet_path: wallet_path_string,
wallet,
})?;
Ok(view)
}
#[tauri::command]
fn lock_wallet(state: State<'_, WalletState>) -> Result<(), String> {
state.clear()
}
#[tauri::command]
async fn list_broadcast_nodes() -> Result<Vec<BroadcastNodeView>, String> {
read_piggyback_nodes().await
}
fn active_wallet_snapshot(
state: &State<'_, WalletState>,
) -> Result<(String, String, String, Option<String>, String, String), String> {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
let active_wallet = guard
.as_ref()
.ok_or_else(|| "No wallet is loaded.".to_string())?;
Ok((
active_wallet.wallet_name.clone(),
active_wallet.wallet_path.clone(),
active_wallet.wallet.saved.short_address.clone(),
active_wallet.wallet.saved.vanity_address.clone(),
active_wallet.wallet.saved.public_key.clone(),
active_wallet.wallet.saved.private_key.clone(),
))
}
async fn verified_private_key_from_active_wallet(
state: &State<'_, WalletState>,
wallet_key: String,
) -> Result<(String, String, Option<String>, String, String), String> {
let wallet_key = wallet_key.trim().to_string();
if wallet_key.is_empty() {
return Err("Wallet encryption key cannot be empty.".to_string());
}
let (wallet_name, wallet_path, short_address, vanity_address, public_key, _) =
active_wallet_snapshot(state)?;
let wallet = Wallet::private_key_from_wallet(&PathBuf::from(&wallet_path), wallet_key)
.await
.map_err(|_| "Wallet encryption key did not decrypt this wallet.".to_string())?;
Ok((
wallet_name,
short_address,
vanity_address,
public_key,
wallet.saved.private_key,
))
}
#[tauri::command]
async fn backup_active_wallet_image(state: State<'_, WalletState>) -> Result<Option<String>, String> {
let (wallet_name, wallet_path, _short_address, _vanity_address, _public_key, _private_key) =
active_wallet_snapshot(&state)?;
let image_base64 = saved_private_key_image(&wallet_path)
.await
.ok_or_else(|| "This wallet does not contain a private key image.".to_string())?;
let image_bytes = STANDARD
.decode(image_base64.as_bytes())
.map_err(|err| format!("Private key image data was not valid base64: {err}"))?;
let default_name = format!(
"{}-private-key-image.png",
wallet_name.trim_end_matches(".wallet")
);
let selected = FileDialog::new()
.set_filename(&default_name)
.add_filter("PNG Image", &["png"])
.show_save_single_file()
.map_err(|err| format!("Unable to open image backup save dialog: {err}"))?;
let Some(mut path) = selected else {
return Ok(None);
};
if path.extension().is_none() {
path.set_extension("png");
}
blockchain::tokio::fs::write(&path, image_bytes)
.await
.map_err(|err| format!("Unable to save wallet image backup: {err}"))?;
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
async fn backup_active_wallet_private_key(
state: State<'_, WalletState>,
wallet_key: String,
) -> Result<Option<String>, String> {
let (wallet_name, short_address, vanity_address, public_key, private_key) =
verified_private_key_from_active_wallet(&state, wallet_key).await?;
let default_name = format!(
"{}-private-key.txt",
wallet_name.trim_end_matches(".wallet")
);
let contents = format!(
"Contractless Wallet Private Key Backup\n\nWallet: {wallet_name}\nAddress: {short_address}\nVanity Address: {}\nPublic Key: {public_key}\nPrivate Key: {private_key}\n",
vanity_address.unwrap_or_else(|| "N/A".to_string())
);
let selected = FileDialog::new()
.set_filename(&default_name)
.add_filter("Text", &["txt"])
.show_save_single_file()
.map_err(|err| format!("Unable to open private key backup save dialog: {err}"))?;
let Some(mut path) = selected else {
return Ok(None);
};
if path.extension().is_none() {
path.set_extension("txt");
}
blockchain::tokio::fs::write(&path, contents)
.await
.map_err(|err| format!("Unable to save private key backup: {err}"))?;
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
async fn printable_wallet_backup(
state: State<'_, WalletState>,
wallet_key: String,
) -> Result<WalletPrintBackupView, String> {
let wallet_key = wallet_key.trim().to_string();
let (wallet_name, short_address, vanity_address, public_key, private_key) =
verified_private_key_from_active_wallet(&state, wallet_key.clone()).await?;
Ok(WalletPrintBackupView {
wallet_name,
short_address,
vanity_address,
public_key,
private_key,
encryption_key: wallet_key,
})
}
fn unquote_ini_value(value: &str) -> String {
value.trim().trim_matches('"').trim_matches('\'').to_string()
}
fn quote_ini_value(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "/").replace('"', "\\\""))
}
fn ini_value(settings: &str, section: &str, key: &str) -> Option<String> {
let mut in_section = false;
for line in settings.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_section = trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(section);
continue;
}
if !in_section || trimmed.starts_with(';') || trimmed.starts_with('#') {
continue;
}
let Some((candidate_key, value)) = trimmed.split_once('=') else {
continue;
};
if candidate_key.trim().eq_ignore_ascii_case(key) {
return Some(unquote_ini_value(value));
}
}
None
}
fn bool_ini_value(settings: &str, section: &str, key: &str, default: bool) -> bool {
ini_value(settings, section, key)
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(default)
}
fn default_storage_path(wallet_path: &str, folder_name: &str) -> String {
let wallet_path = wallet_path.trim();
if wallet_path.is_empty() {
return folder_name.to_string();
}
let path = PathBuf::from(wallet_path);
let base = path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| path.clone());
base.join(folder_name).to_string_lossy().replace('\\', "/")
}
fn replace_ini_section(settings: &str, section: &str, entries: &[(String, String)]) -> String {
let mut output = Vec::new();
let mut in_section = false;
let mut found = false;
let mut wrote = false;
for line in settings.lines() {
let trimmed = line.trim();
let is_section = trimmed.starts_with('[') && trimmed.ends_with(']');
if is_section {
if in_section && !wrote {
for (key, value) in entries {
output.push(format!("{key:<20} = {value}"));
}
wrote = true;
}
in_section = trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(section);
if in_section {
found = true;
output.push(format!("[{section}]"));
continue;
}
}
if in_section {
continue;
}
output.push(line.to_string());
}
if in_section && !wrote {
for (key, value) in entries {
output.push(format!("{key:<20} = {value}"));
}
}
if !found {
if !output.last().is_some_and(|line| line.trim().is_empty()) {
output.push(String::new());
}
output.push(format!("[{section}]"));
for (key, value) in entries {
output.push(format!("{key:<20} = {value}"));
}
}
output.join("\n") + "\n"
}
fn replace_ini_keys(settings: &str, section: &str, entries: &[(String, String)]) -> String {
let mut output = Vec::new();
let mut in_section = false;
let mut found_section = false;
let mut written = std::collections::HashSet::new();
for line in settings.lines() {
let trimmed = line.trim();
let is_section = trimmed.starts_with('[') && trimmed.ends_with(']');
if is_section {
if in_section {
for (key, value) in entries {
if !written.contains(&key.to_ascii_lowercase()) {
output.push(format!("{key:<20} = {value}"));
}
}
}
in_section = trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(section);
found_section |= in_section;
output.push(line.to_string());
continue;
}
if in_section {
if let Some((candidate_key, _)) = trimmed.split_once('=') {
if let Some((key, value)) = entries
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(candidate_key.trim()))
{
output.push(format!("{key:<20} = {value}"));
written.insert(key.to_ascii_lowercase());
continue;
}
}
}
output.push(line.to_string());
}
if in_section {
for (key, value) in entries {
if !written.contains(&key.to_ascii_lowercase()) {
output.push(format!("{key:<20} = {value}"));
}
}
}
if !found_section {
if !output.last().is_some_and(|line| line.trim().is_empty()) {
output.push(String::new());
}
output.push(format!("[{section}]"));
for (key, value) in entries {
output.push(format!("{key:<20} = {value}"));
}
}
output.join("\n") + "\n"
}
#[tauri::command]
async fn read_gui_settings() -> Result<GuiSettingsView, String> {
ensure_gui_settings_file().await?;
let settings_path = gui_settings_path()?;
let settings = read_to_string(&settings_path)
.await
.map_err(|err| format!("Unable to read GUI settings.ini: {err}"))?;
let nodes = read_piggyback_nodes().await?;
let default_node = nodes
.first()
.map(|node| node.address.clone())
.unwrap_or_else(|| "contractless.dev:50050".to_string());
let wallet_path = ini_value(&settings, "Paths", "WALLET_PATH").unwrap_or_default();
let unbroadcast_transactions_path = ini_value(&settings, "Paths", "UNBROADCAST_TRANSACTIONS")
.filter(|path| !path.trim().is_empty())
.unwrap_or_else(|| default_storage_path(&wallet_path, "unbroadcast"));
let address_book_path = ini_value(&settings, "Paths", "ADDRESS_BOOK")
.filter(|path| !path.trim().is_empty())
.unwrap_or_else(|| default_storage_path(&wallet_path, "address_book"));
Ok(GuiSettingsView {
wallet_path,
unbroadcast_transactions_path,
address_book_path,
default_node,
saved_nodes: nodes.into_iter().map(|node| node.address).collect(),
ipfs_gateway: ini_value(&settings, "GUI", "IPFS_GATEWAY")
.or_else(|| ini_value(&settings, "GUI", "METADATA_GATEWAY"))
.or_else(|| ini_value(&settings, "GUI", "IMAGE_GATEWAY"))
.unwrap_or_else(|| "https://gateway.pinata.cloud/ipfs/".to_string()),
auto_load_trusted_nft_images: bool_ini_value(
&settings,
"GUI",
"AUTO_LOAD_TRUSTED_NFT_IMAGES",
false,
),
auto_lock_timeout: ini_value(&settings, "GUI", "AUTO_LOCK_TIMEOUT")
.unwrap_or_else(|| "15 minutes".to_string()),
clipboard_clear_timeout: ini_value(&settings, "GUI", "CLIPBOARD_CLEAR_TIMEOUT")
.unwrap_or_else(|| "60 seconds".to_string()),
require_key_before_signing: bool_ini_value(
&settings,
"GUI",
"REQUIRE_KEY_BEFORE_SIGNING",
true,
),
settings_path: settings_path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
async fn save_gui_settings(settings: GuiSettingsView) -> Result<(), String> {
ensure_gui_settings_file().await?;
let settings_path = gui_settings_path()?;
let current = read_to_string(&settings_path)
.await
.map_err(|err| format!("Unable to read GUI settings.ini: {err}"))?;
let mut nodes = Vec::new();
if !settings.default_node.trim().is_empty() {
nodes.push(settings.default_node.trim().to_string());
}
for node in settings.saved_nodes {
let node = node.trim();
if !node.is_empty() && !nodes.iter().any(|known| known.eq_ignore_ascii_case(node)) {
nodes.push(node.to_string());
}
}
let wallet_path = settings.wallet_path.trim().to_string();
let unbroadcast_transactions_path = if settings.unbroadcast_transactions_path.trim().is_empty()
{
default_storage_path(&wallet_path, "unbroadcast")
} else {
settings.unbroadcast_transactions_path.trim().to_string()
};
let address_book_path = if settings.address_book_path.trim().is_empty() {
default_storage_path(&wallet_path, "address_book")
} else {
settings.address_book_path.trim().to_string()
};
let path_entries = vec![
("WALLET_PATH".to_string(), quote_ini_value(&wallet_path)),
(
"UNBROADCAST_TRANSACTIONS".to_string(),
quote_ini_value(&unbroadcast_transactions_path),
),
(
"ADDRESS_BOOK".to_string(),
quote_ini_value(&address_book_path),
),
];
let piggyback_entries: Vec<(String, String)> = nodes
.iter()
.enumerate()
.map(|(index, node)| (format!("PIGGYBACK_{}", index + 1), quote_ini_value(node)))
.collect();
let gui_entries = vec![
(
"IPFS_GATEWAY".to_string(),
quote_ini_value(&settings.ipfs_gateway),
),
(
"AUTO_LOAD_TRUSTED_NFT_IMAGES".to_string(),
quote_ini_value(if settings.auto_load_trusted_nft_images {
"true"
} else {
"false"
}),
),
(
"AUTO_LOCK_TIMEOUT".to_string(),
quote_ini_value(&settings.auto_lock_timeout),
),
(
"CLIPBOARD_CLEAR_TIMEOUT".to_string(),
quote_ini_value(&settings.clipboard_clear_timeout),
),
(
"REQUIRE_KEY_BEFORE_SIGNING".to_string(),
quote_ini_value(if settings.require_key_before_signing {
"true"
} else {
"false"
}),
),
];
let updated = replace_ini_keys(&current, "Paths", &path_entries);
let updated = replace_ini_section(&updated, "Piggyback", &piggyback_entries);
let updated = replace_ini_section(&updated, "GUI", &gui_entries);
blockchain::tokio::fs::write(&settings_path, updated)
.await
.map_err(|err| format!("Unable to write GUI settings.ini: {err}"))
}
#[tauri::command]
fn select_settings_path(kind: String, current_path: String) -> Result<Option<String>, String> {
let mut dialog = FileDialog::new();
let current = PathBuf::from(current_path.trim());
let mut location_path: Option<PathBuf> = None;
if current.exists() {
let location = if current.is_dir() {
current
} else {
current.parent().map(PathBuf::from).unwrap_or(current)
};
location_path = Some(location);
}
if let Some(location) = &location_path {
dialog = dialog.set_location(location);
}
let selected = match kind.trim().to_ascii_lowercase().as_str() {
"folder" => dialog
.show_open_single_dir()
.map_err(|err| format!("Unable to open folder picker: {err}"))?,
"file" => dialog
.show_open_single_file()
.map_err(|err| format!("Unable to open file picker: {err}"))?,
_ => return Err("Settings path picker kind must be folder or file.".to_string()),
};
Ok(selected.map(|path| path.to_string_lossy().into_owned()))
}
#[tauri::command]
async fn active_wallet(state: State<'_, WalletState>) -> Result<Option<WalletView>, String> {
let active_wallet = {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
guard.as_ref().map(|active_wallet| {
(
active_wallet.wallet_name.clone(),
active_wallet.wallet_path.clone(),
active_wallet.wallet.saved.short_address.clone(),
active_wallet.wallet.saved.vanity_address.clone(),
)
})
};
match active_wallet {
Some((wallet_name, wallet_path, short_address, vanity_address)) => Ok(Some(WalletView {
short_address_hex: Wallet::short_address_to_bytes(&short_address)
.map(encode)
.unwrap_or_default(),
wallet_name,
short_address,
vanity_address,
private_key_image: saved_private_key_image(&wallet_path).await,
wallet_path,
})),
None => Ok(None),
}
}

View File

@ -0,0 +1,298 @@
#[derive(Clone, Copy, PartialEq, Eq)]
enum WalletRpcPriority {
Foreground,
Background,
}
impl WalletRpcPriority {
fn rank(self) -> u8 {
match self {
Self::Foreground => 2,
Self::Background => 1,
}
}
fn minimum_spacing(self) -> Duration {
match self {
Self::Foreground => Duration::from_millis(50),
Self::Background => Duration::from_secs(2),
}
}
}
struct WalletRpcRequest {
socket_address: SocketAddr,
command_input: String,
rpc_command: usize,
public_key: String,
private_key: String,
error_prefix: String,
}
struct QueuedWalletRpcRequest {
priority: WalletRpcPriority,
sequence: u64,
generation: u64,
request: WalletRpcRequest,
responder: oneshot::Sender<Result<Vec<u8>, String>>,
}
impl PartialEq for QueuedWalletRpcRequest {
fn eq(&self, other: &Self) -> bool {
self.priority == other.priority && self.sequence == other.sequence
}
}
impl Eq for QueuedWalletRpcRequest {}
impl PartialOrd for QueuedWalletRpcRequest {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for QueuedWalletRpcRequest {
fn cmp(&self, other: &Self) -> Ordering {
self.priority
.rank()
.cmp(&other.priority.rank())
.then_with(|| other.sequence.cmp(&self.sequence))
}
}
#[derive(Default)]
struct WalletRpcQueueState {
queue: BinaryHeap<QueuedWalletRpcRequest>,
last_completed: Option<Instant>,
draining: bool,
node_health: HashMap<SocketAddr, NodeRpcHealth>,
}
struct WalletRpcScheduler {
queue_state: AsyncMutex<WalletRpcQueueState>,
sequence: AtomicU64,
generation: AtomicU64,
}
#[derive(Default)]
struct NodeRpcHealth {
consecutive_failures: u32,
backoff_until: Option<Instant>,
}
impl WalletRpcScheduler {
const BACKGROUND_YIELD_CHECK: Duration = Duration::from_millis(50);
fn new() -> Self {
Self {
queue_state: AsyncMutex::new(WalletRpcQueueState::default()),
sequence: AtomicU64::new(1),
generation: AtomicU64::new(1),
}
}
fn bump_generation(&self) {
self.generation.fetch_add(1, AtomicOrdering::SeqCst);
}
fn current_generation(&self) -> u64 {
self.generation.load(AtomicOrdering::SeqCst)
}
async fn enqueue(
&self,
priority: WalletRpcPriority,
request: WalletRpcRequest,
) -> Result<Vec<u8>, String> {
let sequence = self.sequence.fetch_add(1, AtomicOrdering::SeqCst);
let generation = self.current_generation();
let (sender, receiver) = oneshot::channel();
let should_drain = {
let mut state = self.queue_state.lock().await;
state.queue.push(QueuedWalletRpcRequest {
priority,
sequence,
generation,
request,
responder: sender,
});
if state.draining {
false
} else {
state.draining = true;
true
}
};
if should_drain {
self.drain_queue().await;
}
receiver
.await
.map_err(|_| "Wallet RPC scheduler dropped the response channel.".to_string())?
}
async fn drain_queue(&self) {
loop {
let queued = {
let mut state = self.queue_state.lock().await;
match state.queue.pop() {
Some(queued) => Some(queued),
None => {
state.draining = false;
None
}
}
};
let Some(queued) = queued else {
break;
};
if queued.generation != self.current_generation() {
let _ = queued.responder.send(Err(
"Wallet RPC request was cancelled by wallet state change.".to_string(),
));
continue;
}
if self.should_yield_background(queued.priority).await {
self.requeue(queued).await;
continue;
}
if !self.wait_for_spacing(queued.priority).await {
self.requeue(queued).await;
continue;
}
if !self
.wait_for_node_backoff(queued.priority, queued.request.socket_address)
.await
{
self.requeue(queued).await;
continue;
}
let result = run_wallet_rpc_request(&queued.request).await;
{
let mut state = self.queue_state.lock().await;
state.last_completed = Some(Instant::now());
state.record_node_result(queued.request.socket_address, result.is_ok());
}
let _ = queued.responder.send(result);
}
}
async fn requeue(&self, queued: QueuedWalletRpcRequest) {
let mut state = self.queue_state.lock().await;
state.queue.push(queued);
}
async fn should_yield_background(&self, priority: WalletRpcPriority) -> bool {
if priority != WalletRpcPriority::Background {
return false;
}
let state = self.queue_state.lock().await;
state
.queue
.iter()
.any(|queued| queued.priority == WalletRpcPriority::Foreground)
}
async fn wait_for_spacing(&self, priority: WalletRpcPriority) -> bool {
loop {
if self.should_yield_background(priority).await {
return false;
}
let delay = {
let state = self.queue_state.lock().await;
let Some(last_completed) = state.last_completed else {
return true;
};
priority
.minimum_spacing()
.checked_sub(last_completed.elapsed())
};
let Some(delay) = delay else {
return true;
};
if delay.is_zero() {
return true;
}
sleep(delay.min(Self::BACKGROUND_YIELD_CHECK)).await;
}
}
async fn wait_for_node_backoff(
&self,
priority: WalletRpcPriority,
socket_address: SocketAddr,
) -> bool {
loop {
if self.should_yield_background(priority).await {
return false;
}
let delay = {
let state = self.queue_state.lock().await;
state
.node_health
.get(&socket_address)
.and_then(|health| health.backoff_until)
.and_then(|until| until.checked_duration_since(Instant::now()))
};
let Some(delay) = delay else {
return true;
};
if delay.is_zero() {
return true;
}
sleep(delay.min(Self::BACKGROUND_YIELD_CHECK)).await;
}
}
}
impl WalletRpcQueueState {
fn record_node_result(&mut self, socket_address: SocketAddr, success: bool) {
let health = self.node_health.entry(socket_address).or_default();
if success {
health.consecutive_failures = 0;
health.backoff_until = None;
return;
}
health.consecutive_failures = health.consecutive_failures.saturating_add(1);
let seconds = match health.consecutive_failures {
0 | 1 => 2,
2 => 5,
3 => 10,
_ => 20,
};
health.backoff_until = Some(Instant::now() + Duration::from_secs(seconds));
}
}
async fn run_wallet_rpc_request(request: &WalletRpcRequest) -> Result<Vec<u8>, String> {
handshake::connect_and_handshake(
request.socket_address,
request.command_input.clone(),
request.rpc_command,
HandshakeWallet::WalletParts {
public_key: request.public_key.clone(),
private_key: request.private_key.clone(),
},
generate_uid(),
)
.await
.map_err(|err| format!("{}: {err}", request.error_prefix))
}

View File

@ -0,0 +1,411 @@
fn parse_balance_response(response: Vec<u8>) -> Result<Vec<BalanceView>, String> {
if response.is_empty() {
return Ok(Vec::new());
}
if response.len() % 27 != 0 {
let response_text = String::from_utf8_lossy(&response).trim().to_string();
if !response_text.is_empty() {
return Err(response_text);
}
return Err(format!(
"Balance response had an unexpected byte length: {}",
response.len()
));
}
let mut balances = Vec::new();
for entry in response.chunks_exact(27) {
let asset = String::from_utf8_lossy(&entry[..15])
.trim_matches(char::from(0))
.trim()
.to_string();
let nft_series = u32::from_le_bytes(
entry[15..19]
.try_into()
.map_err(|_| "Failed to read NFT series from balance response.".to_string())?,
);
let balance = u64::from_le_bytes(
entry[19..27]
.try_into()
.map_err(|_| "Failed to read amount from balance response.".to_string())?,
);
balances.push(BalanceView {
asset,
nft_series,
balance: balance.to_string(),
});
}
Ok(balances)
}
fn active_broadcast_socket(broadcast_node: &str) -> Result<SocketAddr, String> {
let broadcast_node = broadcast_node.trim();
if broadcast_node.is_empty() {
return Err("Broadcast node cannot be empty.".to_string());
}
if let Ok(socket_address) = broadcast_node.parse::<SocketAddr>() {
return Ok(socket_address);
}
let (host, port) = broadcast_node
.rsplit_once(':')
.ok_or_else(|| "Broadcast node must include host and port.".to_string())?;
let port = port
.trim()
.parse::<u16>()
.map_err(|err| format!("Invalid broadcast node port: {err}"))?;
(host.trim(), port)
.to_socket_addrs()
.map_err(|err| format!("Unable to resolve broadcast node: {err}"))?
.next()
.ok_or_else(|| "Broadcast node did not resolve to an address.".to_string())
}
fn active_wallet_rpc_parts(
state: &State<'_, WalletState>,
) -> Result<(String, String, String), String> {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
let active_wallet = guard
.as_ref()
.ok_or_else(|| "No wallet is loaded.".to_string())?;
Ok((
active_wallet.wallet.saved.short_address.clone(),
active_wallet.wallet.saved.public_key.clone(),
active_wallet.wallet.saved.private_key.clone(),
))
}
fn active_wallet_cache_path(state: &State<'_, WalletState>) -> Result<PathBuf, String> {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
let active_wallet = guard
.as_ref()
.ok_or_else(|| "No wallet is loaded.".to_string())?;
let wallet_path = PathBuf::from(&active_wallet.wallet_path);
let short_address_bytes =
Wallet::short_address_to_bytes(&active_wallet.wallet.saved.short_address)
.ok_or_else(|| "Active wallet short address is invalid.".to_string())?;
let cache_name = format!("{}.address_transactions.json", encode(short_address_bytes));
Ok(wallet_path.with_file_name(cache_name))
}
fn active_wallet_history_dir(state: &State<'_, WalletState>) -> Result<PathBuf, String> {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
let active_wallet = guard
.as_ref()
.ok_or_else(|| "No wallet is loaded.".to_string())?;
let wallet_path = PathBuf::from(&active_wallet.wallet_path);
let short_address_bytes =
Wallet::short_address_to_bytes(&active_wallet.wallet.saved.short_address)
.ok_or_else(|| "Active wallet short address is invalid.".to_string())?;
let dir_name = format!("{}.address_transactions", encode(short_address_bytes));
Ok(wallet_path.with_file_name(dir_name))
}
fn active_wallet_nft_dir(state: &State<'_, WalletState>) -> Result<PathBuf, String> {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
let active_wallet = guard
.as_ref()
.ok_or_else(|| "No wallet is loaded.".to_string())?;
let wallet_path = PathBuf::from(&active_wallet.wallet_path);
let short_address_bytes =
Wallet::short_address_to_bytes(&active_wallet.wallet.saved.short_address)
.ok_or_else(|| "Active wallet short address is invalid.".to_string())?;
let dir_name = format!("{}_nfts", encode(short_address_bytes));
Ok(wallet_path.with_file_name(dir_name))
}
fn active_wallet_address_book_path(state: &State<'_, WalletState>) -> Result<PathBuf, String> {
let guard = state
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
let active_wallet = guard
.as_ref()
.ok_or_else(|| "No wallet is loaded.".to_string())?;
let wallet_path = PathBuf::from(&active_wallet.wallet_path);
Ok(wallet_path.with_file_name("address_book.json"))
}
fn validate_transaction_record_file(file_name: &str) -> Result<String, String> {
let trimmed = file_name.trim();
if trimmed.len() != 13 || !trimmed.ends_with(".json") {
return Err("Transaction record filename must look like 00000001.json.".to_string());
}
if !trimmed[..8].bytes().all(|byte| byte.is_ascii_digit()) {
return Err("Transaction record filename must start with 8 digits.".to_string());
}
Ok(trimmed.to_string())
}
fn parse_error_response(response: &[u8]) -> Option<String> {
let response_text = String::from_utf8_lossy(response).trim().to_string();
response_text.starts_with("error:").then_some(response_text)
}
fn parse_latest_transaction_response(
response: Vec<u8>,
) -> Result<Vec<LatestAddressTransactionView>, String> {
const MINER_EARNING_ASSET_BYTES: usize = 15;
const MINER_EARNING_ENTRY_BYTES: usize = 1 + MINER_EARNING_ASSET_BYTES + 4 + 8;
if let Some(error) = parse_error_response(&response) {
return Err(error);
}
if response.len() < 4 {
return Err("Latest transaction response had an unexpected byte length.".to_string());
}
let count = u32::from_le_bytes(
response[0..4]
.try_into()
.map_err(|_| "Failed to parse transaction count.".to_string())?,
) as usize;
let mut offset = 4usize;
let mut transactions = Vec::with_capacity(count);
for _ in 0..count {
if response.len().saturating_sub(offset) < 40 {
return Err("Latest transaction response entry was truncated.".to_string());
}
let txid = encode(&response[offset..offset + 32]);
offset += 32;
let block_height = u32::from_le_bytes(
response[offset..offset + 4]
.try_into()
.map_err(|_| "Failed to parse transaction block height.".to_string())?,
);
offset += 4;
let tx_len = u32::from_le_bytes(
response[offset..offset + 4]
.try_into()
.map_err(|_| "Failed to parse transaction byte length.".to_string())?,
) as usize;
offset += 4;
if response.len().saturating_sub(offset) < tx_len {
return Err("Latest transaction payload was truncated.".to_string());
}
let transaction_bytes = response[offset..offset + tx_len].to_vec();
offset += tx_len;
let transaction_type = *transaction_bytes
.first()
.ok_or_else(|| "Latest transaction payload was empty.".to_string())?;
if response.len().saturating_sub(offset) < 4 {
return Err("Latest transaction miner earnings count was truncated.".to_string());
}
let earnings_count = u32::from_le_bytes(
response[offset..offset + 4]
.try_into()
.map_err(|_| "Failed to parse miner earnings count.".to_string())?,
) as usize;
offset += 4;
let earnings_bytes = earnings_count
.checked_mul(MINER_EARNING_ENTRY_BYTES)
.ok_or_else(|| "Miner earnings response length overflowed.".to_string())?;
if response.len().saturating_sub(offset) < earnings_bytes {
return Err("Latest transaction miner earnings were truncated.".to_string());
}
let mut miner_earnings = Vec::with_capacity(earnings_count);
for _ in 0..earnings_count {
let earning_type = response[offset];
offset += 1;
let asset =
String::from_utf8_lossy(&response[offset..offset + MINER_EARNING_ASSET_BYTES])
.trim()
.to_string();
offset += MINER_EARNING_ASSET_BYTES;
let nft_series = u32::from_le_bytes(
response[offset..offset + 4]
.try_into()
.map_err(|_| "Failed to parse miner earning NFT series.".to_string())?,
);
offset += 4;
let amount = u64::from_le_bytes(
response[offset..offset + 8]
.try_into()
.map_err(|_| "Failed to parse miner earning amount.".to_string())?,
);
offset += 8;
miner_earnings.push(MinerEarningView {
earning_type,
asset,
nft_series,
amount: amount.to_string(),
});
}
transactions.push(LatestAddressTransactionView {
txid,
block_height,
transaction_type,
transaction_bytes,
miner_earnings,
});
}
if offset != response.len() {
return Err("Latest transaction response had trailing bytes.".to_string());
}
Ok(transactions)
}
#[cfg(test)]
mod latest_transaction_response_tests {
use super::*;
#[test]
fn parses_reward_miner_earnings() {
let transaction = vec![1u8; 13];
let mut response = Vec::new();
response.extend_from_slice(&1u32.to_le_bytes());
response.extend_from_slice(&[3u8; 32]);
response.extend_from_slice(&123u32.to_le_bytes());
response.extend_from_slice(&(transaction.len() as u32).to_le_bytes());
response.extend_from_slice(&transaction);
response.extend_from_slice(&2u32.to_le_bytes());
response.push(0);
response.extend_from_slice(b"cltc ");
response.extend_from_slice(&0u32.to_le_bytes());
response.extend_from_slice(&500u64.to_le_bytes());
response.push(1);
response.extend_from_slice(b"example ");
response.extend_from_slice(&7u32.to_le_bytes());
response.extend_from_slice(&900u64.to_le_bytes());
let records = parse_latest_transaction_response(response).unwrap();
assert_eq!(records[0].miner_earnings.len(), 2);
assert_eq!(records[0].miner_earnings[0].amount, "500");
assert_eq!(records[0].miner_earnings[1].asset, "example");
}
#[test]
fn parses_transaction_without_miner_earnings() {
let transaction = vec![2u8; 84];
let mut response = Vec::new();
response.extend_from_slice(&1u32.to_le_bytes());
response.extend_from_slice(&[4u8; 32]);
response.extend_from_slice(&10u32.to_le_bytes());
response.extend_from_slice(&(transaction.len() as u32).to_le_bytes());
response.extend_from_slice(&transaction);
response.extend_from_slice(&0u32.to_le_bytes());
let records = parse_latest_transaction_response(response).unwrap();
assert!(records[0].miner_earnings.is_empty());
}
}
async fn serialized_wallet_rpc(
state: &State<'_, WalletState>,
socket_address: SocketAddr,
command_input: String,
rpc_command: usize,
public_key: String,
private_key: String,
error_prefix: &str,
) -> Result<Vec<u8>, String> {
state
.rpc_scheduler
.enqueue(
WalletRpcPriority::Foreground,
WalletRpcRequest {
socket_address,
command_input,
rpc_command,
public_key,
private_key,
error_prefix: error_prefix.to_string(),
},
)
.await
}
async fn background_wallet_rpc(
state: &State<'_, WalletState>,
socket_address: SocketAddr,
command_input: String,
rpc_command: usize,
public_key: String,
private_key: String,
error_prefix: &str,
) -> Result<Vec<u8>, String> {
state
.rpc_scheduler
.enqueue(
WalletRpcPriority::Background,
WalletRpcRequest {
socket_address,
command_input,
rpc_command,
public_key,
private_key,
error_prefix: error_prefix.to_string(),
},
)
.await
}
fn normalize_wallet_filename(wallet_name: &str) -> Result<String, String> {
let trimmed = wallet_name.trim();
if trimmed.is_empty() {
return Err("Wallet name cannot be empty.".to_string());
}
if trimmed.contains('/') || trimmed.contains('\\') {
return Err("Wallet name cannot contain folders or path separators.".to_string());
}
let stem = trimmed.strip_suffix(".wallet").unwrap_or(trimmed).trim();
if stem.is_empty() {
return Err("Wallet name cannot be empty.".to_string());
}
let valid = stem.chars().all(|character| {
character.is_ascii_alphanumeric()
|| character == ' '
|| character == '-'
|| character == '_'
|| character == '.'
});
if !valid {
return Err(
"Wallet name can only use letters, numbers, spaces, dots, dashes, and underscores."
.to_string(),
);
}
Ok(format!("{stem}.wallet"))
}

22
src/wallet_app/state.rs Normal file
View File

@ -0,0 +1,22 @@
impl WalletState {
fn set_active(&self, active_wallet: ActiveWallet) -> Result<(), String> {
let mut guard = self
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
*guard = Some(active_wallet);
self.rpc_scheduler.bump_generation();
Ok(())
}
fn clear(&self) -> Result<(), String> {
let mut guard = self
.active_wallet
.lock()
.map_err(|_| "Wallet session lock was poisoned.".to_string())?;
*guard = None;
self.rpc_scheduler.bump_generation();
Ok(())
}
}

View File

@ -0,0 +1,151 @@
async fn decode_transaction_json(transaction: &[u8]) -> Result<JsonValue, String> {
let (&txtype, body) = transaction
.split_first()
.ok_or_else(|| "Transaction payload was empty.".to_string())?;
match txtype {
0 => serde_json::to_value(
GenesisTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode genesis transaction: {err}"))?,
),
1 => serde_json::to_value(
RewardsTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode rewards transaction: {err}"))?,
),
2 => serde_json::to_value(
TransferTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode transfer transaction: {err}"))?,
),
3 => serde_json::to_value(
CreateTokenTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode token creation transaction: {err}"))?,
),
4 => serde_json::to_value(
CreateNftTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode NFT creation transaction: {err}"))?,
),
5 => serde_json::to_value(
MarketingTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode marketing transaction: {err}"))?,
),
6 => serde_json::to_value(
SwapTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode swap transaction: {err}"))?,
),
7 => serde_json::to_value(
LoanContractTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode loan contract transaction: {err}"))?,
),
8 => serde_json::to_value(
ContractPaymentTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode loan payment transaction: {err}"))?,
),
9 => serde_json::to_value(
CollateralClaimTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode collateral claim transaction: {err}"))?,
),
10 => serde_json::to_value(
BurnTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode burn transaction: {err}"))?,
),
11 => serde_json::to_value(
IssueTokenTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode token issue transaction: {err}"))?,
),
12 => serde_json::to_value(
VanityAddressTransaction::from_bytes(txtype, body)
.await
.map_err(|err| format!("Failed to decode vanity transaction: {err}"))?,
),
_ => return Err(format!("Unknown transaction type: {txtype}")),
}
.map_err(|err| format!("Failed to serialize decoded transaction: {err}"))
}
fn read_network_u32(response: &[u8], offset: &mut usize) -> Result<u32, String> {
let bytes = response
.get(*offset..*offset + 4)
.ok_or_else(|| "Network info response ended while reading u32.".to_string())?
.try_into()
.map_err(|_| "Failed to read network info u32.".to_string())?;
*offset += 4;
Ok(u32::from_le_bytes(bytes))
}
fn read_network_u64(response: &[u8], offset: &mut usize) -> Result<u64, String> {
let bytes = response
.get(*offset..*offset + 8)
.ok_or_else(|| "Network info response ended while reading u64.".to_string())?
.try_into()
.map_err(|_| "Failed to read network info u64.".to_string())?;
*offset += 8;
Ok(u64::from_le_bytes(bytes))
}
fn parse_network_info_response(response: Vec<u8>) -> Result<NetworkInfoView, String> {
const NETWORK_NAME_BYTES: usize = 7;
const FIXED_BYTES_WITHOUT_PREFIX: usize = 40;
if let Some(error) = parse_error_response(&response) {
return Err(error);
}
if response.len() <= FIXED_BYTES_WITHOUT_PREFIX {
let text = String::from_utf8_lossy(&response).trim().to_string();
if !text.is_empty() {
return Err(text);
}
return Err("Network info response had an unexpected byte length.".to_string());
}
let wallet_prefix_len = response.len() - FIXED_BYTES_WITHOUT_PREFIX;
let mut offset = 0;
let version = *response
.get(offset)
.ok_or_else(|| "Network info response did not include version.".to_string())?;
offset += 1;
let network = String::from_utf8_lossy(
response
.get(offset..offset + NETWORK_NAME_BYTES)
.ok_or_else(|| "Network info response did not include network name.".to_string())?,
)
.trim()
.to_string();
offset += NETWORK_NAME_BYTES;
let time = read_network_u32(&response, &mut offset)?;
let wallet_prefix = String::from_utf8_lossy(
response
.get(offset..offset + wallet_prefix_len)
.ok_or_else(|| "Network info response did not include wallet prefix.".to_string())?,
)
.trim()
.to_string();
offset += wallet_prefix_len;
Ok(NetworkInfoView {
version,
network,
time,
wallet_prefix,
height: read_network_u32(&response, &mut offset)?,
next_block_difficulty: read_network_u64(&response, &mut offset)?,
total_block_transactions: read_network_u32(&response, &mut offset)?,
total_mempool_transactions: read_network_u32(&response, &mut offset)?,
largest_tx_fee: read_network_u64(&response, &mut offset)?,
})
}

340
src/wallet_app/types.rs Normal file
View File

@ -0,0 +1,340 @@
#[derive(Serialize)]
struct AppStatus {
app_name: &'static str,
network: &'static str,
bridge: &'static str,
}
#[derive(Serialize)]
struct WalletView {
wallet_name: String,
short_address: String,
short_address_hex: String,
vanity_address: Option<String>,
wallet_path: String,
private_key_image: Option<String>,
}
#[derive(Serialize)]
struct WalletListItem {
wallet_name: String,
wallet_path: String,
}
#[derive(Serialize)]
struct SelectedFile {
file_name: String,
file_path: String,
}
#[derive(Serialize)]
struct BalanceView {
asset: String,
nft_series: u32,
balance: String,
}
#[derive(Serialize)]
struct RegistrationStatusView {
registered: bool,
}
#[derive(Serialize)]
struct VanityAvailabilityView {
vanity_address: String,
available: bool,
owner: Option<String>,
}
#[derive(Serialize)]
struct TransactionBytesView {
txid: String,
block_height: u32,
transaction_type: u8,
transaction: JsonValue,
transaction_bytes: Vec<u8>,
}
#[derive(Serialize)]
struct LatestAddressTransactionView {
txid: String,
block_height: u32,
transaction_type: u8,
transaction_bytes: Vec<u8>,
miner_earnings: Vec<MinerEarningView>,
}
#[derive(Serialize)]
struct MinerEarningView {
earning_type: u8,
asset: String,
nft_series: u32,
amount: String,
}
#[derive(Serialize)]
struct MarketingCampaignView {
total_records: String,
total_impressions: String,
total_clicks: String,
total_impression_value: String,
total_click_value: String,
rows: Vec<MarketingCampaignRowView>,
}
#[derive(Serialize)]
struct MarketingCampaignRowView {
status: String,
txid: String,
block_height: Option<u32>,
time: u32,
campaign: String,
ad_type: String,
keyword: String,
displayed: String,
impression: u8,
click: u8,
impression_value: u16,
click_value: u16,
}
#[derive(Serialize)]
struct TokenListItemView {
ticker: String,
origin_txid: String,
}
#[derive(Serialize)]
struct TokenCatalogItemView {
ticker: String,
origin_txid: String,
creator: String,
created_timestamp: u32,
initial_supply: String,
current_supply: String,
total_burned: String,
holder_count: u32,
hard_limit: bool,
}
#[derive(Serialize)]
struct NftListItemView {
nft_name: String,
series: u32,
origin_txid: String,
}
#[derive(Serialize)]
struct NftHistoryEntryView {
txid: String,
block: u32,
txtype: u8,
action: String,
from: String,
to: String,
received_asset: String,
received_series: u32,
received_value: String,
}
#[derive(Serialize)]
struct NftDetailsView {
nft_name: String,
series: u32,
asset_name: String,
genesis_txid: String,
creator: String,
item_ipfs: String,
metadata_uri: String,
current_holder: String,
history_count: u32,
history: Vec<NftHistoryEntryView>,
}
#[derive(Serialize)]
struct NftCacheRecordView {
detail: JsonValue,
metadata: JsonValue,
image_data_url: Option<String>,
}
#[derive(Serialize)]
struct IssueTokenDetailsView {
ticker: String,
creator: String,
hard_limit: bool,
eligible: bool,
reason: String,
}
#[derive(Serialize)]
struct BroadcastNodeView {
label: String,
address: String,
}
#[derive(Serialize, Deserialize)]
struct GuiSettingsView {
wallet_path: String,
unbroadcast_transactions_path: String,
address_book_path: String,
default_node: String,
saved_nodes: Vec<String>,
ipfs_gateway: String,
auto_load_trusted_nft_images: bool,
auto_lock_timeout: String,
clipboard_clear_timeout: String,
require_key_before_signing: bool,
settings_path: String,
}
#[derive(Serialize)]
struct WalletPrintBackupView {
wallet_name: String,
short_address: String,
vanity_address: Option<String>,
public_key: String,
private_key: String,
encryption_key: String,
}
#[derive(Serialize)]
struct NetworkInfoView {
version: u8,
network: String,
time: u32,
wallet_prefix: String,
height: u32,
next_block_difficulty: u64,
total_block_transactions: u32,
total_mempool_transactions: u32,
largest_tx_fee: u64,
}
#[derive(Serialize)]
struct UnbroadcastTransactionListItem {
file_name: String,
transaction_type: u8,
type_name: String,
txid: Option<String>,
summary: String,
status: String,
status_message: String,
can_sign: bool,
can_broadcast: bool,
}
#[derive(Serialize)]
struct UnbroadcastTransactionReview {
file_name: String,
transaction_type: u8,
type_name: String,
txid: Option<String>,
summary: String,
status: String,
status_message: String,
can_sign: bool,
can_broadcast: bool,
transaction: JsonValue,
}
#[derive(Serialize)]
struct SavedSwapOfferView {
review: UnbroadcastTransactionReview,
file_path: String,
}
#[derive(Serialize)]
struct SavedLoanOfferView {
review: UnbroadcastTransactionReview,
file_path: String,
}
#[derive(Serialize, Clone)]
struct LoanContractView {
contract: String,
status: String,
start_timestamp: u32,
lender: String,
borrower: String,
loan_asset: String,
loan_amount: String,
collateral: String,
collateral_amount: String,
payment_period: String,
payment_number: u8,
payment_amount: String,
grace_period: u8,
max_late_value: String,
confirmed_paid: String,
pending_paid: String,
remaining_balance: String,
remaining_after_pending: String,
payments: Vec<LoanPaymentHistoryView>,
}
#[derive(Serialize, Clone)]
struct LoanPaymentHistoryView {
txid: String,
amount: String,
payer: String,
date: String,
}
#[derive(Serialize, Clone)]
struct CollateralClaimContractView {
contract: String,
role: String,
reason: String,
lender: String,
borrower: String,
loan_asset: String,
loan_amount: String,
collateral: String,
collateral_amount: String,
confirmed_paid: String,
pending_paid: String,
remaining_balance: String,
remaining_after_pending: String,
fee: String,
}
#[derive(Serialize)]
struct BroadcastUnbroadcastResult {
response: String,
txid: Option<String>,
}
#[derive(Clone, Copy)]
enum GuiNetwork {
Mainnet,
Testnet,
}
impl GuiNetwork {
fn parse(network: &str) -> Result<Self, String> {
match network.trim().to_ascii_lowercase().as_str() {
"mainnet" => Ok(Self::Mainnet),
"testnet" => Ok(Self::Testnet),
_ => Err("Network must be testnet or mainnet.".to_string()),
}
}
fn folder_name(self) -> &'static str {
match self {
Self::Mainnet => "mainnet",
Self::Testnet => "testnet",
}
}
}
struct ActiveWallet {
wallet_name: String,
wallet_path: String,
wallet: Wallet,
}
struct WalletState {
active_wallet: Mutex<Option<ActiveWallet>>,
rpc_scheduler: WalletRpcScheduler,
}

View File

@ -0,0 +1,324 @@
async fn saved_private_key_image(wallet_path: &str) -> Option<String> {
let bytes = read(wallet_path).await.ok()?;
let saved_wallet: SavedWallet = blockchain::from_slice(&bytes).ok()?;
if saved_wallet.private_key.trim().is_empty() {
None
} else {
Some(saved_wallet.private_key)
}
}
async fn wallet_view(wallet_name: String, wallet_path: String, wallet: &Wallet) -> WalletView {
let private_key_image = saved_private_key_image(&wallet_path).await;
let short_address_hex = Wallet::short_address_to_bytes(&wallet.saved.short_address)
.map(encode)
.unwrap_or_default();
WalletView {
wallet_name,
short_address: wallet.saved.short_address.clone(),
short_address_hex,
vanity_address: wallet.saved.vanity_address.clone(),
wallet_path,
private_key_image,
}
}
fn wallet_name_from_path(wallet_path: &Path) -> Result<String, String> {
wallet_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
.ok_or_else(|| "Wallet path does not contain a valid filename.".to_string())
}
fn gui_wallet_root() -> Result<PathBuf, String> {
let home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| "Unable to resolve user home directory.".to_string())?;
Ok(home.join("gui-wallets"))
}
fn app_wallet_dir(network: GuiNetwork) -> Result<PathBuf, String> {
Ok(gui_wallet_root()?.join(network.folder_name()))
}
async fn create_wallet_file(
wallet_path: &Path,
wallet_key: String,
) -> Result<Wallet, String> {
let seed_wallet = Wallet {
saved: SavedWallet {
short_address: String::new(),
vanity_address: None,
public_key: String::new(),
private_key: String::new(),
},
encryption_key: wallet_key.clone(),
};
let wallet = seed_wallet.create_wallet();
wallet.saved.save_the_wallet(wallet_path).await;
Wallet::private_key_from_wallet(wallet_path, wallet_key).await
}
async fn import_private_key_file(
wallet_path: &Path,
wallet_key: String,
private_key: String,
) -> Result<Wallet, String> {
let private_key = private_key.trim().to_string();
if private_key.is_empty() {
return Err("Private key cannot be empty.".to_string());
}
let public_key_bytes = Wallet::regenerate_public_key(&private_key)?;
let public_key = encode(public_key_bytes.clone());
let short_address_bytes = Wallet::public_key_bytes_to_short_address_bytes(&public_key_bytes)
.ok_or_else(|| "Failed to derive short address bytes.".to_string())?;
let short_address = Wallet::bytes_to_short_address(&short_address_bytes)
.ok_or_else(|| "Failed to encode short address.".to_string())?;
let encrypted_private_key = encrypts(&private_key, Some(&wallet_key), None)
.ok_or_else(|| "Failed to encrypt private key with the provided wallet key.".to_string())?;
let image_data = create_img(
&encrypted_private_key,
"h",
"empty",
None,
None,
None,
None,
None,
None,
)
.ok_or_else(|| "Failed to create encrypted private key image.".to_string())?;
let saved_wallet = SavedWallet {
short_address,
vanity_address: None,
public_key,
private_key: image_data,
};
let output = to_string_pretty(&saved_wallet)
.map_err(|err| format!("Failed to serialize imported wallet: {err}"))?;
blockchain::tokio::fs::write(wallet_path, output)
.await
.map_err(|err| format!("Failed to write imported wallet file: {err}"))?;
Wallet::private_key_from_wallet(wallet_path, wallet_key).await
}
async fn private_key_from_png_image(image_path: &Path, wallet_key: &str) -> Result<String, String> {
let image_bytes = blockchain::tokio::fs::read(image_path)
.await
.map_err(|err| format!("Failed to read private key image: {err}"))?;
let image_base64 = STANDARD.encode(image_bytes);
let encrypted_text = decode_image_and_extract_text(&image_base64)
.ok_or_else(|| "Failed to decode private key image.".to_string())?;
decrypts(&encrypted_text, Some(wallet_key))
.ok_or_else(|| "Private key image decryption failed.".to_string())
}
async fn lookup_public_ip_from_services() -> Option<String> {
let services = [
"https://api.ipify.org",
"https://icanhazip.com",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
"https://ident.me",
];
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
.ok()?;
for service in services {
let response = match client.get(service).send().await {
Ok(response) => response,
Err(_) => continue,
};
let text = match response.text().await {
Ok(text) => text,
Err(_) => continue,
};
let ip = text.trim();
if ip.parse::<std::net::IpAddr>().is_ok() {
return Some(ip.to_string());
}
}
None
}
async fn best_effort_local_ip() -> Result<String, String> {
let public_ip = lookup_public_ip_from_services()
.await
.ok_or_else(|| "Unable to determine public IP from any service.".to_string());
return public_ip;
}
fn gui_settings_path() -> Result<PathBuf, String> {
if let Ok(settings_path) = env::var("SETTINGS_PATH") {
let path = PathBuf::from(settings_path);
if path.exists() {
return Ok(path);
}
}
if let Ok(exe_path) = env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
return Ok(exe_dir.join("settings.ini"));
}
}
Ok(env::current_dir()
.map_err(|err| format!("Unable to resolve current directory: {err}"))?
.join("settings.ini"))
}
async fn ensure_gui_settings_file() -> Result<(), String> {
let settings_path = gui_settings_path()?;
if settings_path.exists() {
env::set_var("SETTINGS_PATH", &settings_path);
return Ok(());
}
let settings_dir = settings_path
.parent()
.ok_or_else(|| "Unable to resolve settings directory.".to_string())?;
create_dir_all(settings_dir)
.await
.map_err(|err| format!("Unable to create settings directory: {err}"))?;
let runtime_root = gui_wallet_root()?.join("gui-runtime");
create_dir_all(&runtime_root)
.await
.map_err(|err| format!("Unable to create GUI runtime directory: {err}"))?;
let public_ip = best_effort_local_ip().await?;
let settings = format!(
r#"; Auto-generated by Contractless Wallet GUI.
[Paths]
BLOCK_PATH = "{root}/blocks"
TORRENT_PATH = "{root}/torrents"
DB_PATH = "{root}/state"
BALANCE_SHEET = "{root}/balance_sheet"
LOG_PATH = "{root}/logs"
WALLET_PATH = "{root}/wallets"
UNBROADCAST_TRANSACTIONS = "{root}/unbroadcast"
ADDRESS_BOOK = "{root}/address_book"
WALLET_NAME = "gui.wallet"
[Settings]
LOG_LEVEL = "info"
PUBLIC_IP = "{public_ip}"
LISTEN_IP = "0.0.0.0"
RPC_PORT = "50050"
TESTNET_RPC_PORT = "50053"
INCOMING_CONNECTIONS = "1"
OUTGOING_CONNECTIONS = "1"
VALIDATOR = "false"
THREADS = "1"
[Piggyback]
PIGGYBACK_1 = "contractless.dev:50050"
[Postgres-Testnet]
host = 127.0.0.1
port = 5432
user = contractless_gui
password = contractless_gui
dbname = contractless_gui
[Postgres]
host = 127.0.0.1
port = 5432
user = contractless_gui
password = contractless_gui
dbname = contractless_gui
"#,
root = runtime_root.to_string_lossy().replace('\\', "/"),
public_ip = public_ip
);
blockchain::tokio::fs::write(&settings_path, settings)
.await
.map_err(|err| format!("Unable to write GUI settings.ini: {err}"))?;
env::set_var("SETTINGS_PATH", &settings_path);
Ok(())
}
async fn read_piggyback_nodes() -> Result<Vec<BroadcastNodeView>, String> {
ensure_gui_settings_file().await?;
let settings_path = gui_settings_path()?;
let settings = read_to_string(&settings_path)
.await
.map_err(|err| format!("Unable to read GUI settings.ini: {err}"))?;
let mut in_piggyback = false;
let mut nodes = Vec::new();
for line in settings.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(';') || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_piggyback = trimmed.eq_ignore_ascii_case("[Piggyback]");
continue;
}
if !in_piggyback {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
if !key.to_ascii_uppercase().starts_with("PIGGYBACK_") {
continue;
}
let address = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
if address.is_empty() {
continue;
}
nodes.push(BroadcastNodeView {
label: key.to_string(),
address,
});
}
nodes.sort_by(|left, right| {
piggyback_sort_index(&left.label)
.cmp(&piggyback_sort_index(&right.label))
.then_with(|| left.label.cmp(&right.label))
});
nodes.dedup_by(|left, right| left.address == right.address);
Ok(nodes)
}
fn piggyback_sort_index(label: &str) -> u32 {
label
.rsplit_once('_')
.and_then(|(_, index)| index.trim().parse::<u32>().ok())
.unwrap_or(u32::MAX)
}

27
tauri.conf.json Normal file
View File

@ -0,0 +1,27 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Contractless Wallet",
"version": "0.1.0",
"identifier": "dev.contractless.wallet",
"build": {
"frontendDist": "frontend"
},
"bundle": {
"active": false
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Contractless Wallet",
"width": 1180,
"height": 760,
"minWidth": 360,
"minHeight": 620
}
],
"security": {
"csp": null
}
}
}