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