Initial Contractless GUI wallet commit
This commit is contained in:
commit
ad64c14a04
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default wallet window permissions",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
// Contractless Wallet frontend is split by feature in frontend/js/*.js.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"));
|
||||||
|
|
@ -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"));
|
||||||
|
|
@ -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"));
|
||||||
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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"));
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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"));
|
||||||
|
|
@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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"));
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -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
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
|
@ -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(¤t, "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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue