From 18bedf64b087b484fa59f4f2fb9a2f1b81eb7878 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Thu, 16 Jul 2026 08:46:52 -0600 Subject: [PATCH] added features and fixed cache reset buggs --- Cargo.lock | 82 +- Cargo.toml | 2 +- README.md | 6 +- frontend/index.html | 82 +- frontend/js/address_data_page.js | 593 ++++++++++++ frontend/js/app_state.js | 30 +- frontend/js/collateral_claim_form.js | 11 +- frontend/js/dashboard_network.js | 18 +- frontend/js/data_storage_form.js | 444 +++++++++ frontend/js/history_page.js | 30 +- frontend/js/loan_payment_form.js | 11 +- frontend/js/loans_page.js | 296 +++++- frontend/js/navigation_actions.js | 20 + frontend/js/settings_page.js | 32 +- frontend/js/transaction_history.js | 215 ++++- frontend/js/wallet_session.js | 9 + frontend/styles.css | 202 ++++- src/main.rs | 79 +- src/wallet_app/commands_network_cache.rs | 49 +- src/wallet_app/commands_transactions.rs | 1056 +++++++++++++++++++++- src/wallet_app/commands_wallet.rs | 8 +- src/wallet_app/shared_rpc.rs | 17 + src/wallet_app/transaction_decode.rs | 108 ++- src/wallet_app/types.rs | 5 + src/wallet_app/wallet_files.rs | 10 +- 25 files changed, 3191 insertions(+), 224 deletions(-) create mode 100644 frontend/js/address_data_page.js create mode 100644 frontend/js/data_storage_form.js diff --git a/Cargo.lock b/Cargo.lock index 78c5fb3..9deaef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,44 +202,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blockchain" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64 0.22.1", - "chrono", - "cid", - "colored", - "encrypted_images", - "falcon-rs", - "flexi_logger", - "fn-dsa", - "hex", - "ipnetwork", - "lazy_static", - "log", - "nix 0.27.1", - "once_cell", - "rand 0.8.6", - "rayon", - "ripemd", - "rpassword", - "rust-ini", - "rustyline", - "rustyline-derive", - "serde", - "serde_json", - "shellexpand", - "skein", - "sled", - "tokio", - "tokio-postgres", - "windows-service", - "windows-sys 0.61.2", - "winreg 0.52.0", -] - [[package]] name = "brotli" version = "8.0.3" @@ -558,13 +520,51 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "contractless" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "cid", + "colored", + "encrypted_images", + "falcon-rs", + "flexi_logger", + "fn-dsa", + "hex", + "ipnetwork", + "lazy_static", + "log", + "nix 0.27.1", + "once_cell", + "rand 0.8.6", + "rayon", + "ripemd", + "rpassword", + "rust-ini", + "rustyline", + "rustyline-derive", + "serde", + "serde_json", + "shellexpand", + "skein", + "sled", + "tokio", + "tokio-postgres", + "windows-service", + "windows-sys 0.61.2", + "winreg 0.52.0", +] + [[package]] name = "contractless-wallet" version = "0.1.0" dependencies = [ "base64 0.22.1", - "blockchain", "clipboard-win", + "contractless", "native-dialog", "reqwest 0.12.28", "serde", @@ -1196,9 +1196,9 @@ dependencies = [ [[package]] name = "encrypted_images" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761fe0fc7059c1e9cb4217f49263b146d269322354aeefb8fc519a60b2e8cef1" +checksum = "dda784ca648e472fc1bb0e408cb60b22e6b1647511d3c8f7144dadcf7133f605" dependencies = [ "base64 0.21.7", "encoding", diff --git a/Cargo.toml b/Cargo.toml index 6f9f5eb..ffaced4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ build = "build.rs" tauri-build = { version = "2", features = [] } [dependencies] -blockchain = { path = ".." } +contractless = { path = ".." } base64 = "0.22" native-dialog = "0.6" reqwest = { version = "0.12", features = ["rustls-tls"] } diff --git a/README.md b/README.md index 5f6c906..51dc9bc 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Contractless/ The wallet depends on the blockchain crate with: ```toml -blockchain = { path = ".." } +contractless = { 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. +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 @@ -35,7 +35,7 @@ Because of that, `gui wallet/` must be in the same folder that contains the Cont ## Build Check -From `gui wallet/`: +From `gui_wallet/`: ```bash cargo check diff --git a/frontend/index.html b/frontend/index.html index 3c05da3..ba86bf9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -177,6 +177,10 @@ % Marketing + + {} + Address Data + @ Address Book @@ -782,6 +786,58 @@ + + + Storage Action + + Create storage key + Write storage value + + + + Storage Key + + Select storage key + Enter new storage key + + + + New Storage Key Hash + + + + Data Key + + + + Value + + + + Value Type + + + + Transaction Fee + + + + Previous String Tx + + + + Writer-- + Action-- + Value Type-- + Fee500.00000000 + + + + Save Transaction + Broadcast Transaction + + + Vanity Name @@ -837,6 +893,7 @@ Collateral Claim Burn Marketing + Data Storage Vanity Address @@ -1106,7 +1163,7 @@ - + Marketing Campaigns @@ -1215,6 +1272,27 @@ + + + + + Address Data + On-chain application data stored by the active address. + + + + Select storage key + + Refresh + + + + + + Load a wallet to view stored address data. + + + @@ -1540,6 +1618,7 @@ + @@ -1557,6 +1636,7 @@ + diff --git a/frontend/js/address_data_page.js b/frontend/js/address_data_page.js new file mode 100644 index 0000000..0771954 --- /dev/null +++ b/frontend/js/address_data_page.js @@ -0,0 +1,593 @@ +const STORAGE_TRANSACTION_TYPES = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113]; +const STORAGE_ZERO_HASH = "0".repeat(64); +const ADDRESS_DATA_DELETE_FEE_ATOMIC = 100000000n; + +const addressDataGroups = document.querySelector("[data-address-data-groups]"); +const addressDataState = document.querySelector("[data-address-data-state]"); +const addressDataSelect = document.querySelector("[data-address-data-select]"); +const addressDataRefresh = document.querySelector("[data-address-data-refresh]"); +const addressDataSelection = document.querySelector("[data-address-data-selection]"); + +let addressDataRecords = []; +let addressDataLoading = false; +let addressDataWalletToken = ""; +let addressDataError = ""; +let addressDataLabels = { labels: {} }; +let addressDataLabelsLoaded = false; +let addressDataLabelSavePromise = Promise.resolve(); + +function escapeAddressDataText(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function trimStoredText(value) { + return String(value ?? "").replace(/[\0 ]+$/g, ""); +} + +function normalizeStorageAddress(value) { + return String(value || "").trim().toLowerCase().replace(/\.cltc$/, ""); +} + +function isActiveStorageAddress(value) { + const active = normalizeStorageAddress(activeWalletAddressHex || activeWalletAddressShort); + return Boolean(active && normalizeStorageAddress(value) === active); +} + +function storageUnsignedRecord(transaction) { + if (!transaction || typeof transaction !== "object" || Array.isArray(transaction)) { + return null; + } + return transaction.unsigned_storagekey + || transaction.unsigned_storagebool + || transaction.unsigned_storageu8 + || transaction.unsigned_storageu16 + || transaction.unsigned_storageu32 + || transaction.unsigned_storageu64 + || transaction.unsigned_storageu128 + || transaction.unsigned_storage + || transaction.unsigned_storagei8 + || transaction.unsigned_storagei16 + || transaction.unsigned_storagei32 + || transaction.unsigned_storagei64 + || transaction.unsigned_storagei128 + || transaction.unsigned_deletekey + || null; +} + +function storageTypeLabel(type) { + return { + 101: "Bool", + 102: "U8", + 103: "U16", + 104: "U32", + 105: "U64", + 106: "U128", + 107: "String", + 108: "I8", + 109: "I16", + 110: "I32", + 111: "I64", + 112: "I128" + }[Number(type)] || "Value"; +} + +function storageDisplayValue(type, value) { + if (Number(type) === 101) { + return value === true || value === 1 || value === "1" ? "true" : "false"; + } + return trimStoredText(value); +} + +function storageRecordTime(record, unsigned) { + return Number(unsigned?.time || unsigned?.timestamp || 0); +} + +function storageRecordOrder(left, right) { + const blockDifference = Number(left.record?.block || 0) - Number(right.record?.block || 0); + if (blockDifference) { + return blockDifference; + } + const timeDifference = storageRecordTime(left.record, left.unsigned) + - storageRecordTime(right.record, right.unsigned); + if (timeDifference) { + return timeDifference; + } + return left.txid.localeCompare(right.txid); +} + +function storageTransactionIds(index) { + const seen = new Set(); + const txids = []; + for (const type of STORAGE_TRANSACTION_TYPES) { + const values = index?.type_to_txids?.[String(type)]; + for (const txid of Array.isArray(values) ? values : []) { + if (txid && !seen.has(txid)) { + seen.add(txid); + txids.push(txid); + } + } + } + return txids; +} + +function storageRecordNeedsDecode(record) { + const type = Number(record?.txtype || record?.transaction_type || 0); + return STORAGE_TRANSACTION_TYPES.includes(type) && Boolean(record?.transaction?.unknown); +} + +async function repairLegacyStorageRecords(cache, index, walletToken) { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !broadcastNode) { + return cache; + } + + const stale = Object.entries(cache.transactions || {}) + .filter(([, record]) => storageRecordNeedsDecode(record)); + if (!stale.length) { + return cache; + } + + setAddressDataState("Updating cached storage records...", "loading"); + for (const [txid] of stale) { + if (!isActiveWalletCacheToken(walletToken)) { + return cache; + } + try { + const tx = await invoke("transaction_by_txid", { broadcastNode, txid }); + const record = repairCachedTransactionRecord(txid, { + txid, + block: tx.block_height || null, + transaction: tx.transaction, + transaction_type: tx.transaction_type, + linked_transactions: {}, + miner_earnings: [] + }); + cache.transactions[txid] = record; + const fileName = index.txid_to_file?.[txid]; + if (fileName) { + await writeTransactionRecordFile(fileName, record, walletToken); + } + } catch (_) { + // Keep the remaining locally decoded records visible if one old lookup fails. + } + } + return cache; +} + +function buildAddressDataGroups(cache) { + const records = Object.entries(cache.transactions || {}) + .map(([txid, record]) => ({ + txid, + record, + unsigned: storageUnsignedRecord(record?.transaction), + type: Number(record?.txtype || decodedTransactionType(record?.transaction) || 0) + })) + .filter((item) => item.unsigned && isActiveStorageAddress(item.unsigned.address)) + .sort(storageRecordOrder); + + const groups = new Map(); + const stringsByTxid = new Map(records.filter((item) => item.type === 107).map((item) => [item.txid, item])); + const stringDepthCache = new Map(); + + const stringDepth = (item, visited = new Set()) => { + if (stringDepthCache.has(item.txid)) { + return stringDepthCache.get(item.txid); + } + const previous = String(item.unsigned.previous || "").toLowerCase(); + if (!previous || previous === STORAGE_ZERO_HASH) { + stringDepthCache.set(item.txid, 0); + return 0; + } + if (visited.has(item.txid)) { + return null; + } + const parent = stringsByTxid.get(previous); + if (!parent) { + return null; + } + visited.add(item.txid); + const parentDepth = stringDepth(parent, visited); + const depth = parentDepth === null ? null : parentDepth + 1; + stringDepthCache.set(item.txid, depth); + return depth; + }; + + const ensureGroup = (storageId) => { + const normalized = String(storageId || "").toLowerCase(); + if (!groups.has(normalized)) { + groups.set(normalized, { + storageId: normalized, + created: 0, + updated: 0, + scalarFields: new Map(), + stringFields: new Map() + }); + } + return groups.get(normalized); + }; + + for (const item of records) { + const timestamp = storageRecordTime(item.record, item.unsigned); + if (item.type === 100) { + const group = ensureGroup(item.txid); + group.created = timestamp; + group.updated = Math.max(group.updated, timestamp); + continue; + } + + const storageId = String(item.unsigned.storagekey || "").toLowerCase(); + if (!storageId) { + continue; + } + const group = ensureGroup(storageId); + const key = trimStoredText(item.unsigned.key); + group.updated = Math.max(group.updated, timestamp); + + if (item.type === 113) { + const chunkMatch = key.match(/^(.*)_(\d{2})$/); + if (chunkMatch && Number(chunkMatch[2]) < 20) { + group.stringFields.get(chunkMatch[1])?.delete(Number(chunkMatch[2])); + } else { + group.scalarFields.delete(key); + group.stringFields.delete(key); + } + continue; + } + + if (item.type === 107) { + const depth = stringDepth(item); + if (depth === null || depth < 0 || depth > 19) { + continue; + } + if (!group.stringFields.has(key)) { + group.stringFields.set(key, new Map()); + } + group.stringFields.get(key).set(depth, { + value: trimStoredText(item.unsigned.value), + timestamp, + txid: item.txid + }); + continue; + } + + group.scalarFields.set(key, { + key, + value: storageDisplayValue(item.type, item.unsigned.value), + type: storageTypeLabel(item.type), + timestamp, + txid: item.txid + }); + } + + return Array.from(groups.values()).map((group) => { + const fields = Array.from(group.scalarFields.values()); + for (const [key, chunks] of group.stringFields.entries()) { + const orderedChunks = Array.from(chunks.entries()).sort(([left], [right]) => left - right); + if (!orderedChunks.length) { + continue; + } + fields.push({ + key, + value: orderedChunks.map(([, chunk]) => chunk.value).join(""), + type: "String", + timestamp: Math.max(...orderedChunks.map(([, chunk]) => chunk.timestamp)), + txid: orderedChunks.at(-1)?.[1]?.txid || "" + }); + } + fields.sort((left, right) => left.key.localeCompare(right.key)); + return { ...group, fields }; + }).sort((left, right) => (right.updated || right.created) - (left.updated || left.created)); +} + +function setAddressDataState(message, mode = "") { + if (!addressDataState) { + return; + } + addressDataState.textContent = message || ""; + addressDataState.classList.toggle("loading", mode === "loading"); + addressDataState.classList.toggle("error", mode === "error"); +} + +function normalizeAddressDataLabels(source) { + const labels = source?.labels && typeof source.labels === "object" && !Array.isArray(source.labels) + ? source.labels + : {}; + const normalized = {}; + Object.entries(labels).forEach(([storageId, label]) => { + const id = String(storageId || "").trim().toLowerCase(); + const text = String(label || "").trim().slice(0, 80); + if (/^[a-f0-9]{64}$/.test(id) && text) { + normalized[id] = text; + } + }); + return { labels: normalized }; +} + +async function loadAddressDataLabels() { + if (!walletLoaded || addressDataLabelsLoaded) { + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + try { + const raw = await invoke("read_storage_labels"); + addressDataLabels = normalizeAddressDataLabels(JSON.parse(raw || "{}")); + } catch (_) { + addressDataLabels = { labels: {} }; + } finally { + addressDataLabelsLoaded = true; + } +} + +async function saveAddressDataLabel(storageId, label) { + const id = String(storageId || "").trim().toLowerCase(); + const text = String(label || "").trim().slice(0, 80); + const walletToken = activeWalletCacheToken(); + if (!/^[a-f0-9]{64}$/.test(id)) { + return; + } + if (text) { + addressDataLabels.labels[id] = text; + } else { + delete addressDataLabels.labels[id]; + } + syncAddressDataOptions(); + + const status = addressDataSelection?.querySelector("[data-address-data-label-status]"); + if (status) { + status.textContent = "Saving..."; + } + addressDataLabelSavePromise = addressDataLabelSavePromise + .catch(() => {}) + .then(() => { + if (!isActiveWalletCacheToken(walletToken)) { + return; + } + return window.__TAURI__?.core?.invoke?.("write_storage_labels", { + storageLabelsJson: JSON.stringify(normalizeAddressDataLabels(addressDataLabels)) + }); + }); + try { + await addressDataLabelSavePromise; + if (status?.isConnected) { + status.textContent = "Saved"; + } + } catch (_) { + if (status?.isConnected) { + status.textContent = "Unable to save label"; + status.classList.add("error"); + } + } +} + +function syncAddressDataOptions() { + if (!addressDataSelect) { + return; + } + + const currentSelection = addressDataSelect.value; + if (!addressDataRecords.length) { + addressDataSelect.innerHTML = 'Select storage key'; + addressDataSelect.disabled = true; + return; + } + + addressDataSelect.innerHTML = 'Select storage key' + addressDataRecords.map((group) => { + const optionLabel = addressDataLabels.labels[group.storageId] || group.storageId; + return `${escapeAddressDataText(optionLabel)}`; + }).join(""); + addressDataSelect.disabled = false; + addressDataSelect.value = addressDataRecords.some((group) => group.storageId === currentSelection) + ? currentSelection + : ""; +} + +async function knownAddressDataStorageKeys() { + await loadAddressDataLabels(); + await loadAddressDataPage(); + return addressDataRecords.map((group) => ({ + storageId: group.storageId, + label: addressDataLabels.labels[group.storageId] || "" + })); +} + +function renderAddressDataPage() { + if (!addressDataGroups) { + return; + } + syncAddressDataOptions(); + const selectedStorageId = addressDataSelect?.value || ""; + const selectedGroup = addressDataRecords.find((group) => group.storageId === selectedStorageId); + + if (addressDataSelection) { + addressDataSelection.classList.toggle("hidden", !selectedGroup); + addressDataSelection.innerHTML = selectedGroup ? ` + + Application Label + + + + + Storage Key + ${escapeAddressDataText(selectedGroup.storageId)} + + ` : ""; + } + + addressDataGroups.innerHTML = selectedGroup ? ` + + + + Key + Value + Type + Action + + ${selectedGroup.fields.length ? selectedGroup.fields.map((field) => ` + + + ${escapeAddressDataText(field.key || "")} + + ${escapeAddressDataText(field.value)} + ${escapeAddressDataText(field.type)} + + Delete + + + `).join("") : 'No values have been stored under this storage ID.'} + + + ` : ""; + + if (!walletLoaded) { + setAddressDataState("Load a wallet to view stored address data."); + } else if (addressDataError) { + setAddressDataState(addressDataError, "error"); + } else if (addressDataLoading) { + setAddressDataState("Loading stored address data...", "loading"); + } else if (!addressDataRecords.length) { + setAddressDataState("No stored address data found in the local transaction cache."); + } else if (!selectedGroup) { + setAddressDataState("Select a storage ID to view its keys."); + } else { + const keyCount = selectedGroup.fields.length; + setAddressDataState(`${keyCount} key${keyCount === 1 ? "" : "s"} shown`); + } +} + +async function loadAddressDataPage({ force = false } = {}) { + const walletToken = activeWalletCacheToken(); + if (!walletLoaded || !walletToken || addressDataLoading) { + renderAddressDataPage(); + return; + } + await loadAddressDataLabels(); + if (!force && addressDataWalletToken === walletToken && addressDataRecords.length) { + renderAddressDataPage(); + return; + } + + addressDataLoading = true; + addressDataError = ""; + renderAddressDataPage(); + try { + const index = await readTransactionIndexObject(walletToken); + const txids = storageTransactionIds(index); + let cache = await readTransactionCacheRecords(index, txids, walletToken); + cache = await repairLegacyStorageRecords(cache, index, walletToken); + if (!isActiveWalletCacheToken(walletToken)) { + return; + } + addressDataRecords = buildAddressDataGroups(cache); + addressDataWalletToken = walletToken; + } catch (error) { + addressDataRecords = []; + addressDataError = String(error); + } finally { + addressDataLoading = false; + renderAddressDataPage(); + } +} + +function prepareAddressDataPage() { + renderAddressDataPage(); + loadAddressDataPage(); +} + +function resetAddressDataSelection() { + if (addressDataSelect) { + addressDataSelect.value = ""; + } + renderAddressDataPage(); +} + +function resetAddressDataPage() { + addressDataRecords = []; + addressDataLoading = false; + addressDataWalletToken = ""; + addressDataError = ""; + addressDataLabels = { labels: {} }; + addressDataLabelsLoaded = false; + addressDataLabelSavePromise = Promise.resolve(); + if (addressDataSelect) { + addressDataSelect.innerHTML = 'Select storage key'; + addressDataSelect.disabled = true; + } + if (addressDataSelection) { + addressDataSelection.innerHTML = ""; + addressDataSelection.classList.add("hidden"); + } + if (addressDataGroups) { + addressDataGroups.innerHTML = ""; + } + renderAddressDataPage(); +} + +addressDataSelect?.addEventListener("change", renderAddressDataPage); +addressDataRefresh?.addEventListener("click", () => loadAddressDataPage({ force: true })); +addressDataGroups?.addEventListener("click", async (event) => { + const button = event.target.closest("[data-address-data-delete]"); + if (!button || button.disabled) { + return; + } + const storageKey = addressDataSelect?.value || ""; + const fieldKey = button.dataset.addressDataDelete || ""; + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = activeBroadcastNode(); + if (!invoke || !storageKey || !fieldKey || !broadcastNode) { + setAddressDataState("Unable to prepare the storage delete transaction.", "error"); + return; + } + + const fee = formatAtomicBalance(ADDRESS_DATA_DELETE_FEE_ATOMIC); + const walletKey = guiRuntimeSettings.require_key_before_signing === false + ? null + : await requestWalletBackupKey( + `Deleting this key requires a transaction fee of ${fee} ${activeBaseCoin()}. Enter the wallet encryption key to sign and broadcast the transaction.`, + "Delete Stored Data" + ); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + return; + } + + button.disabled = true; + setAddressDataState("Signing and broadcasting storage deletion...", "loading"); + try { + await invoke("broadcast_delete_storage_transaction", { + walletKey, + broadcastNode, + storageKey, + fieldKey, + txfee: ADDRESS_DATA_DELETE_FEE_ATOMIC.toString() + }); + const group = addressDataRecords.find((entry) => entry.storageId === storageKey); + if (group) { + group.fields = group.fields.filter((field) => field.key !== fieldKey); + } + renderAddressDataPage(); + setAddressDataState(`Delete transaction broadcast. ${group?.fields.length || 0} key${group?.fields.length === 1 ? "" : "s"} shown`); + } catch (error) { + button.disabled = false; + setAddressDataState(String(error), "error"); + } +}); +addressDataSelection?.addEventListener("change", (event) => { + const input = event.target.closest("[data-address-data-label]"); + if (input) { + saveAddressDataLabel(input.dataset.addressDataLabel, input.value); + } +}); +addressDataSelection?.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target.matches("[data-address-data-label]")) { + event.preventDefault(); + event.target.blur(); + } +}); diff --git a/frontend/js/app_state.js b/frontend/js/app_state.js index 284c0ea..df80cd9 100644 --- a/frontend/js/app_state.js +++ b/frontend/js/app_state.js @@ -8,6 +8,7 @@ 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."], + addressdata: ["Address Data", "Review on-chain application data stored by this address."], addressbook: ["Address Book", "Manage known addresses and labels."], settings: ["Settings", "Manage wallets, nodes, security, and app preferences."] }; @@ -36,6 +37,7 @@ 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 addressDataView = document.querySelector("#address-data-view"); const addressbookView = document.querySelector("#addressbook-view"); const settingsView = document.querySelector("#settings-view"); const viewTitle = document.querySelector("#view-title"); @@ -202,6 +204,27 @@ 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 dataStorageForm = document.querySelector("[data-data-storage-form]"); +const dataStorageActions = document.querySelector("[data-data-storage-actions]"); +const dataStorageModeSelect = document.querySelector("[data-data-storage-mode]"); +const dataStorageTypeSelect = document.querySelector("[data-data-storage-value-type]"); +const dataStorageTypeRow = document.querySelector("[data-data-storage-type-row]"); +const dataStorageKeyChoiceRow = document.querySelector("[data-data-storage-key-choice-row]"); +const dataStorageKeySelect = document.querySelector("[data-data-storage-key-select]"); +const dataStorageKeyRow = document.querySelector("[data-data-storage-key-row]"); +const dataStorageFieldRow = document.querySelector("[data-data-storage-field-row]"); +const dataStorageValueRow = document.querySelector("[data-data-storage-value-row]"); +const dataStoragePreviousRow = document.querySelector("[data-data-storage-previous-row]"); +const dataStorageKeyInput = document.querySelector("[data-data-storage-key]"); +const dataStorageFieldKeyInput = document.querySelector("[data-data-storage-field-key]"); +const dataStorageValueInput = document.querySelector("[data-data-storage-value]"); +const dataStoragePreviousInput = document.querySelector("[data-data-storage-previous]"); +const dataStorageFeeInput = document.querySelector("[data-data-storage-fee]"); +const dataStorageWriter = document.querySelector("[data-data-storage-writer]"); +const dataStorageActionLabel = document.querySelector("[data-data-storage-action-label]"); +const dataStorageTypeLabel = document.querySelector("[data-data-storage-type-label]"); +const dataStorageFeeLabel = document.querySelector("[data-data-storage-fee-label]"); +const dataStorageMessage = document.querySelector("[data-data-storage-message]"); const vanityForm = document.querySelector("[data-vanity-form]"); const vanityActions = document.querySelector("[data-vanity-actions]"); const vanityNameInput = document.querySelector("[data-vanity-name]"); @@ -368,6 +391,7 @@ let latestDashboardTransactionsTimer = null; let latestDashboardTxids = []; let latestDashboardTransactionRecords = new Map(); let backgroundTransactionSyncId = 0; +let backgroundTransactionSyncPromise = Promise.resolve(); let historyRefreshTimer = null; let historyRefreshReadId = 0; let historyRefreshInFlight = false; @@ -676,6 +700,7 @@ async function refreshDashboardNftBalanceKeys() { return knownNftBalanceLoad; } + const wasReady = dashboardNftBalanceKeysReady(); knownNftBalanceLoad = invoke("nft_list", { broadcastNode }) .then((rows) => { const nextKeys = new Set(); @@ -692,7 +717,7 @@ async function refreshDashboardNftBalanceKeys() { knownNftBalanceKeys = nextKeys; knownNftBalanceNode = broadcastNode; knownNftBalanceLoadedAt = Date.now(); - return previous !== next; + return !wasReady || previous !== next; }) .catch(() => false) .finally(() => { @@ -832,6 +857,7 @@ function resetTransactionForms() { "resetCollateralClaimSession", "resetBurnSession", "resetMarketingSession", + "resetDataStorageSession", "resetVanityAddressSession" ].forEach((name) => { if (typeof window[name] === "function") { @@ -850,6 +876,7 @@ function resetTransactionForms() { "clearCollateralClaimForm", "clearBurnForm", "clearMarketingForm", + "clearDataStorageForm", "clearVanityForm" ].forEach((name) => { if (typeof window[name] === "function") { @@ -868,6 +895,7 @@ function resetTransactionForms() { collateralClaimMessage, burnMessage, marketingMessage, + dataStorageMessage, vanityMessage ].forEach((element) => { if (!element) { diff --git a/frontend/js/collateral_claim_form.js b/frontend/js/collateral_claim_form.js index 5445198..9bc518c 100644 --- a/frontend/js/collateral_claim_form.js +++ b/frontend/js/collateral_claim_form.js @@ -56,12 +56,11 @@ async function loadCollateralClaimContracts(force = false) { if (collateralClaimLoadPromise) { return collateralClaimLoadPromise; } - const invoke = window.__TAURI__?.core?.invoke; - const broadcastNode = activeBroadcastNode(); - if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) { + if (!walletLoaded || !registrationRegistered) { return; } - const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + const walletToken = activeWalletCacheToken(); + const key = `${walletToken}|local-claimable-collateral`; if (!force && collateralClaimContractsKey === key) { populateCollateralClaimContracts(); return; @@ -69,7 +68,9 @@ async function loadCollateralClaimContracts(force = false) { collateralClaimLoadPromise = (async () => { setCollateralClaimMessage("Loading claimable collateral..."); - const contracts = await invoke("claimable_collateral_contracts", { broadcastNode }); + const contracts = typeof loadLocalClaimableCollateralContracts === "function" + ? await loadLocalClaimableCollateralContracts(walletToken) + : []; collateralClaimContracts = Array.isArray(contracts) ? contracts : []; collateralClaimContractsKey = key; populateCollateralClaimContracts(); diff --git a/frontend/js/dashboard_network.js b/frontend/js/dashboard_network.js index 27bf65c..9f1baa6 100644 --- a/frontend/js/dashboard_network.js +++ b/frontend/js/dashboard_network.js @@ -199,7 +199,7 @@ function signedAmount(sign, value) { function signedBaseFee(sign, value) { const amount = signedAmount(sign, value); - return amount && amount !== "0" ? `${amount} ${activeBaseCoin()}` : "--"; + return amount && amount !== "0" ? amount : "--"; } function statusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) { @@ -248,7 +248,21 @@ function txTypeName(type) { 9: "Collateral Claim", 10: "Burn", 11: "Issue Token", - 12: "Vanity Address" + 12: "Vanity Address", + 100: "Data Storage Key", + 101: "Data Storage Bool", + 102: "Data Storage U8", + 103: "Data Storage U16", + 104: "Data Storage U32", + 105: "Data Storage U64", + 106: "Data Storage U128", + 107: "Data Storage String", + 108: "Data Storage I8", + 109: "Data Storage I16", + 110: "Data Storage I32", + 111: "Data Storage I64", + 112: "Data Storage I128", + 113: "Delete Stored Data" }[type] || "Transaction"; } diff --git a/frontend/js/data_storage_form.js b/frontend/js/data_storage_form.js new file mode 100644 index 0000000..929b72e --- /dev/null +++ b/frontend/js/data_storage_form.js @@ -0,0 +1,444 @@ +const DATA_STORAGE_KEY_FEE_ATOMIC = 50000000000n; +const DATA_STORAGE_VALUE_FEE_ATOMIC = 100000000n; +const DATA_STORAGE_U64_MAX = 18446744073709551615n; +const DATA_STORAGE_U128_MAX = 340282366920938463463374607431768211455n; +const DATA_STORAGE_I64_MIN = -9223372036854775808n; +const DATA_STORAGE_I64_MAX = 9223372036854775807n; +const DATA_STORAGE_I128_MIN = -170141183460469231731687303715884105728n; +const DATA_STORAGE_I128_MAX = 170141183460469231731687303715884105727n; +const DATA_STORAGE_NEW_KEY_OPTION = "__new__"; + +function setDataStorageMessage(message, type = "") { + setScopedTransactionMessage("Data Storage", dataStorageMessage, message, type); +} + +function isDataStorageKeyMode() { + return dataStorageModeSelect?.value !== "write_value"; +} + +function dataStorageFeeAtomic() { + return isDataStorageKeyMode() ? DATA_STORAGE_KEY_FEE_ATOMIC : DATA_STORAGE_VALUE_FEE_ATOMIC; +} + +function dataStorageValueTypeName() { + return inferDataStorageValueType(dataStorageValueInput?.value); +} + +function inferDataStorageValueType(value) { + const text = String(value ?? "").trim(); + if (!text) { + return ""; + } + + const normalized = text.toLowerCase(); + if (["true", "false", "yes", "no"].includes(normalized)) { + return "bool"; + } + + if (/^\+?\d+$/.test(text)) { + const number = BigInt(text); + if (number <= 255n) return "u8"; + if (number <= 65535n) return "u16"; + if (number <= 4294967295n) return "u32"; + if (number <= DATA_STORAGE_U64_MAX) return "u64"; + if (number <= DATA_STORAGE_U128_MAX) return "u128"; + return "string"; + } + + if (/^-\d+$/.test(text)) { + const number = BigInt(text); + if (number >= -128n) return "i8"; + if (number >= -32768n) return "i16"; + if (number >= -2147483648n) return "i32"; + if (number >= DATA_STORAGE_I64_MIN) return "i64"; + if (number >= DATA_STORAGE_I128_MIN) return "i128"; + return "string"; + } + + return "string"; +} + +function dataStorageFieldIsAscii(value) { + return /^[\x20-\x7E]+$/.test(value); +} + +function dataStorageHashIsValid(value) { + return /^[0-9a-fA-F]{64}$/.test(String(value || "").trim()); +} + +function dataStoragePreviousHashValue() { + const text = String(dataStoragePreviousInput?.value || "").trim(); + return text === "" ? "0" : text; +} + +function dataStorageUsesNewKey() { + return dataStorageKeySelect?.value === DATA_STORAGE_NEW_KEY_OPTION; +} + +function selectedDataStorageKey() { + if (dataStorageUsesNewKey()) { + return String(dataStorageKeyInput?.value || "").trim(); + } + return String(dataStorageKeySelect?.value || "").trim(); +} + +async function refreshDataStorageKeyOptions() { + if (!dataStorageKeySelect || isDataStorageKeyMode()) { + return; + } + + const previousSelection = dataStorageKeySelect.value; + dataStorageKeySelect.disabled = true; + dataStorageKeySelect.replaceChildren(new Option("Loading storage keys...", "")); + + let knownKeys = []; + try { + if (typeof knownAddressDataStorageKeys === "function") { + knownKeys = await knownAddressDataStorageKeys(); + } + } catch (_) { + knownKeys = []; + } + + knownKeys.sort((left, right) => { + const leftName = left.label || left.storageId; + const rightName = right.label || right.storageId; + return leftName.localeCompare(rightName); + }); + + const options = [new Option("Select storage key", "")]; + knownKeys.forEach(({ storageId, label }) => { + options.push(new Option(label || storageId, storageId)); + }); + options.push(new Option("Enter new storage key", DATA_STORAGE_NEW_KEY_OPTION)); + dataStorageKeySelect.replaceChildren(...options); + dataStorageKeySelect.disabled = false; + + const selectionStillExists = Array.from(dataStorageKeySelect.options) + .some((option) => option.value === previousSelection); + dataStorageKeySelect.value = selectionStillExists ? previousSelection : ""; + updateDataStorageDerivedFields(); +} + +function dataStorageParseBigInt(value, max, label) { + const text = String(value || "").trim(); + if (!/^\+?\d+$/.test(text)) { + return { error: `${label} must be a whole number.` }; + } + const number = BigInt(text); + if (number > max) { + return { error: `${label} is too large for ${dataStorageValueTypeName().toUpperCase()}.` }; + } + return { value: number.toString() }; +} + +function dataStorageParseSignedBigInt(value, min, max, label) { + const text = String(value || "").trim(); + if (!/^-?\d+$/.test(text)) { + return { error: `${label} must be a whole number.` }; + } + const number = BigInt(text); + if (number < min || number > max) { + return { error: `${label} is outside the ${dataStorageValueTypeName().toUpperCase()} range.` }; + } + return { value: number.toString() }; +} + +function dataStorageNormalizedBool(value) { + const text = String(value || "").trim().toLowerCase(); + if (["true", "1", "yes"].includes(text)) { + return { value: "true" }; + } + if (["false", "0", "no"].includes(text)) { + return { value: "false" }; + } + return { error: "Bool value must be true or false." }; +} + +function updateDataStorageDerivedFields() { + const keyMode = isDataStorageKeyMode(); + const inferredType = dataStorageValueTypeName(); + const stringMode = !keyMode && inferredType === "string"; + const fee = dataStorageFeeAtomic(); + + dataStorageTypeRow?.classList.toggle("hidden", keyMode); + dataStorageKeyChoiceRow?.classList.toggle("hidden", keyMode); + dataStorageKeyRow?.classList.toggle("hidden", keyMode || !dataStorageUsesNewKey()); + dataStorageFieldRow?.classList.toggle("hidden", keyMode); + dataStorageValueRow?.classList.toggle("hidden", keyMode); + dataStoragePreviousRow?.classList.toggle("hidden", !stringMode); + + if (dataStorageTypeSelect) { + dataStorageTypeSelect.value = keyMode ? "" : inferredType; + } + + if (dataStorageFeeInput) { + dataStorageFeeInput.value = atomicToDecimal(fee); + } + if (dataStorageWriter) { + dataStorageWriter.textContent = activeWalletAddressShort || activeWalletAddressHex || "--"; + } + if (dataStorageActionLabel) { + dataStorageActionLabel.textContent = keyMode ? "Create storage key" : "Write storage value"; + } + if (dataStorageTypeLabel) { + dataStorageTypeLabel.textContent = keyMode || !inferredType ? "--" : inferredType.toUpperCase(); + } + if (dataStorageFeeLabel) { + dataStorageFeeLabel.textContent = `${atomicToDecimal(fee)} ${activeBaseCoin()}`; + } +} + +function validateDataStorageForm() { + if (!walletLoaded) { + return "Load a wallet before creating a data storage transaction."; + } + if (!registrationRegistered) { + return "Address registration is required before creating a data storage transaction."; + } + if (baseCoinBalanceAtomic() < dataStorageFeeAtomic()) { + return `Data storage requires ${atomicToDecimal(dataStorageFeeAtomic())} ${activeBaseCoin()} for the fee.`; + } + if (isDataStorageKeyMode()) { + return ""; + } + + const storageKey = selectedDataStorageKey(); + if (!storageKey) { + return "Select a storage key or choose Enter new storage key."; + } + if (!dataStorageHashIsValid(storageKey)) { + return "Storage key hash must be exactly 64 hexadecimal characters."; + } + + const fieldKey = String(dataStorageFieldKeyInput?.value || "").trim(); + if (!fieldKey) { + return "Data key is required."; + } + if (fieldKey.length > 50 || !dataStorageFieldIsAscii(fieldKey)) { + return "Data key must be ASCII and no longer than 50 characters."; + } + + const value = String(dataStorageValueInput?.value || ""); + const type = dataStorageValueTypeName(); + if (!type) { + return "Value is required."; + } + if (type === "bool") { + return dataStorageNormalizedBool(value).error || ""; + } + if (type === "u8") { + return dataStorageParseBigInt(value, 255n, "U8 value").error || ""; + } + if (type === "u16") { + return dataStorageParseBigInt(value, 65535n, "U16 value").error || ""; + } + if (type === "u32") { + return dataStorageParseBigInt(value, 4294967295n, "U32 value").error || ""; + } + if (type === "u64") { + return dataStorageParseBigInt(value, DATA_STORAGE_U64_MAX, "U64 value").error || ""; + } + if (type === "u128") { + return dataStorageParseBigInt(value, DATA_STORAGE_U128_MAX, "U128 value").error || ""; + } + if (type === "i8") { + return dataStorageParseSignedBigInt(value, -128n, 127n, "I8 value").error || ""; + } + if (type === "i16") { + return dataStorageParseSignedBigInt(value, -32768n, 32767n, "I16 value").error || ""; + } + if (type === "i32") { + return dataStorageParseSignedBigInt(value, -2147483648n, 2147483647n, "I32 value").error || ""; + } + if (type === "i64") { + return dataStorageParseSignedBigInt(value, DATA_STORAGE_I64_MIN, DATA_STORAGE_I64_MAX, "I64 value").error || ""; + } + if (type === "i128") { + return dataStorageParseSignedBigInt(value, DATA_STORAGE_I128_MIN, DATA_STORAGE_I128_MAX, "I128 value").error || ""; + } + if (type === "string") { + if (!dataStorageFieldIsAscii(value) || value.length > 180) { + return "String value must be ASCII and no longer than 180 characters."; + } + const previous = dataStoragePreviousHashValue(); + if (previous !== "0" && !dataStorageHashIsValid(previous)) { + return "Previous string transaction must be 0 or a 64 character hash."; + } + return ""; + } + return "Select a valid storage value type."; +} + +function normalizedDataStorageValue() { + const value = String(dataStorageValueInput?.value || "").trim(); + const type = dataStorageValueTypeName(); + if (type === "bool") { + return dataStorageNormalizedBool(value).value; + } + if (type === "u8") { + return dataStorageParseBigInt(value, 255n, "U8 value").value; + } + if (type === "u16") { + return dataStorageParseBigInt(value, 65535n, "U16 value").value; + } + if (type === "u32") { + return dataStorageParseBigInt(value, 4294967295n, "U32 value").value; + } + if (type === "u64") { + return dataStorageParseBigInt(value, DATA_STORAGE_U64_MAX, "U64 value").value; + } + if (type === "u128") { + return dataStorageParseBigInt(value, DATA_STORAGE_U128_MAX, "U128 value").value; + } + if (type === "i8") { + return dataStorageParseSignedBigInt(value, -128n, 127n, "I8 value").value; + } + if (type === "i16") { + return dataStorageParseSignedBigInt(value, -32768n, 32767n, "I16 value").value; + } + if (type === "i32") { + return dataStorageParseSignedBigInt(value, -2147483648n, 2147483647n, "I32 value").value; + } + if (type === "i64") { + return dataStorageParseSignedBigInt(value, DATA_STORAGE_I64_MIN, DATA_STORAGE_I64_MAX, "I64 value").value; + } + if (type === "i128") { + return dataStorageParseSignedBigInt(value, DATA_STORAGE_I128_MIN, DATA_STORAGE_I128_MAX, "I128 value").value; + } + return String(dataStorageValueInput?.value || "").trim(); +} + +function dataStoragePayload() { + return { + mode: dataStorageModeSelect.value, + valueType: dataStorageValueTypeName(), + storageKey: selectedDataStorageKey(), + fieldKey: String(dataStorageFieldKeyInput?.value || "").trim(), + value: isDataStorageKeyMode() ? "" : normalizedDataStorageValue(), + previousHash: isDataStorageKeyMode() ? "0" : dataStoragePreviousHashValue(), + txfee: String(dataStorageFeeAtomic()) + }; +} + +function clearDataStorageForm() { + if (dataStorageModeSelect) { + dataStorageModeSelect.value = "create_key"; + } + if (dataStorageTypeSelect) { + dataStorageTypeSelect.value = ""; + } + if (dataStorageKeySelect) { + dataStorageKeySelect.value = ""; + } + if (dataStorageKeyInput) { + dataStorageKeyInput.value = ""; + } + if (dataStorageFieldKeyInput) { + dataStorageFieldKeyInput.value = ""; + } + if (dataStorageValueInput) { + dataStorageValueInput.value = ""; + } + if (dataStoragePreviousInput) { + dataStoragePreviousInput.value = ""; + } + updateDataStorageDerivedFields(); +} + +function setDataStorageButtonsDisabled(disabled) { + document.querySelector("[data-data-storage-save]")?.toggleAttribute("disabled", disabled); + document.querySelector("[data-data-storage-broadcast]")?.toggleAttribute("disabled", disabled); +} + +async function handleDataStorageAction(action) { + const validationError = validateDataStorageForm(); + if (validationError) { + setDataStorageMessage(validationError, "error"); + return; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setDataStorageMessage("Wallet bridge is not available.", "error"); + return; + } + const walletKey = await requestSigningWalletKey(); + if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) { + setDataStorageMessage("Signing cancelled."); + return; + } + + setDataStorageButtonsDisabled(true); + try { + if (action === "save") { + setDataStorageMessage("Saving data storage transaction..."); + const review = await invoke("save_data_storage_transaction", { + ...dataStoragePayload(), + walletKey + }); + clearDataStorageForm(); + setDataStorageMessage(`Data storage 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."); + } + setDataStorageMessage("Broadcasting data storage transaction..."); + const result = await invoke("broadcast_data_storage_transaction", { + ...dataStoragePayload(), + broadcastNode, + walletKey + }); + clearDataStorageForm(); + setDataStorageMessage(result.response || "Data storage transaction broadcast.", "success"); + } + } catch (error) { + setDataStorageMessage(String(error), "error"); + } finally { + setDataStorageButtonsDisabled(false); + } +} + +function prepareDataStorageForm() { + updateDataStorageDerivedFields(); + refreshDataStorageKeyOptions(); + setDataStorageMessage(""); +} + +function resetDataStorageSession() { + clearDataStorageForm(); + setDataStorageMessage(""); +} + +[ + dataStorageModeSelect, + dataStorageTypeSelect, + dataStorageKeySelect, + dataStorageKeyInput, + dataStorageFieldKeyInput, + dataStorageValueInput, + dataStoragePreviousInput +].forEach((element) => { + element?.addEventListener("input", () => { + updateDataStorageDerivedFields(); + setDataStorageMessage(""); + }); + element?.addEventListener("change", () => { + updateDataStorageDerivedFields(); + setDataStorageMessage(""); + }); +}); + +dataStorageModeSelect?.addEventListener("change", () => { + refreshDataStorageKeyOptions(); +}); + +document + .querySelector("[data-data-storage-save]") + ?.addEventListener("click", () => handleDataStorageAction("save")); +document + .querySelector("[data-data-storage-broadcast]") + ?.addEventListener("click", () => handleDataStorageAction("broadcast")); diff --git a/frontend/js/history_page.js b/frontend/js/history_page.js index 87436dc..6df16de 100644 --- a/frontend/js/history_page.js +++ b/frontend/js/history_page.js @@ -282,7 +282,35 @@ function historyTransactionTimestamp(record) { transaction?.unsigned_issue_token?.timestamp, transaction?.unsigned_issue_token?.time, transaction?.unsigned_vanity_address?.timestamp, - transaction?.unsigned_vanity_address?.time + transaction?.unsigned_vanity_address?.time, + transaction?.unsigned_storagekey?.timestamp, + transaction?.unsigned_storagekey?.time, + transaction?.unsigned_storagebool?.timestamp, + transaction?.unsigned_storagebool?.time, + transaction?.unsigned_storageu8?.timestamp, + transaction?.unsigned_storageu8?.time, + transaction?.unsigned_storageu16?.timestamp, + transaction?.unsigned_storageu16?.time, + transaction?.unsigned_storageu32?.timestamp, + transaction?.unsigned_storageu32?.time, + transaction?.unsigned_storageu64?.timestamp, + transaction?.unsigned_storageu64?.time, + transaction?.unsigned_storageu128?.timestamp, + transaction?.unsigned_storageu128?.time, + transaction?.unsigned_storage?.timestamp, + transaction?.unsigned_storage?.time, + transaction?.unsigned_storagei8?.timestamp, + transaction?.unsigned_storagei8?.time, + transaction?.unsigned_storagei16?.timestamp, + transaction?.unsigned_storagei16?.time, + transaction?.unsigned_storagei32?.timestamp, + transaction?.unsigned_storagei32?.time, + transaction?.unsigned_storagei64?.timestamp, + transaction?.unsigned_storagei64?.time, + transaction?.unsigned_storagei128?.timestamp, + transaction?.unsigned_storagei128?.time, + transaction?.unsigned_deletekey?.timestamp, + transaction?.unsigned_deletekey?.time ]; const timestamp = candidates.map(Number).find((value) => Number.isFinite(value) && value > 0); return timestamp || 0; diff --git a/frontend/js/loan_payment_form.js b/frontend/js/loan_payment_form.js index 2949549..caccfe7 100644 --- a/frontend/js/loan_payment_form.js +++ b/frontend/js/loan_payment_form.js @@ -55,12 +55,11 @@ async function loadLoanPaymentContracts(force = false) { if (loanPaymentLoadPromise) { return loanPaymentLoadPromise; } - const invoke = window.__TAURI__?.core?.invoke; - const broadcastNode = activeBroadcastNode(); - if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) { + if (!walletLoaded || !registrationRegistered) { return; } - const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`; + const walletToken = activeWalletCacheToken(); + const key = `${walletToken}|local-active-loans`; if (!force && loanPaymentContractsKey === key) { populateLoanPaymentContracts(); return; @@ -68,7 +67,9 @@ async function loadLoanPaymentContracts(force = false) { loanPaymentLoadPromise = (async () => { setLoanPaymentMessage("Loading active loans..."); - const contracts = await invoke("active_loan_contracts", { broadcastNode }); + const contracts = typeof loadLocalActiveBorrowerLoanContracts === "function" + ? await loadLocalActiveBorrowerLoanContracts(walletToken) + : []; loanPaymentContracts = Array.isArray(contracts) ? contracts : []; loanPaymentContractsKey = key; populateLoanPaymentContracts(); diff --git a/frontend/js/loans_page.js b/frontend/js/loans_page.js index b7b1d97..c11a65c 100644 --- a/frontend/js/loans_page.js +++ b/frontend/js/loans_page.js @@ -74,6 +74,20 @@ function loanPaymentCount(contract) { return Array.isArray(contract.payments) ? contract.payments.length : 0; } +function loanPeriodLabel(period) { + const normalized = String(period || "").trim().toLowerCase(); + if (normalized === "d" || normalized === "daily") { + return "daily"; + } + if (normalized === "w" || normalized === "weekly") { + return "weekly"; + } + if (normalized === "m" || normalized === "monthly") { + return "monthly"; + } + return normalized || "unknown"; +} + function loanAmountLabel(value, asset) { return `${loanFormatAtomic(value)} ${String(asset || "").trim()}`.trim(); } @@ -146,6 +160,257 @@ function nextLoanPaymentDueLabel(contract) { return formatLoanDueDate(loanDueDateForPayment(contract, completedPayments + 1)); } +function loanTransactionTime(transaction) { + return Number( + transaction?.unsigned_loan_contract?.time ?? + transaction?.unsigned_loan_contract?.timestamp ?? + transaction?.unsigned_contract_payment?.time ?? + transaction?.unsigned_contract_payment?.timestamp ?? + transaction?.unsigned_collateral_claim?.time ?? + transaction?.unsigned_collateral_claim?.timestamp ?? + 0 + ) || 0; +} + +function loanContractRecordFromCache(txid, record) { + const tx = record?.transaction?.unsigned_loan_contract; + if (!tx) { + return null; + } + const contractHash = txid || record?.txid || ""; + const totalDue = BigInt(tx.payment_amount || 0) * BigInt(tx.payment_number || 0); + return { + contract: contractHash, + status: "active", + start_timestamp: Number(tx.time ?? tx.timestamp ?? 0) || 0, + lender: tx.lender || "", + borrower: tx.borrower || "", + loan_asset: tx.loan_coin || activeBaseCoin(), + loan_amount: String(tx.loan_amount || 0), + collateral: tx.collateral || activeBaseCoin(), + collateral_amount: String(tx.collateral_amount || 0), + payment_period: loanPeriodLabel(tx.payment_period), + payment_number: Number(tx.payment_number || 0), + payment_amount: String(tx.payment_amount || 0), + grace_period: Number(tx.grace_period || 0), + max_late_value: String(tx.max_late_value || 0), + confirmed_paid: "0", + pending_paid: "0", + remaining_balance: String(totalDue), + remaining_after_pending: String(totalDue), + payments: [], + collateral_claimed_by: "" + }; +} + +function loanPaymentsDueCount(contract, nowSeconds = Math.floor(Date.now() / 1000)) { + const startTimestamp = Number(contract?.start_timestamp || 0); + const paymentNumber = Number(contract?.payment_number || 0); + if (!startTimestamp || !paymentNumber || nowSeconds <= startTimestamp) { + return 0; + } + const period = String(contract.payment_period || "").trim().toLowerCase(); + let due = 0; + if (period === "daily") { + due = Math.floor((nowSeconds - startTimestamp) / 86_400); + } else if (period === "weekly") { + due = Math.floor((nowSeconds - startTimestamp) / (7 * 86_400)); + } else if (period === "monthly") { + const start = new Date(startTimestamp * 1000); + const now = new Date(nowSeconds * 1000); + due = (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + + (now.getUTCMonth() - start.getUTCMonth()); + const dueDate = addUtcMonths(startTimestamp, due); + if (dueDate && now.getTime() < dueDate.getTime()) { + due -= 1; + } + } + return Math.max(0, Math.min(paymentNumber, due)); +} + +function loanFinalGracePeriodPassed(contract, nowSeconds = Math.floor(Date.now() / 1000)) { + const paymentNumber = Number(contract?.payment_number || 0); + const gracePeriod = Number(contract?.grace_period || 0); + if (!paymentNumber) { + return false; + } + const dueDate = loanDueDateForPayment(contract, paymentNumber + gracePeriod); + if (!dueDate) { + return false; + } + return nowSeconds >= Math.floor(dueDate.getTime() / 1000) + 86_400; +} + +function finalizeLocalLoanContract(contract) { + const totalPaid = (contract.payments || []).reduce( + (sum, payment) => sum + BigInt(payment.amount_atomic || payment.amount || 0), + 0n + ); + const paymentAmount = BigInt(contract.payment_amount || 0); + const totalDue = paymentAmount * BigInt(contract.payment_number || 0); + const remaining = totalDue > totalPaid ? totalDue - totalPaid : 0n; + const shouldHavePaid = BigInt(loanPaymentsDueCount(contract)) * paymentAmount; + const claimed = Boolean(contract.collateral_claimed_by); + + contract.payments.sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0)); + contract.confirmed_paid = String(totalPaid); + contract.pending_paid = "0"; + contract.remaining_balance = String(remaining); + contract.remaining_after_pending = String(remaining); + contract.status = claimed ? "inactive" : (shouldHavePaid > totalPaid ? "delinquent" : "active"); + return contract; +} + +async function applyRemoteCollateralStatuses(contracts) { + const invoke = window.__TAURI__?.core?.invoke; + const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; + if (!invoke || !broadcastNode || !Array.isArray(contracts) || !contracts.length) { + return contracts; + } + + await Promise.all(contracts.map(async (contract) => { + if (!contract?.contract || contract.collateral_claimed_by) { + return; + } + try { + const status = await invoke("collateral_status", { + broadcastNode, + contractHash: contract.contract + }); + if (status?.claimed) { + contract.collateral_claimed_by = "remote"; + contract.status = "inactive"; + } + } catch (_) { + // Keep the locally synced view usable if the lightweight status check is unavailable. + } + })); + return contracts; +} + +async function loadLocalLoanContractsFromCache(walletToken = activeWalletCacheToken()) { + if (typeof readTransactionIndexObject !== "function" || typeof readTransactionCacheRecords !== "function") { + return []; + } + + const index = await readTransactionIndexObject(walletToken); + let typeTxids = [7, 8, 9].flatMap((type) => Array.isArray(index.type_to_txids?.[String(type)]) + ? index.type_to_txids[String(type)] + : []); + if (!typeTxids.length) { + typeTxids = Array.isArray(index.ordered_txids) ? index.ordered_txids : []; + } + const uniqueTxids = Array.from(new Set(typeTxids.filter(Boolean))); + if (!uniqueTxids.length) { + return []; + } + + const cache = await readTransactionCacheRecords(index, uniqueTxids, walletToken); + const contracts = new Map(); + Object.entries(cache.transactions || {}).forEach(([txid, record]) => { + const contract = loanContractRecordFromCache(txid, record); + if (contract && (isActiveWalletAddress(contract.lender) || isActiveWalletAddress(contract.borrower))) { + contracts.set(contract.contract, contract); + } + }); + + Object.entries(cache.transactions || {}).forEach(([txid, record]) => { + const payment = record?.transaction?.unsigned_contract_payment; + if (payment?.contract_hash && contracts.has(payment.contract_hash)) { + const contract = contracts.get(payment.contract_hash); + contract.payments.push({ + txid, + amount: String(payment.payback_amount || 0), + amount_atomic: String(payment.payback_amount || 0), + payer: payment.address || "", + timestamp: Number(payment.time ?? payment.timestamp ?? 0) || 0, + date: formatHistoryDate(loanTransactionTime(record.transaction)) + }); + } + + const claim = record?.transaction?.unsigned_collateral_claim; + if (claim?.contract_hash && contracts.has(claim.contract_hash)) { + contracts.get(claim.contract_hash).collateral_claimed_by = claim.address || ""; + } + }); + + return Array.from(contracts.values()).map(finalizeLocalLoanContract); +} + +async function loadLocalActiveBorrowerLoanContracts(walletToken = activeWalletCacheToken()) { + const activeAddress = String(activeWalletAddressShort || "").trim().toLowerCase(); + const contracts = await applyRemoteCollateralStatuses(await loadLocalLoanContractsFromCache(walletToken)); + return contracts.filter((contract) => ( + String(contract.borrower || "").trim().toLowerCase() === activeAddress && + String(contract.status || "").toLowerCase() !== "inactive" && + BigInt(contract.remaining_after_pending || 0) > 0n + )); +} + +function localCollateralClaimReason(contract) { + if (!contract || String(contract.status || "").toLowerCase() === "inactive") { + return ""; + } + const role = activeLoanRole(contract); + const remainingBalance = BigInt(contract.remaining_balance || "0"); + if (role === "borrower") { + return remainingBalance === 0n + ? "Loan is fully repaid, so the borrower can reclaim collateral." + : ""; + } + if (role !== "lender") { + return ""; + } + + if (remainingBalance > 0n && loanFinalGracePeriodPassed(contract)) { + return "The full loan term plus allowed grace has passed and an unpaid balance remains."; + } + + const paymentsDue = loanPaymentsDueCount(contract); + const gracePeriod = Number(contract.grace_period || 0); + if (paymentsDue <= gracePeriod) { + return ""; + } + const confirmedPaid = BigInt(contract.confirmed_paid || "0"); + const paymentAmount = BigInt(contract.payment_amount || "0"); + const maxLateValue = BigInt(contract.max_late_value || "0"); + const shouldHavePaid = BigInt(paymentsDue) * paymentAmount; + const overdueValue = shouldHavePaid > confirmedPaid ? shouldHavePaid - confirmedPaid : 0n; + return overdueValue > maxLateValue + ? `Borrower is late by ${overdueValue - maxLateValue} atomic units beyond the allowed threshold.` + : ""; +} + +async function loadLocalClaimableCollateralContracts(walletToken = activeWalletCacheToken()) { + const contracts = await applyRemoteCollateralStatuses(await loadLocalLoanContractsFromCache(walletToken)); + return contracts + .map((contract) => { + const reason = localCollateralClaimReason(contract); + if (!reason) { + return null; + } + const role = activeLoanRole(contract); + return { + contract: contract.contract, + role, + reason, + lender: contract.lender, + borrower: contract.borrower, + loan_asset: contract.loan_asset, + loan_amount: contract.loan_amount, + collateral: contract.collateral, + collateral_amount: contract.collateral_amount, + confirmed_paid: contract.confirmed_paid, + pending_paid: contract.pending_paid, + remaining_balance: contract.remaining_balance, + remaining_after_pending: contract.remaining_after_pending, + fee: String(COLLATERAL_CLAIM_FEE_ATOMIC || 300000000n) + }; + }) + .filter(Boolean) + .sort((left, right) => left.contract.localeCompare(right.contract)); +} + function sortedLoans() { const query = String(loansSearch?.value || "").trim().toLowerCase(); const roleFilter = loansRoleFilter?.value || "all"; @@ -327,8 +592,6 @@ function renderLoansPage() { } async function loadLoansPage({ force = false } = {}) { - const invoke = window.__TAURI__?.core?.invoke; - const broadcastNode = activeBroadcastNode(); if (!walletLoaded) { loansContracts = []; selectedLoanContractHash = ""; @@ -336,21 +599,8 @@ async function loadLoansPage({ force = false } = {}) { 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}`; + const walletToken = activeWalletCacheToken(); + const key = `${walletToken}|local-loans`; if (!force && loansLoadedForKey === key) { renderLoansPage(); return; @@ -363,21 +613,17 @@ async function loadLoansPage({ force = false } = {}) { loansStateMessage = "Loading loans..."; renderLoansPage(); try { - const contracts = await invokeWalletRpcWithBackoff( - "loan contracts", - () => invoke("loan_contracts", { broadcastNode }), - () => currentView === "loans" && activeBroadcastNode() === broadcastNode - ); - loansContracts = Array.isArray(contracts) ? contracts : []; + const contracts = await loadLocalLoanContractsFromCache(walletToken); + loansContracts = await applyRemoteCollateralStatuses(Array.isArray(contracts) ? contracts : []); loansLoadedForKey = key; selectedLoanContractHash = loansContracts[0]?.contract || ""; loansStateMessage = loansContracts.length ? "" - : "Loan contract command returned 0 contracts for this wallet."; + : "No locally synced loan contracts found for this wallet."; } catch (error) { loansContracts = []; selectedLoanContractHash = ""; - loansStateMessage = `Loan contract lookup failed: ${String(error)}`; + loansStateMessage = `Local loan cache lookup failed: ${String(error)}`; } finally { loansLoadInFlight = false; renderLoansPage(); diff --git a/frontend/js/navigation_actions.js b/frontend/js/navigation_actions.js index 92ff80e..2300a06 100644 --- a/frontend/js/navigation_actions.js +++ b/frontend/js/navigation_actions.js @@ -13,6 +13,9 @@ if (previousView === "nfts" && view !== "nfts" && typeof resetNftsPage === "function") { resetNftsPage(); } + if (previousView === "addressdata" && view !== "addressdata" && typeof resetAddressDataSelection === "function") { + resetAddressDataSelection(); + } const [title, subtitle] = titles[view] || titles.dashboard; currentView = view; @@ -42,6 +45,7 @@ nftsView.classList.add("hidden"); loansView.classList.add("hidden"); marketingView.classList.add("hidden"); + addressDataView.classList.add("hidden"); addressbookView.classList.add("hidden"); settingsView.classList.add("hidden"); updateNetworkInfoRefreshState(); @@ -67,6 +71,7 @@ nftsView.classList.add("hidden"); loansView.classList.add("hidden"); marketingView.classList.add("hidden"); + addressDataView.classList.add("hidden"); addressbookView.classList.add("hidden"); settingsView.classList.add("hidden"); @@ -105,6 +110,12 @@ prepareMarketingCampaignPage(); return; } + + if (view === "addressdata") { + addressDataView.classList.remove("hidden"); + prepareAddressDataPage(); + return; + } if (view === "addressbook") { addressbookView.classList.remove("hidden"); @@ -193,6 +204,8 @@ function refreshActiveTransactionFormAfterWalletData() { prepareCollateralClaimForm(); } else if (currentTransactionType === "Burn" && typeof prepareBurnForm === "function") { prepareBurnForm(); + } else if (currentTransactionType === "Data Storage" && typeof prepareDataStorageForm === "function") { + prepareDataStorageForm(); } } @@ -212,6 +225,7 @@ function clearInactiveTransactionMessages(activeType) { "Collateral Claim": collateralClaimMessage, "Burn": burnMessage, "Marketing": marketingMessage, + "Data Storage": dataStorageMessage, "Vanity Address": vanityMessage }; Object.entries(messageByType).forEach(([type, element]) => { @@ -244,6 +258,7 @@ function showTransactionForm(type) { const isCollateralClaim = type === "Collateral Claim"; const isBurn = type === "Burn"; const isMarketing = type === "Marketing"; + const isDataStorage = type === "Data Storage"; const isVanity = type === "Vanity Address"; document.querySelector("[data-transfer-form]")?.classList.toggle("hidden", !isTransfer); transferActions?.classList.toggle("hidden", !isTransfer); @@ -276,6 +291,9 @@ function showTransactionForm(type) { marketingForm?.classList.toggle("hidden", !isMarketing); marketingActions?.classList.toggle("hidden", !isMarketing); marketingMessage?.classList.toggle("hidden", !isMarketing); + dataStorageForm?.classList.toggle("hidden", !isDataStorage); + dataStorageActions?.classList.toggle("hidden", !isDataStorage); + dataStorageMessage?.classList.toggle("hidden", !isDataStorage); vanityForm?.classList.toggle("hidden", !isVanity); vanityActions?.classList.toggle("hidden", !isVanity); vanityMessage?.classList.toggle("hidden", !isVanity); @@ -300,6 +318,8 @@ function showTransactionForm(type) { prepareBurnForm(); } else if (isMarketing && typeof prepareMarketingForm === "function") { prepareMarketingForm(); + } else if (isDataStorage && typeof prepareDataStorageForm === "function") { + prepareDataStorageForm(); } else if (isVanity && typeof prepareVanityAddressForm === "function") { prepareVanityAddressForm(); } diff --git a/frontend/js/settings_page.js b/frontend/js/settings_page.js index 4129538..2f8e096 100644 --- a/frontend/js/settings_page.js +++ b/frontend/js/settings_page.js @@ -80,16 +80,20 @@ function scheduleClipboardClear(copiedText) { } lastClipboardText = copiedText || ""; const timeoutMs = durationSettingMs(guiRuntimeSettings.clipboard_clear_timeout); - if (!timeoutMs || !lastClipboardText || !navigator.clipboard) { + if (!timeoutMs || !lastClipboardText) { return; } clipboardClearTimer = setTimeout(async () => { lastClipboardText = ""; + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } try { - await navigator.clipboard.writeText(""); + await invoke("clear_system_clipboard"); } catch (_) { - // Clipboard writes can fail outside a user gesture; never prompt or block. + // Background clipboard clearing must never open a browser permission prompt. } }, timeoutMs); } @@ -102,20 +106,11 @@ async function clearWalletClipboard() { 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) { + if (!invoke) { return; } try { - await navigator.clipboard.writeText(""); + await invoke("clear_system_clipboard"); } catch (_) {} } @@ -311,11 +306,15 @@ async function clearSelectedWalletCache(kind, button) { return; } + const clearingHistory = kind === "history"; button && (button.disabled = true); setCacheClearMessage("Clearing cache..."); try { + if (clearingHistory) { + await stopBackgroundTransactionSync(); + } await invoke("clear_wallet_cache", { cacheKind: kind }); - if (kind === "history") { + if (clearingHistory) { clearHistoryRows(); resetLatestDashboardTransactionsMemory(); if (typeof resetHistoryPage === "function") { @@ -340,6 +339,9 @@ async function clearSelectedWalletCache(kind, button) { } catch (error) { setCacheClearMessage(String(error), "error"); } finally { + if (clearingHistory) { + startBackgroundTransactionSync(); + } button && (button.disabled = false); } } diff --git a/frontend/js/transaction_history.js b/frontend/js/transaction_history.js index 2a6c9a6..4bc2acc 100644 --- a/frontend/js/transaction_history.js +++ b/frontend/js/transaction_history.js @@ -13,11 +13,25 @@ transaction.unsigned_contract_payment?.txtype ?? transaction.unsigned_collateral_claim?.txtype ?? transaction.unsigned_burn?.txtype ?? - transaction.unsigned_issue_token?.txtype ?? - transaction.unsigned_vanity_address?.txtype ?? - 0 - ); -} + transaction.unsigned_issue_token?.txtype ?? + transaction.unsigned_vanity_address?.txtype ?? + transaction.unsigned_storagekey?.txtype ?? + transaction.unsigned_storagebool?.txtype ?? + transaction.unsigned_storageu8?.txtype ?? + transaction.unsigned_storageu16?.txtype ?? + transaction.unsigned_storageu32?.txtype ?? + transaction.unsigned_storageu64?.txtype ?? + transaction.unsigned_storageu128?.txtype ?? + transaction.unsigned_storage?.txtype ?? + transaction.unsigned_storagei8?.txtype ?? + transaction.unsigned_storagei16?.txtype ?? + transaction.unsigned_storagei32?.txtype ?? + transaction.unsigned_storagei64?.txtype ?? + transaction.unsigned_storagei128?.txtype ?? + transaction.unsigned_deletekey?.txtype ?? + 0 + ); +} function txTypeKey(type) { return { @@ -33,7 +47,21 @@ function txTypeKey(type) { 9: "collateral_claim", 10: "burn", 11: "token_issue", - 12: "vanity" + 12: "vanity", + 100: "storage_key", + 101: "storage_bool", + 102: "storage_u8", + 103: "storage_u16", + 104: "storage_u32", + 105: "storage_u64", + 106: "storage_u128", + 107: "storage_string", + 108: "storage_i8", + 109: "storage_i16", + 110: "storage_i32", + 111: "storage_i64", + 112: "storage_i128", + 113: "storage_delete" }[Number(type)] || "transaction"; } @@ -74,6 +102,24 @@ function loanSummaryFromLinkedTransactions(linkedTransactions = {}, contractHash return null; } +function decodedStorageUnsigned(transaction) { + return transaction?.unsigned_storagekey + || transaction?.unsigned_storagebool + || transaction?.unsigned_storageu8 + || transaction?.unsigned_storageu16 + || transaction?.unsigned_storageu32 + || transaction?.unsigned_storageu64 + || transaction?.unsigned_storageu128 + || transaction?.unsigned_storage + || transaction?.unsigned_storagei8 + || transaction?.unsigned_storagei16 + || transaction?.unsigned_storagei32 + || transaction?.unsigned_storagei64 + || transaction?.unsigned_storagei128 + || transaction?.unsigned_deletekey + || null; +} + function parseDecodedDashboardTransactionRows( txid, blockHeight, @@ -224,7 +270,19 @@ function parseDecodedDashboardTransactionRows( } return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || tx.fee || 0))]; } - } catch (_) { + + if (type >= 100 && type <= 113) { + const tx = decodedStorageUnsigned(transaction); + if (!tx) { + return fallback; + } + if (isActiveWalletAddress(tx.address)) { + const counterparty = type === 100 ? "Self" : shortenAddress(tx.storagekey || "Storage"); + return [dashboardRow(base, txTypeName(type), "Data Storage", counterparty, "", signedBaseFee("-", tx.txfee || 0))]; + } + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))]; + } + } catch (_) { return fallback; } @@ -432,15 +490,25 @@ function parseDashboardTransactionRows( return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))]; } - if (type === 12) { + if (type === 12) { const address = bytesToAddressHex(bytes.slice(5, 27)); const fee = readU64LE(bytes, 49); if (isActiveAddress(address)) { return [dashboardRow(base, "Vanity Registered", "Vanity", "Self", "", signedBaseFee("-", fee))]; } - return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(address), signedAmount("+", fee))]; - } - } catch (_) { + return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(address), signedAmount("+", fee))]; + } + + if (type >= 100 && type <= 113) { + return parseDecodedDashboardTransactionRows( + txid, + blockHeight, + decodeLightweightTransaction(bytes), + linkedTransactions, + minerEarnings + ); + } + } catch (_) { return fallback; } @@ -608,6 +676,106 @@ function decodeLightweightTransaction(bytesLike) { } }; } + if (type === 100) { + return { + unsigned_storagekey: { + txtype: type, + time: time(), + address: bytesToAddressHex(bytes.slice(5, 27)), + txfee: Number(readU64LE(bytes, 27)) + } + }; + } + + const storageValue = (wrapper, value, valueEnd) => ({ + [wrapper]: { + txtype: type, + time: time(), + storagekey: bytesToHex(bytes.slice(5, 37)), + key: bytesToText(bytes.slice(37, 87)), + value, + address: bytesToAddressHex(bytes.slice(valueEnd, valueEnd + 22)), + txfee: Number(readU64LE(bytes, valueEnd + 22)) + } + }); + + const signedStorageValue = (wrapper, byteLength) => { + let value = 0n; + for (let index = byteLength - 1; index >= 0; index -= 1) { + value = (value << 8n) + BigInt(Number(bytes[87 + index] || 0)); + } + const bitLength = BigInt(byteLength * 8); + const signBit = 1n << (bitLength - 1n); + if ((value & signBit) !== 0n) { + value -= 1n << bitLength; + } + return storageValue(wrapper, value.toString(), 87 + byteLength); + }; + + if (type === 101) { + return storageValue("unsigned_storagebool", Boolean(bytes[87]), 88); + } + if (type === 102) { + return storageValue("unsigned_storageu8", Number(bytes[87] || 0), 88); + } + if (type === 103) { + const value = Number(bytes[87] || 0) | (Number(bytes[88] || 0) << 8); + return storageValue("unsigned_storageu16", value, 89); + } + if (type === 104) { + return storageValue("unsigned_storageu32", readU32LE(bytes, 87), 91); + } + if (type === 105) { + return storageValue("unsigned_storageu64", readU64LE(bytes, 87).toString(), 95); + } + if (type === 106) { + let value = 0n; + for (let index = 15; index >= 0; index -= 1) { + value = (value << 8n) + BigInt(Number(bytes[87 + index] || 0)); + } + return storageValue("unsigned_storageu128", value.toString(), 103); + } + if (type === 107) { + return { + unsigned_storage: { + txtype: type, + time: time(), + storagekey: bytesToHex(bytes.slice(5, 37)), + key: bytesToText(bytes.slice(37, 87)), + value: bytesToText(bytes.slice(87, 267)), + address: bytesToAddressHex(bytes.slice(267, 289)), + previous: bytesToHex(bytes.slice(289, 321)), + txfee: Number(readU64LE(bytes, 321)) + } + }; + } + if (type === 108) { + return signedStorageValue("unsigned_storagei8", 1); + } + if (type === 109) { + return signedStorageValue("unsigned_storagei16", 2); + } + if (type === 110) { + return signedStorageValue("unsigned_storagei32", 4); + } + if (type === 111) { + return signedStorageValue("unsigned_storagei64", 8); + } + if (type === 112) { + return signedStorageValue("unsigned_storagei128", 16); + } + if (type === 113) { + return { + unsigned_deletekey: { + txtype: type, + time: time(), + storagekey: bytesToHex(bytes.slice(5, 37)), + key: bytesToText(bytes.slice(37, 87)), + address: bytesToAddressHex(bytes.slice(87, 109)), + txfee: Number(readU64LE(bytes, 109)) + } + }; + } return { unknown: { txtype: type } }; } @@ -1146,10 +1314,12 @@ async function writeTransactionRecordFile(fileName, record, walletToken = active return; } - await invoke("write_local_transaction_record", { - fileName, - recordJson: JSON.stringify(record) - }); + const recordJson = JSON.stringify(record); + if (!fileName || typeof recordJson !== "string") { + throw new Error("Unable to write an empty local transaction record."); + } + + await invoke("write_local_transaction_record", { fileName, recordJson }); } async function mutateTransactionCache(mutator, walletToken = activeWalletCacheToken()) { @@ -1421,8 +1591,10 @@ function shouldContinueBackgroundTransactionSync(syncId, walletToken = activeWal ); } -function stopBackgroundTransactionSync() { - backgroundTransactionSyncId += 1; +async function stopBackgroundTransactionSync() { + const runningSync = backgroundTransactionSyncPromise; + backgroundTransactionSyncId += 1; + await runningSync; } function startBackgroundTransactionSync() { @@ -1432,7 +1604,11 @@ function startBackgroundTransactionSync() { const walletToken = activeWalletCacheToken(); const syncId = ++backgroundTransactionSyncId; - runBackgroundTransactionSync(syncId, 0, walletToken); + const currentSync = runBackgroundTransactionSync(syncId, 0, walletToken); + backgroundTransactionSyncPromise = Promise.allSettled([ + backgroundTransactionSyncPromise, + currentSync + ]).then(() => undefined); } async function runBackgroundTransactionSync(syncId, failureCount = 0, walletToken = activeWalletCacheToken()) { @@ -1519,8 +1695,7 @@ async function runBackgroundTransactionSync(syncId, failureCount = 0, walletToke const backoff = Math.min(BACKGROUND_SYNC_IDLE_MS * 2 ** failureCount, 60000); await sleepMs(backoff); if (shouldContinueBackgroundTransactionSync(syncId, walletToken)) { - runBackgroundTransactionSync(syncId, failureCount + 1, walletToken); - return; + return runBackgroundTransactionSync(syncId, failureCount + 1, walletToken); } } } diff --git a/frontend/js/wallet_session.js b/frontend/js/wallet_session.js index 7d60c7c..38eb248 100644 --- a/frontend/js/wallet_session.js +++ b/frontend/js/wallet_session.js @@ -71,6 +71,9 @@ function updateActiveWallet(wallet) { if (typeof resetAddressBookPage === "function") { resetAddressBookPage(); } + if (typeof resetAddressDataPage === "function") { + resetAddressDataPage(); + } if (typeof resetAssetsPage === "function") { resetAssetsPage(); } @@ -181,6 +184,9 @@ function clearActiveWallet() { if (typeof resetMarketingSession === "function") { resetMarketingSession(); } + if (typeof resetDataStorageSession === "function") { + resetDataStorageSession(); + } if (typeof resetVanityAddressSession === "function") { resetVanityAddressSession(); } @@ -190,6 +196,9 @@ function clearActiveWallet() { if (typeof resetAddressBookPage === "function") { resetAddressBookPage(); } + if (typeof resetAddressDataPage === "function") { + resetAddressDataPage(); + } if (typeof resetAssetsPage === "function") { resetAssetsPage(); } diff --git a/frontend/styles.css b/frontend/styles.css index c8a3ad4..8182cd3 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -226,6 +226,7 @@ body.wallet-open .startup-screen { display: flex; flex-direction: column; gap: 22px; + overflow-y: auto; } .brand { @@ -1546,7 +1547,7 @@ th { min-height: 552px; height: calc(100vh - 142px); display: grid; - grid-template-rows: auto minmax(0, 1fr) auto auto; + grid-template-rows: auto minmax(0, 1fr) auto auto auto; overflow: hidden; } @@ -1595,7 +1596,7 @@ th { } .marketing-workspace { - grid-template-rows: auto auto minmax(0, 1fr) auto auto; + grid-template-rows: auto auto minmax(0, 1fr) auto auto auto; } .marketing-summary-grid { @@ -1605,18 +1606,9 @@ th { margin: 14px 0 12px; } -.history-workspace.panel { - display: grid; - grid-template-rows: - auto /* header */ - auto /* summary */ - minmax(0,1fr) /* table */ - auto; /* footer */ -} - .marketing-table-wrap { - min-height: 0; - overflow: auto; + min-height: 0; + overflow: auto; } .marketing-summary-grid > div { @@ -2606,6 +2598,174 @@ th { text-align: right; } +.address-data-workspace { + min-height: calc(100vh - 100px); +} + +.address-data-header { + align-items: end; +} + +.address-data-actions { + display: grid; + grid-template-columns: minmax(280px, 420px) auto; + gap: 10px; +} + +.address-data-actions select { + width: 100%; + min-height: 42px; + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; + color: var(--text); +} + +.address-data-groups { + display: grid; + margin-top: 14px; +} + +.address-data-selection { + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); + gap: 20px; + align-items: start; + margin-top: 18px; + padding: 14px 0 12px; + border-top: 1px solid var(--line); +} + +.address-data-selected-id { + min-width: 0; + display: grid; + gap: 7px; +} + +.address-data-selected-id code { + overflow-wrap: anywhere; + color: var(--accent-soft); + font-size: 13px; + line-height: 1.45; +} + +.address-data-label-field { + display: grid; + grid-template-columns: 1fr auto; + gap: 7px 10px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.address-data-label-field input { + grid-column: 1 / -1; + min-height: 40px; + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: 8px; + background: #121b24; + color: var(--text); +} + +.address-data-label-field small { + min-height: 15px; + color: var(--success); + font-weight: 600; + text-align: right; +} + +.address-data-label-field small.error { + color: var(--danger); +} + +.address-data-group { + display: grid; + padding: 0; + border-top: 1px solid var(--line); +} + +.address-data-fields { + display: grid; +} + +.address-data-field { + display: grid; + grid-template-columns: minmax(180px, 0.8fr) minmax(240px, 2fr) minmax(90px, 0.45fr) 72px; + gap: 18px; + align-items: start; + padding: 13px 0; + border-top: 1px solid color-mix(in srgb, var(--line) 65%, transparent); +} + +.address-data-field-key { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.address-data-field-header { + background: var(--bg); + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.address-data-field-key strong, +.address-data-field-value, +.address-data-field-type { + overflow-wrap: anywhere; +} + +.address-data-field-value { + white-space: pre-wrap; + line-height: 1.45; +} + +.address-data-field-action { + display: flex; + justify-content: flex-start; +} + +.address-data-delete-button { + min-height: 30px; + padding: 4px 10px; + border: 1px solid rgba(239, 111, 111, 0.55); + border-radius: 6px; + background: rgba(130, 28, 28, 0.22); + color: #ff9a9a; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.address-data-delete-button:hover, +.address-data-delete-button:focus-visible { + background: rgba(164, 38, 38, 0.34); + border-color: #ff9a9a; +} + +.address-data-delete-button:disabled { + cursor: wait; + opacity: 0.55; +} + +@media (max-width: 1180px) and (min-width: 981px) { + .address-data-actions { + grid-template-columns: minmax(210px, 1fr) auto; + } + + .address-data-field { + grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1.5fr) minmax(76px, 0.4fr) 72px; + } + + .address-data-selection { + grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); + } +} + .addressbook-table td:nth-child(3) { max-width: 360px; } @@ -3040,6 +3200,7 @@ tr:hover td { min-height: auto; height: auto; max-height: none; + grid-template-rows: none; } .marketing-campaign-controls { @@ -3072,6 +3233,21 @@ tr:hover td { max-height: none; } + .address-data-header, + .address-data-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + width: 100%; + } + + .address-data-actions .secondary-button { + width: 100%; + } + + .address-data-selection { + grid-template-columns: minmax(0, 1fr); + } + .settings-workspace { min-height: auto; height: auto; diff --git a/src/main.rs b/src/main.rs index 38447c4..61c1f78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,46 +2,57 @@ use base64::engine::general_purpose::STANDARD; use base64::Engine; -use blockchain::blocks::burn::{BurnTransaction, UnsignedBurnTransaction}; -use blockchain::blocks::collateral::{ +use contractless::blocks::burn::{BurnTransaction, UnsignedBurnTransaction}; +use contractless::blocks::collateral::{ CollateralClaimTransaction, UnsignedCollateralClaimTransaction, }; -use blockchain::blocks::genesis::GenesisTransaction; -use blockchain::blocks::issue_token::{IssueTokenTransaction, UnsignedIssueTokenTransaction}; -use blockchain::blocks::loan_payment::{ +use contractless::blocks::delete_key::{DeleteKey, UnsignedDeleteKey}; +use contractless::blocks::genesis::GenesisTransaction; +use contractless::blocks::issue_token::{IssueTokenTransaction, UnsignedIssueTokenTransaction}; +use contractless::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 contractless::blocks::loans::{LoanContractTransaction, UnsignedLoanContractTransaction}; +use contractless::blocks::marketing::{MarketingTransaction, UnsignedMarketingTransaction}; +use contractless::blocks::nft::{CreateNftTransaction, UnsignedCreateNftTransaction}; +use contractless::blocks::rewards::RewardsTransaction; +use contractless::blocks::storage_key::{StorageKey, UnsignedStorageKey}; +use contractless::blocks::storage_values::{ + StorageBool, StorageI128, StorageI16, StorageI32, StorageI64, StorageI8, StorageString, + StorageU128, StorageU16, StorageU32, StorageU64, StorageU8, UnsignedStorageBool, + UnsignedStorageI128, UnsignedStorageI16, UnsignedStorageI32, UnsignedStorageI64, + UnsignedStorageI8, UnsignedStorageString, UnsignedStorageU128, UnsignedStorageU16, + UnsignedStorageU32, UnsignedStorageU64, UnsignedStorageU8, STORAGE_KEY_HASH_BYTES, + STORAGE_STRING_VALUE_BYTES, STORAGE_VALUE_KEY_BYTES, }; -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, +use contractless::blocks::swap::{SwapTransaction, UnsignedSwapTransaction}; +use contractless::blocks::token::{CreateTokenTransaction, UnsignedCreateTokenTransaction}; +use contractless::blocks::transfer::{TransferTransaction, UnsignedTransferTransaction}; +use contractless::blocks::vanity::{UnsignedVanityAddressTransaction, VanityAddressTransaction}; +use contractless::common::skein::skein_256_hash_bytes; +use contractless::common::types::{ + minimum_transfer_fee, BORROWER_FEE, BURN_FEE, COLLATERAL_FEE, CREATE_NFT_FEE, CREATE_TOKEN_FEE, + DELETE_KEY_TYPE, ISSUE_TOKEN_FEE, LENDER_FEE, MARKETING_FEE, STORAGE_KEY_FEE, + STORAGE_VALUE_FEE, SWAP_FEE, VANITY_ADDRESS_FEE, +}; +use contractless::records::memory::mempool::BASECOIN; +use contractless::records::memory::response_channels::generate_uid; +use contractless::rpc::command_maps::{ + RPC_ADDRESS_HISTORY, RPC_ADDRESS_TOTAL_BALANCE, RPC_BLOCK_HEIGHT, RPC_COLLATERAL_STATUS, + 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::{ +use contractless::standalone_tools::connections::handshake::{self, HandshakeWallet}; +use contractless::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::{ +use contractless::tokio::sync::{oneshot, Mutex as AsyncMutex}; +use contractless::tokio::time::sleep; +use contractless::wallets::structures::{SavedWallet, Wallet}; +use contractless::{ create_img, decode_image_and_extract_text, decrypts, encode, encrypts, to_string_pretty, }; use native_dialog::FileDialog; @@ -183,6 +194,8 @@ fn main() { clear_wallet_cache, read_address_book, write_address_book, + read_storage_labels, + write_storage_labels, lookup_address_vanity, check_vanity_address_availability, save_history_export, @@ -199,12 +212,16 @@ fn main() { broadcast_burn_transaction, save_marketing_transaction, broadcast_marketing_transaction, + save_data_storage_transaction, + broadcast_data_storage_transaction, + broadcast_delete_storage_transaction, save_vanity_address_transaction, broadcast_vanity_address_transaction, save_swap_offer, save_loan_offer, active_loan_contracts, loan_contracts, + collateral_status, save_loan_payment_transaction, broadcast_loan_payment_transaction, claimable_collateral_contracts, diff --git a/src/wallet_app/commands_network_cache.rs b/src/wallet_app/commands_network_cache.rs index 33ebda9..2bcdc1f 100644 --- a/src/wallet_app/commands_network_cache.rs +++ b/src/wallet_app/commands_network_cache.rs @@ -1,4 +1,4 @@ -#[tauri::command] +#[tauri::command] async fn current_block_height( state: State<'_, WalletState>, broadcast_node: String, @@ -109,7 +109,7 @@ async fn write_local_transaction_cache( 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) + contractless::tokio::fs::write(&cache_path, normalized) .await .map_err(|err| format!("Unable to write local transaction cache: {err}")) } @@ -140,7 +140,7 @@ async fn write_local_transaction_index( 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) + contractless::tokio::fs::write(history_dir.join("index.json"), normalized) .await .map_err(|err| format!("Unable to write local transaction index: {err}")) } @@ -176,7 +176,7 @@ async fn write_local_transaction_record( 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) + contractless::tokio::fs::write(history_dir.join(file_name), normalized) .await .map_err(|err| format!("Unable to write local transaction record: {err}")) } @@ -293,7 +293,7 @@ async fn write_nft_cache( .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) + contractless::tokio::fs::write(record_dir.join("image.bin"), bytes) .await .map_err(|err| format!("Unable to write NFT cached image: {err}"))?; image_cached = true; @@ -322,7 +322,7 @@ async fn write_nft_cache( .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) + contractless::tokio::fs::write(record_dir.join("image.bin"), &bytes) .await .map_err(|err| format!("Unable to write NFT cached image: {err}"))?; image_cached = true; @@ -338,7 +338,7 @@ async fn write_nft_cache( }); 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) + contractless::tokio::fs::write(record_dir.join("record.json"), normalized) .await .map_err(|err| format!("Unable to write NFT cache record: {err}"))?; @@ -373,7 +373,7 @@ async fn prune_nft_cache( } let key = entry.file_name().to_string_lossy().to_ascii_lowercase(); if !keep.contains(&key) { - blockchain::tokio::fs::remove_dir_all(entry.path()) + contractless::tokio::fs::remove_dir_all(entry.path()) .await .map_err(|err| format!("Unable to remove stale NFT cache entry: {err}"))?; } @@ -460,11 +460,40 @@ async fn write_address_book( 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) + contractless::tokio::fs::write(&address_book_path, normalized) .await .map_err(|err| format!("Unable to write address book: {err}")) } +#[tauri::command] +async fn read_storage_labels(state: State<'_, WalletState>) -> Result { + let labels_path = active_wallet_storage_labels_path(&state)?; + match read(&labels_path).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|_| "Storage labels file is not valid UTF-8.".to_string()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok("{\"labels\":{}}".to_string()) + } + Err(err) => Err(format!("Unable to read storage labels: {err}")), + } +} + +#[tauri::command] +async fn write_storage_labels( + state: State<'_, WalletState>, + storage_labels_json: String, +) -> Result<(), String> { + let labels_path = active_wallet_storage_labels_path(&state)?; + let parsed: JsonValue = serde_json::from_str(&storage_labels_json) + .map_err(|err| format!("Storage labels must be valid JSON: {err}"))?; + let normalized = serde_json::to_string_pretty(&parsed) + .map_err(|err| format!("Unable to serialize storage labels: {err}"))?; + + contractless::tokio::fs::write(&labels_path, normalized) + .await + .map_err(|err| format!("Unable to write storage labels: {err}")) +} + #[tauri::command] async fn lookup_address_vanity( state: State<'_, WalletState>, @@ -595,7 +624,7 @@ async fn save_history_export( path.set_extension(extension); } - blockchain::tokio::fs::write(&path, contents) + contractless::tokio::fs::write(&path, contents) .await .map_err(|err| format!("Unable to save history export: {err}"))?; diff --git a/src/wallet_app/commands_transactions.rs b/src/wallet_app/commands_transactions.rs index 28a2299..7997328 100644 --- a/src/wallet_app/commands_transactions.rs +++ b/src/wallet_app/commands_transactions.rs @@ -236,7 +236,7 @@ async fn transaction_by_txid_with_scheduler( ensure_gui_settings_file().await?; let normalized_txid = txid.trim().to_string(); - let txid_bytes = blockchain::decode(&normalized_txid) + let txid_bytes = contractless::decode(&normalized_txid) .map_err(|err| format!("Invalid transaction id: {err}"))?; if txid_bytes.len() != 32 { return Err("Transaction id must decode to 32 bytes.".to_string()); @@ -851,6 +851,19 @@ fn unbroadcast_type_name(txtype: u8) -> &'static str { 10 => "Burn", 11 => "Issue Token", 12 => "Vanity Address", + 100 => "Data Storage Key", + 101 => "Data Storage Bool", + 102 => "Data Storage U8", + 103 => "Data Storage U16", + 104 => "Data Storage U32", + 105 => "Data Storage U64", + 106 => "Data Storage U128", + 107 => "Data Storage String", + 108 => "Data Storage I8", + 109 => "Data Storage I16", + 110 => "Data Storage I32", + 111 => "Data Storage I64", + 112 => "Data Storage I128", _ => "Unknown", } } @@ -897,7 +910,7 @@ fn optional_string(transaction: &JsonValue, field: &str) -> String { } fn valid_signature(signature: &str) -> bool { - blockchain::decode(signature).is_ok_and(|bytes| bytes.len() == Wallet::SIGNATURE_LENGTH) + contractless::decode(signature).is_ok_and(|bytes| bytes.len() == Wallet::SIGNATURE_LENGTH) } fn parse_atomic_u64(value: &str, field: &str) -> Result { @@ -915,13 +928,13 @@ fn padded_coin_field(coin: &str) -> Result { if trimmed.eq_ignore_ascii_case(BASECOIN.trim()) { return Ok(BASECOIN.clone()); } - blockchain::common::asset_names::padded_normalized_asset_input(trimmed) + contractless::common::asset_names::padded_normalized_asset_input(trimmed) .ok_or_else(|| "Transfer asset must normalize to 3-15 alphanumeric characters.".to_string()) } fn padded_token_ticker(ticker: &str) -> Result { let trimmed = ticker.trim(); - blockchain::common::asset_names::padded_normalized_asset_input(trimmed) + contractless::common::asset_names::padded_normalized_asset_input(trimmed) .ok_or_else(|| "Token ticker must normalize to 3-15 alphanumeric characters.".to_string()) } @@ -1038,7 +1051,7 @@ async fn persist_active_wallet_vanity_address( let Ok(wallet_bytes) = read(&wallet_path).await else { return; }; - let Ok(mut saved_wallet) = blockchain::from_slice::(&wallet_bytes) else { + let Ok(mut saved_wallet) = contractless::from_slice::(&wallet_bytes) else { return; }; if saved_wallet.short_address.trim() != owner_address.trim() { @@ -1061,7 +1074,7 @@ async fn resolve_transaction_address( } } let canonical_vanity = - blockchain::standalone_tools::vanity_resolver::canonical_vanity_address_input(trimmed) + contractless::standalone_tools::vanity_resolver::canonical_vanity_address_input(trimmed) .ok_or_else(|| format!("{field} must be a valid short or vanity address."))?; let socket_address = active_broadcast_socket(broadcast_node)?; @@ -1156,7 +1169,7 @@ async fn build_transfer_json( )); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_transfer = UnsignedTransferTransaction::new( 2, timestamp, value, &coin, nft_series, &sender, &receiver, txfee, ) @@ -1207,7 +1220,7 @@ async fn build_create_token_json( } let hard_limit = if allow_more { 0 } else { 1 }; - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_create_token = UnsignedCreateTokenTransaction::new( 3, timestamp, &creator, &ticker, number, hard_limit, txfee, ) @@ -1258,7 +1271,7 @@ async fn build_create_nft_json( } let item_ipfs = item_ipfs.trim(); - if blockchain::Cid::try_from(item_ipfs).is_err() { + if contractless::Cid::try_from(item_ipfs).is_err() { return Err("Enter a valid IPFS CID.".to_string()); } let item_ipfs = padded_fixed_field(item_ipfs, 100, "IPFS CID")?; @@ -1271,7 +1284,7 @@ async fn build_create_nft_json( )); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_create_nft = UnsignedCreateNftTransaction::new( 4, timestamp, @@ -1334,7 +1347,7 @@ async fn build_burn_json( )); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_burn = UnsignedBurnTransaction::new(10, timestamp, &address, &coin, nft_series, value, txfee) .await; @@ -1382,7 +1395,7 @@ async fn build_marketing_json( )); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_marketing = UnsignedMarketingTransaction::new( 5, timestamp, @@ -1436,7 +1449,7 @@ async fn build_vanity_address_json( )); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_vanity = UnsignedVanityAddressTransaction::new( 12, timestamp, @@ -1461,6 +1474,464 @@ async fn build_vanity_address_json( })) } +fn validate_storage_key_hash(storage_key: &str) -> Result { + let trimmed = storage_key.trim().to_ascii_lowercase(); + let bytes = contractless::decode(&trimmed) + .map_err(|err| format!("Invalid storage key hash: {err}"))?; + if bytes.len() != STORAGE_KEY_HASH_BYTES { + return Err("Storage key hash must be a 32-byte hexadecimal value.".to_string()); + } + Ok(trimmed) +} + +fn validate_storage_previous_hash(previous: &str) -> Result { + let trimmed = previous.trim(); + if trimmed == "0" || trimmed.is_empty() { + return Ok("0".repeat(STORAGE_KEY_HASH_BYTES * 2)); + } + validate_storage_key_hash(trimmed) +} + +fn padded_storage_field_key(key: &str) -> Result { + padded_fixed_field(key, STORAGE_VALUE_KEY_BYTES, "Storage field key") +} + +fn padded_storage_string_value(value: &str) -> Result { + let trimmed = value.trim(); + if !trimmed.is_ascii() { + return Err("Storage string value must contain ASCII characters only.".to_string()); + } + if trimmed.len() > STORAGE_STRING_VALUE_BYTES { + return Err(format!( + "Storage string value cannot exceed {STORAGE_STRING_VALUE_BYTES} characters." + )); + } + Ok(format!( + "{trimmed: Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(true), + "false" | "0" | "no" => Ok(false), + _ => Err("Storage bool value must be true or false.".to_string()), + } +} + +fn parse_storage_u8(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| "Storage u8 value must be between 0 and 255.".to_string()) +} + +fn parse_storage_u16(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| "Storage u16 value must be between 0 and 65535.".to_string()) +} + +fn parse_storage_u32(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| "Storage u32 value must be between 0 and 4294967295.".to_string()) +} + +fn parse_storage_u128(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| "Storage u128 value must be an unsigned 128-bit value.".to_string()) +} + +fn parse_storage_i8(value: &str) -> Result { + value.trim().parse::().map_err(|_| { + "Storage i8 value must be between -128 and 127.".to_string() + }) +} + +fn parse_storage_i16(value: &str) -> Result { + value.trim().parse::().map_err(|_| { + "Storage i16 value must be between -32768 and 32767.".to_string() + }) +} + +fn parse_storage_i32(value: &str) -> Result { + value.trim().parse::().map_err(|_| { + "Storage i32 value must be between -2147483648 and 2147483647.".to_string() + }) +} + +fn parse_storage_i64(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| "Storage i64 value must be a signed 64-bit value.".to_string()) +} + +fn parse_storage_i128(value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| "Storage i128 value must be a signed 128-bit value.".to_string()) +} + +async fn build_data_storage_key_json( + state: &State<'_, WalletState>, + wallet_key: Option, + txfee: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let txfee = parse_atomic_u64(&txfee, "Storage key fee")?; + if txfee < STORAGE_KEY_FEE { + return Err(format!( + "Storage key transaction fee is below the minimum required fee of {STORAGE_KEY_FEE}." + )); + } + + let timestamp = contractless::Utc::now().timestamp() as u32; + let unsigned_storagekey = UnsignedStorageKey::new(100, timestamp, &address, txfee).await; + let storage_key = StorageKey::new(unsigned_storagekey.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage key transaction: {err}"))?; + let hash = unsigned_storagekey.hash().await; + + Ok(serde_json::json!({ + "txtype": 100, + "timestamp": timestamp, + "address": address, + "txfee": txfee, + "hash": hash, + "unsigned_storagekey": { + "txtype": 100, + "time": timestamp, + "address": address, + "txfee": txfee, + }, + "signature": storage_key.signature, + })) +} + +#[allow(clippy::too_many_arguments)] +async fn build_data_storage_value_json( + state: &State<'_, WalletState>, + wallet_key: Option, + value_type: String, + storage_key: String, + field_key: String, + value: String, + previous_hash: String, + txfee: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(state, wallet_key).await?; + let storage_key = validate_storage_key_hash(&storage_key)?; + let field_key = padded_storage_field_key(&field_key)?; + let txfee = parse_atomic_u64(&txfee, "Storage value fee")?; + if txfee < STORAGE_VALUE_FEE { + return Err(format!( + "Storage value transaction fee is below the minimum required fee of {STORAGE_VALUE_FEE}." + )); + } + + let timestamp = contractless::Utc::now().timestamp() as u32; + macro_rules! build_signed_storage { + ($value:expr, $unsigned:ident, $signed:ident, $field:literal, $txtype:literal, $label:literal) => {{ + let unsigned = $unsigned { + txtype: $txtype, + time: timestamp, + storagekey: storage_key, + key: field_key, + value: $value, + address, + txfee, + }; + let tx = $signed::new(unsigned.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage {} transaction: {err}", $label))?; + let hash = unsigned.hash().await; + Ok(serde_json::json!({ + "txtype": $txtype, + "timestamp": timestamp, + "storagekey": &unsigned.storagekey, + "key": &unsigned.key, + "value": unsigned.value.to_string(), + "address": &unsigned.address, + "txfee": txfee, + "hash": hash, + $field: { + "txtype": $txtype, + "time": timestamp, + "storagekey": &unsigned.storagekey, + "key": &unsigned.key, + "value": unsigned.value.to_string(), + "address": &unsigned.address, + "txfee": txfee, + }, + "signature": tx.signature, + })) + }}; + } + match value_type.trim().to_ascii_lowercase().as_str() { + "bool" => { + let value = parse_storage_bool(&value)?; + let unsigned_storagebool = UnsignedStorageBool { + txtype: 101, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + txfee, + }; + let tx = StorageBool::new(unsigned_storagebool.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage bool transaction: {err}"))?; + let hash = unsigned_storagebool.hash().await; + Ok(serde_json::json!({ + "txtype": 101, + "timestamp": timestamp, + "storagekey": &unsigned_storagebool.storagekey, + "key": &unsigned_storagebool.key, + "value": value, + "address": &unsigned_storagebool.address, + "txfee": txfee, + "hash": hash, + "unsigned_storagebool": unsigned_storagebool.clone(), + "signature": tx.signature, + })) + } + "u8" | "8bit" => { + let value = parse_storage_u8(&value)?; + let unsigned_storageu8 = UnsignedStorageU8 { + txtype: 102, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + txfee, + }; + let tx = StorageU8::new(unsigned_storageu8.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage u8 transaction: {err}"))?; + let hash = unsigned_storageu8.hash().await; + Ok(serde_json::json!({ + "txtype": 102, + "timestamp": timestamp, + "storagekey": &unsigned_storageu8.storagekey, + "key": &unsigned_storageu8.key, + "value": value, + "address": &unsigned_storageu8.address, + "txfee": txfee, + "hash": hash, + "unsigned_storageu8": unsigned_storageu8.clone(), + "signature": tx.signature, + })) + } + "u16" | "16bit" => { + let value = parse_storage_u16(&value)?; + let unsigned_storageu16 = UnsignedStorageU16 { + txtype: 103, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + txfee, + }; + let tx = StorageU16::new(unsigned_storageu16.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage u16 transaction: {err}"))?; + let hash = unsigned_storageu16.hash().await; + Ok(serde_json::json!({ + "txtype": 103, + "timestamp": timestamp, + "storagekey": &unsigned_storageu16.storagekey, + "key": &unsigned_storageu16.key, + "value": value, + "address": &unsigned_storageu16.address, + "txfee": txfee, + "hash": hash, + "unsigned_storageu16": unsigned_storageu16.clone(), + "signature": tx.signature, + })) + } + "u32" | "32bit" => { + let value = parse_storage_u32(&value)?; + let unsigned_storageu32 = UnsignedStorageU32 { + txtype: 104, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + txfee, + }; + let tx = StorageU32::new(unsigned_storageu32.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage u32 transaction: {err}"))?; + let hash = unsigned_storageu32.hash().await; + Ok(serde_json::json!({ + "txtype": 104, + "timestamp": timestamp, + "storagekey": &unsigned_storageu32.storagekey, + "key": &unsigned_storageu32.key, + "value": value, + "address": &unsigned_storageu32.address, + "txfee": txfee, + "hash": hash, + "unsigned_storageu32": unsigned_storageu32.clone(), + "signature": tx.signature, + })) + } + "u64" | "64bit" => { + let value = parse_atomic_u64(&value, "Storage u64 value")?; + let unsigned_storageu64 = UnsignedStorageU64 { + txtype: 105, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + txfee, + }; + let tx = StorageU64::new(unsigned_storageu64.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage u64 transaction: {err}"))?; + let hash = unsigned_storageu64.hash().await; + Ok(serde_json::json!({ + "txtype": 105, + "timestamp": timestamp, + "storagekey": &unsigned_storageu64.storagekey, + "key": &unsigned_storageu64.key, + "value": value, + "address": &unsigned_storageu64.address, + "txfee": txfee, + "hash": hash, + "unsigned_storageu64": unsigned_storageu64.clone(), + "signature": tx.signature, + })) + } + "u128" | "128bit" => { + let value = parse_storage_u128(&value)?; + let unsigned_storageu128 = UnsignedStorageU128 { + txtype: 106, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + txfee, + }; + let tx = StorageU128::new(unsigned_storageu128.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage u128 transaction: {err}"))?; + let hash = unsigned_storageu128.hash().await; + Ok(serde_json::json!({ + "txtype": 106, + "timestamp": timestamp, + "storagekey": &unsigned_storageu128.storagekey, + "key": &unsigned_storageu128.key, + "value": value.to_string(), + "address": &unsigned_storageu128.address, + "txfee": txfee, + "hash": hash, + "unsigned_storageu128": unsigned_storageu128.clone(), + "signature": tx.signature, + })) + } + "string" => { + let value = padded_storage_string_value(&value)?; + let previous = validate_storage_previous_hash(&previous_hash)?; + let unsigned_storage = UnsignedStorageString { + txtype: 107, + time: timestamp, + storagekey: storage_key, + key: field_key, + value, + address, + previous, + txfee, + }; + let tx = StorageString::new(unsigned_storage.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage string transaction: {err}"))?; + let hash = unsigned_storage.hash().await; + Ok(serde_json::json!({ + "txtype": 107, + "timestamp": timestamp, + "storagekey": &unsigned_storage.storagekey, + "key": &unsigned_storage.key, + "value": &unsigned_storage.value, + "address": &unsigned_storage.address, + "previous": &unsigned_storage.previous, + "txfee": txfee, + "hash": hash, + "unsigned_storage": unsigned_storage.clone(), + "signature": tx.signature, + })) + } + "i8" | "signed8" => build_signed_storage!( + parse_storage_i8(&value)?, UnsignedStorageI8, StorageI8, + "unsigned_storagei8", 108, "i8" + ), + "i16" | "signed16" => build_signed_storage!( + parse_storage_i16(&value)?, UnsignedStorageI16, StorageI16, + "unsigned_storagei16", 109, "i16" + ), + "i32" | "signed32" => build_signed_storage!( + parse_storage_i32(&value)?, UnsignedStorageI32, StorageI32, + "unsigned_storagei32", 110, "i32" + ), + "i64" | "signed64" => build_signed_storage!( + parse_storage_i64(&value)?, UnsignedStorageI64, StorageI64, + "unsigned_storagei64", 111, "i64" + ), + "i128" | "signed128" => build_signed_storage!( + parse_storage_i128(&value)?, UnsignedStorageI128, StorageI128, + "unsigned_storagei128", 112, "i128" + ), + _ => Err("Storage value type must be bool, u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, or string.".to_string()), + } +} + +#[allow(clippy::too_many_arguments)] +async fn build_data_storage_json( + state: &State<'_, WalletState>, + wallet_key: Option, + mode: String, + value_type: String, + storage_key: String, + field_key: String, + value: String, + previous_hash: String, + txfee: String, +) -> Result { + match mode.trim().to_ascii_lowercase().as_str() { + "create_key" | "create_storage" | "key" => { + build_data_storage_key_json(state, wallet_key, txfee).await + } + "write_value" | "value" | "write" => { + build_data_storage_value_json( + state, + wallet_key, + value_type, + storage_key, + field_key, + value, + previous_hash, + txfee, + ) + .await + } + _ => Err("Data storage mode must be create_key or write_value.".to_string()), + } +} + #[allow(clippy::too_many_arguments)] async fn build_swap_offer_json( state: &State<'_, WalletState>, @@ -1497,7 +1968,7 @@ async fn build_swap_offer_json( return Err("Swap expiration must be between 1 and 720 hours.".to_string()); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let expiration_seconds = expiration_hours .checked_mul(60 * 60) .ok_or_else(|| "Swap expiration overflowed.".to_string())?; @@ -1576,7 +2047,7 @@ async fn build_loan_offer_json( let loan_coin = padded_coin_field(&loan_coin)?; let collateral = - blockchain::common::asset_names::padded_loan_collateral_input_or_base( + contractless::common::asset_names::padded_loan_collateral_input_or_base( &collateral, &BASECOIN, ) @@ -1861,11 +2332,11 @@ fn collateral_claim_reason( })); } - let payments_due = blockchain::common::loan_schedule::overdue_payment_count( + let payments_due = contractless::common::loan_schedule::overdue_payment_count( contract.start_timestamp, compact_payment_period(&contract.payment_period)?, contract.payment_number, - blockchain::Utc::now().timestamp() as u32, + contractless::Utc::now().timestamp() as u32, )?; if payments_due <= contract.grace_period as usize { return Ok(None); @@ -1982,7 +2453,7 @@ async fn build_loan_payment_json( return Err("Payment exceeds the remaining balance after pending payments.".to_string()); } let tip = payback_amount.div_ceil(100); - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_payment = UnsignedContractPaymentTransaction::new( 8, timestamp, @@ -2023,7 +2494,7 @@ async fn build_collateral_claim_json( return Err("This loan is not currently eligible for a collateral claim.".to_string()); }; - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_claim = UnsignedCollateralClaimTransaction::new( 9, timestamp, @@ -2079,7 +2550,7 @@ async fn build_issue_token_json( )); } - let timestamp = blockchain::Utc::now().timestamp() as u32; + let timestamp = contractless::Utc::now().timestamp() as u32; let unsigned_issue_token = UnsignedIssueTokenTransaction::new(11, timestamp, &creator, &ticker, number, txfee).await; let issue_token = IssueTokenTransaction::new(unsigned_issue_token.clone(), &private_key) @@ -2388,7 +2859,7 @@ async fn validate_imported_transaction( } 6 => { let unsigned = rebuild_swap(transaction).await?; - let now = blockchain::Utc::now().timestamp() as u32; + let now = contractless::Utc::now().timestamp() as u32; if unsigned.offer_expiration < unsigned.timestamp || unsigned.offer_expiration > unsigned.timestamp.saturating_add(30 * 24 * 60 * 60) { @@ -2422,7 +2893,7 @@ async fn validate_imported_transaction( if unsigned.loan_coin.len() != 15 { return Err("Loan asset must use its canonical 15-byte field.".to_string()); } - if !blockchain::common::asset_names::is_canonical_loan_collateral_or_base( + if !contractless::common::asset_names::is_canonical_loan_collateral_or_base( &unsigned.collateral, &BASECOIN, ) { @@ -2518,7 +2989,7 @@ async fn validate_imported_transaction( 10 => { let unsigned = rebuild_burn(transaction).await?; if unsigned.coin.len() != 15 - || !blockchain::common::asset_names::is_canonical_padded_asset_identifier( + || !contractless::common::asset_names::is_canonical_padded_asset_identifier( &unsigned.coin, ) { @@ -2577,9 +3048,141 @@ async fn validate_imported_transaction( )], ) } + 100 => { + let unsigned = rebuild_storage_key(transaction).await?; + if unsigned.txfee < STORAGE_KEY_FEE { + return Err(format!( + "Storage key transaction fee is below the minimum required fee of {STORAGE_KEY_FEE}." + )); + } + if Wallet::short_address_to_bytes(&unsigned.address).is_none() { + return Err("Storage key creator address is invalid.".to_string()); + } + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-key signature", + )], + ) + } + 101 => { + let unsigned = rebuild_storage_bool(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-bool signature", + )], + ) + } + 102 => { + let unsigned = rebuild_storage_u8(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-u8 signature", + )], + ) + } + 103 => { + let unsigned = rebuild_storage_u16(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-u16 signature", + )], + ) + } + 104 => { + let unsigned = rebuild_storage_u32(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-u32 signature", + )], + ) + } + 105 => { + let unsigned = rebuild_storage_u64(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-u64 signature", + )], + ) + } + 106 => { + let unsigned = rebuild_storage_u128(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-u128 signature", + )], + ) + } + 107 => { + let unsigned = rebuild_storage_string(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + validate_storage_previous_hash(&unsigned.previous)?; + if unsigned.value.as_bytes().len() != STORAGE_STRING_VALUE_BYTES { + return Err("Storage string value must use its 180-byte field.".to_string()); + } + ( + unsigned.hash().await, + vec![( + unsigned.address.clone(), + json_str(transaction, "signature")?, + "Storage-string signature", + )], + ) + } + 108 => { + let unsigned = rebuild_storage_i8(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + (unsigned.hash().await, vec![(unsigned.address.clone(), json_str(transaction, "signature")?, "Storage-i8 signature")]) + } + 109 => { + let unsigned = rebuild_storage_i16(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + (unsigned.hash().await, vec![(unsigned.address.clone(), json_str(transaction, "signature")?, "Storage-i16 signature")]) + } + 110 => { + let unsigned = rebuild_storage_i32(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + (unsigned.hash().await, vec![(unsigned.address.clone(), json_str(transaction, "signature")?, "Storage-i32 signature")]) + } + 111 => { + let unsigned = rebuild_storage_i64(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + (unsigned.hash().await, vec![(unsigned.address.clone(), json_str(transaction, "signature")?, "Storage-i64 signature")]) + } + 112 => { + let unsigned = rebuild_storage_i128(transaction).await?; + validate_storage_value_common(&unsigned.storagekey, &unsigned.key, &unsigned.address, unsigned.txfee)?; + (unsigned.hash().await, vec![(unsigned.address.clone(), json_str(transaction, "signature")?, "Storage-i128 signature")]) + } _ => { return Err( - "This wallet currently imports transfer, create-token, create-NFT, marketing, swap, loan, loan-payment, collateral-claim, burn, issue-token, and vanity-address transactions only." + "This wallet currently imports transfer, create-token, create-NFT, marketing, swap, loan, loan-payment, collateral-claim, burn, issue-token, vanity-address, and data-storage transactions only." .to_string(), ); } @@ -2589,7 +3192,7 @@ async fn validate_imported_transaction( if supplied_hash != computed_hash { return Err("Transaction hash does not match its fields.".to_string()); } - if !blockchain::decode(&supplied_hash).is_ok_and(|bytes| bytes.len() == 32) { + if !contractless::decode(&supplied_hash).is_ok_and(|bytes| bytes.len() == 32) { return Err("Transaction hash must be a 32-byte hexadecimal value.".to_string()); } @@ -2715,6 +3318,206 @@ async fn rebuild_vanity_address( .pipe(Ok) } +fn storage_payload<'a>(transaction: &'a JsonValue, field: &str) -> &'a JsonValue { + transaction.get(field).unwrap_or(transaction) +} + +fn payload_str(payload: &JsonValue, field: &str) -> Result { + payload + .get(field) + .and_then(|value| value.as_str()) + .map(str::to_string) + .ok_or_else(|| format!("Missing or invalid {field}.")) +} + +fn payload_u64(payload: &JsonValue, field: &str) -> Result { + payload + .get(field) + .and_then(|value| value.as_u64()) + .ok_or_else(|| format!("Missing or invalid {field}.")) +} + +fn payload_i128(payload: &JsonValue, field: &str) -> Result { + if let Some(value) = payload.get(field).and_then(|value| value.as_i64()) { + return Ok(value as i128); + } + payload + .get(field) + .and_then(|value| value.as_str()) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| format!("Missing or invalid {field}.")) +} + +fn payload_u32(payload: &JsonValue, primary: &str, fallback: &str) -> Result { + payload + .get(primary) + .and_then(|value| value.as_u64()) + .or_else(|| payload.get(fallback).and_then(|value| value.as_u64())) + .map(|value| value as u32) + .ok_or_else(|| format!("Missing or invalid {primary}.")) +} + +fn validate_storage_value_common( + storage_key: &str, + key: &str, + address: &str, + txfee: u64, +) -> Result<(), String> { + validate_storage_key_hash(storage_key)?; + if key.len() != STORAGE_VALUE_KEY_BYTES { + return Err("Storage field key must use its 50-byte field.".to_string()); + } + if Wallet::short_address_to_bytes(address).is_none() { + return Err("Storage value writer address is invalid.".to_string()); + } + if txfee < STORAGE_VALUE_FEE { + return Err(format!( + "Storage value transaction fee is below the minimum required fee of {STORAGE_VALUE_FEE}." + )); + } + Ok(()) +} + +async fn rebuild_storage_key(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storagekey"); + UnsignedStorageKey::new( + 100, + payload_u32(payload, "timestamp", "time")?, + &payload_str(payload, "address")?, + payload_u64(payload, "txfee")?, + ) + .await + .pipe(Ok) +} + +async fn rebuild_storage_bool(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storagebool"); + Ok(UnsignedStorageBool { + txtype: 101, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: payload + .get("value") + .and_then(|value| value.as_bool()) + .ok_or_else(|| "Missing or invalid storage bool value.".to_string())?, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + +async fn rebuild_storage_u8(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storageu8"); + Ok(UnsignedStorageU8 { + txtype: 102, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: payload_u64(payload, "value")? as u8, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + +async fn rebuild_storage_u16(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storageu16"); + Ok(UnsignedStorageU16 { + txtype: 103, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: payload_u64(payload, "value")? as u16, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + +async fn rebuild_storage_u32(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storageu32"); + Ok(UnsignedStorageU32 { + txtype: 104, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: payload_u64(payload, "value")? as u32, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + +async fn rebuild_storage_u64(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storageu64"); + Ok(UnsignedStorageU64 { + txtype: 105, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: payload_u64(payload, "value")?, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + +async fn rebuild_storage_u128(transaction: &JsonValue) -> Result { + let payload = storage_payload(transaction, "unsigned_storageu128"); + Ok(UnsignedStorageU128 { + txtype: 106, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: if let Some(value) = payload.get("value").and_then(|value| value.as_u64()) { + value as u128 + } else { + payload_str(payload, "value")? + .trim() + .parse::() + .map_err(|_| "Missing or invalid storage u128 value.".to_string())? + }, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + +macro_rules! rebuild_signed_storage { + ($fn_name:ident, $unsigned:ident, $field:literal, $txtype:literal, $value_ty:ty) => { + async fn $fn_name(transaction: &JsonValue) -> Result<$unsigned, String> { + let payload = storage_payload(transaction, $field); + Ok($unsigned { + txtype: $txtype, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: <$value_ty>::try_from(payload_i128(payload, "value")?) + .map_err(|_| concat!("Storage ", stringify!($value_ty), " value is out of range.").to_string())?, + address: payload_str(payload, "address")?, + txfee: payload_u64(payload, "txfee")?, + }) + } + }; +} + +rebuild_signed_storage!(rebuild_storage_i8, UnsignedStorageI8, "unsigned_storagei8", 108, i8); +rebuild_signed_storage!(rebuild_storage_i16, UnsignedStorageI16, "unsigned_storagei16", 109, i16); +rebuild_signed_storage!(rebuild_storage_i32, UnsignedStorageI32, "unsigned_storagei32", 110, i32); +rebuild_signed_storage!(rebuild_storage_i64, UnsignedStorageI64, "unsigned_storagei64", 111, i64); +rebuild_signed_storage!(rebuild_storage_i128, UnsignedStorageI128, "unsigned_storagei128", 112, i128); + +async fn rebuild_storage_string( + transaction: &JsonValue, +) -> Result { + let payload = storage_payload(transaction, "unsigned_storage"); + Ok(UnsignedStorageString { + txtype: 107, + time: payload_u32(payload, "timestamp", "time")?, + storagekey: payload_str(payload, "storagekey")?, + key: payload_str(payload, "key")?, + value: payload_str(payload, "value")?, + address: payload_str(payload, "address")?, + previous: payload_str(payload, "previous")?, + txfee: payload_u64(payload, "txfee")?, + }) +} + trait Pipe: Sized { fn pipe(self, f: impl FnOnce(Self) -> T) -> T { f(self) @@ -2963,7 +3766,7 @@ async fn save_json_unbroadcast_transaction( let path = dir.join(&file_name); let output = serde_json::to_string_pretty(&transaction) .map_err(|err| format!("Failed to serialize transaction: {err}"))?; - blockchain::tokio::fs::write(&path, output) + contractless::tokio::fs::write(&path, output) .await .map_err(|err| format!("Failed to save transaction: {err}"))?; @@ -3021,7 +3824,7 @@ async fn import_unbroadcast_transaction( let destination_canonical = std::fs::canonicalize(&destination_path) .map_err(|err| format!("Unable to resolve existing transaction path: {err}"))?; if source_canonical != destination_canonical { - blockchain::tokio::fs::remove_file(&source_path) + contractless::tokio::fs::remove_file(&source_path) .await .map_err(|err| { format!( @@ -3030,17 +3833,17 @@ async fn import_unbroadcast_transaction( })?; } } else if let Err(rename_error) = - blockchain::tokio::fs::rename(&source_path, &destination_path).await + contractless::tokio::fs::rename(&source_path, &destination_path).await { - blockchain::tokio::fs::copy(&source_path, &destination_path) + contractless::tokio::fs::copy(&source_path, &destination_path) .await .map_err(|copy_error| { format!( "Unable to move imported transaction ({rename_error}); copy fallback also failed: {copy_error}" ) })?; - if let Err(remove_error) = blockchain::tokio::fs::remove_file(&source_path).await { - let _ = blockchain::tokio::fs::remove_file(&destination_path).await; + if let Err(remove_error) = contractless::tokio::fs::remove_file(&source_path).await { + let _ = contractless::tokio::fs::remove_file(&destination_path).await; return Err(format!( "Transaction copy succeeded, but its source could not be removed: {remove_error}" )); @@ -3403,6 +4206,157 @@ async fn broadcast_marketing_transaction( }) } +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn save_data_storage_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + mode: String, + value_type: String, + storage_key: String, + field_key: String, + value: String, + previous_hash: String, + txfee: String, +) -> Result { + let active_short_address = active_wallet_rpc_parts(&state)?.0; + let transaction = build_data_storage_json( + &state, + wallet_key, + mode, + value_type, + storage_key, + field_key, + value, + previous_hash, + txfee, + ) + .await?; + save_json_unbroadcast_transaction(transaction, &active_short_address).await +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn broadcast_data_storage_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + mode: String, + value_type: String, + storage_key: String, + field_key: String, + value: String, + previous_hash: String, + txfee: String, +) -> Result { + let transaction = build_data_storage_json( + &state, + wallet_key, + mode, + value_type, + storage_key, + field_key, + value, + previous_hash, + txfee, + ) + .await?; + let txid = transaction + .get("hash") + .and_then(|value| value.as_str()) + .map(str::to_string); + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction) + .map_err(|err| format!("Failed to serialize data storage transaction: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Data storage transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid, + }) +} + +#[tauri::command] +async fn broadcast_delete_storage_transaction( + state: State<'_, WalletState>, + wallet_key: Option, + broadcast_node: String, + storage_key: String, + field_key: String, + txfee: String, +) -> Result { + let (address, private_key) = active_wallet_signing_parts(&state, wallet_key).await?; + let storage_key = validate_storage_key_hash(&storage_key)?; + let field_key = padded_storage_field_key(&field_key)?; + let txfee = parse_atomic_u64(&txfee, "Storage delete fee")?; + if txfee < STORAGE_VALUE_FEE { + return Err(format!( + "Storage delete transaction fee is below the minimum required fee of {STORAGE_VALUE_FEE}." + )); + } + + let timestamp = contractless::Utc::now().timestamp() as u32; + let unsigned_deletekey = UnsignedDeleteKey { + txtype: DELETE_KEY_TYPE, + time: timestamp, + storagekey: storage_key, + key: field_key, + address, + txfee, + }; + let transaction = DeleteKey::new(unsigned_deletekey.clone(), &private_key) + .await + .map_err(|err| format!("Failed to sign storage delete transaction: {err}"))?; + let hash = unsigned_deletekey.hash().await; + let transaction_json = serde_json::json!({ + "txtype": DELETE_KEY_TYPE, + "timestamp": timestamp, + "storagekey": &unsigned_deletekey.storagekey, + "key": &unsigned_deletekey.key, + "address": &unsigned_deletekey.address, + "txfee": txfee, + "hash": &hash, + "unsigned_deletekey": unsigned_deletekey, + "signature": transaction.signature, + }); + + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let command_input = serde_json::to_string(&transaction_json) + .map_err(|err| format!("Failed to serialize storage delete transaction: {err}"))?; + let response = serialized_wallet_rpc( + &state, + socket_address, + command_input, + 8, + public_key, + private_key, + "Storage delete transaction broadcast failed", + ) + .await?; + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + + Ok(BroadcastUnbroadcastResult { + response: String::from_utf8_lossy(&response).trim().to_string(), + txid: Some(hash), + }) +} + #[tauri::command] async fn save_vanity_address_transaction( state: State<'_, WalletState>, @@ -3557,6 +4511,40 @@ async fn loan_contracts( load_loan_contracts_for_active_wallet(&state, &broadcast_node).await } +#[tauri::command] +async fn collateral_status( + state: State<'_, WalletState>, + broadcast_node: String, + contract_hash: String, +) -> Result { + let socket_address = active_broadcast_socket(&broadcast_node)?; + let (_active_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; + let contract_hash = contract_hash.trim(); + if contract_hash.len() != 64 || !contract_hash.chars().all(|character| character.is_ascii_hexdigit()) { + return Err("Contract hash must be a 32-byte hex string.".to_string()); + } + + let response = serialized_wallet_rpc( + &state, + socket_address, + contract_hash.to_string(), + RPC_COLLATERAL_STATUS as usize, + public_key, + private_key, + "Collateral status lookup failed", + ) + .await?; + + if let Some(error) = parse_error_response(&response) { + return Err(error); + } + match response.as_slice() { + [0] => Ok(CollateralStatusView { claimed: false }), + [1] => Ok(CollateralStatusView { claimed: true }), + _ => Err("Collateral status response was invalid.".to_string()), + } +} + #[tauri::command] async fn save_loan_payment_transaction( state: State<'_, WalletState>, @@ -3893,7 +4881,7 @@ async fn sign_unbroadcast_transaction( let output = serde_json::to_string_pretty(&transaction) .map_err(|err| format!("Failed to serialize signed transaction: {err}"))?; - blockchain::tokio::fs::write(&path, output) + contractless::tokio::fs::write(&path, output) .await .map_err(|err| format!("Failed to save signed transaction: {err}"))?; @@ -3953,7 +4941,7 @@ async fn broadcast_unbroadcast_transaction( let response_text = String::from_utf8_lossy(&response).trim().to_string(); let dir = unbroadcast_dir().await?; let path = dir.join(validate_unbroadcast_file_name(&file_name)?); - blockchain::tokio::fs::remove_file(&path) + contractless::tokio::fs::remove_file(&path) .await .map_err(|err| { format!("Transaction broadcast completed, but local copy could not be deleted: {err}") @@ -3970,7 +4958,7 @@ async fn delete_unbroadcast_transaction(file_name: String) -> Result<(), String> let file_name = validate_unbroadcast_file_name(&file_name)?; let dir = unbroadcast_dir().await?; let path = dir.join(file_name); - blockchain::tokio::fs::remove_file(&path) + contractless::tokio::fs::remove_file(&path) .await .map_err(|err| format!("Unable to delete unbroadcast transaction: {err}")) } diff --git a/src/wallet_app/commands_wallet.rs b/src/wallet_app/commands_wallet.rs index 7df3fcb..2c2ce08 100644 --- a/src/wallet_app/commands_wallet.rs +++ b/src/wallet_app/commands_wallet.rs @@ -1,4 +1,4 @@ -#[tauri::command] +#[tauri::command] fn get_app_status() -> AppStatus { AppStatus { app_name: "Contractless Wallet", @@ -342,7 +342,7 @@ async fn backup_active_wallet_image(state: State<'_, WalletState>) -> Result Result<(), String> { 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) + contractless::tokio::fs::write(&settings_path, updated) .await .map_err(|err| format!("Unable to write GUI settings.ini: {err}")) } diff --git a/src/wallet_app/shared_rpc.rs b/src/wallet_app/shared_rpc.rs index 928b5eb..43c486e 100644 --- a/src/wallet_app/shared_rpc.rs +++ b/src/wallet_app/shared_rpc.rs @@ -153,6 +153,23 @@ fn active_wallet_address_book_path(state: &State<'_, WalletState>) -> Result) -> Result { + 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 file_name = format!("{}.storage_labels.json", encode(short_address_bytes)); + + Ok(wallet_path.with_file_name(file_name)) +} + fn validate_transaction_record_file(file_name: &str) -> Result { let trimmed = file_name.trim(); if trimmed.len() != 13 || !trimmed.ends_with(".json") { diff --git a/src/wallet_app/transaction_decode.rs b/src/wallet_app/transaction_decode.rs index 667415a..65469c6 100644 --- a/src/wallet_app/transaction_decode.rs +++ b/src/wallet_app/transaction_decode.rs @@ -64,13 +64,107 @@ .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}")), - } + 12 => serde_json::to_value( + VanityAddressTransaction::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode vanity transaction: {err}"))?, + ), + 100 => serde_json::to_value( + StorageKey::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage key transaction: {err}"))?, + ), + 101 => serde_json::to_value( + StorageBool::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage bool transaction: {err}"))?, + ), + 102 => serde_json::to_value( + StorageU8::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage u8 transaction: {err}"))?, + ), + 103 => serde_json::to_value( + StorageU16::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage u16 transaction: {err}"))?, + ), + 104 => serde_json::to_value( + StorageU32::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage u32 transaction: {err}"))?, + ), + 105 => serde_json::to_value( + StorageU64::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage u64 transaction: {err}"))?, + ), + 106 => { + let tx = StorageU128::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage u128 transaction: {err}"))?; + Ok(serde_json::json!({ + "unsigned_storageu128": { + "txtype": tx.unsigned_storageu128.txtype, + "time": tx.unsigned_storageu128.time, + "storagekey": tx.unsigned_storageu128.storagekey, + "key": tx.unsigned_storageu128.key, + "value": tx.unsigned_storageu128.value.to_string(), + "address": tx.unsigned_storageu128.address, + "txfee": tx.unsigned_storageu128.txfee, + }, + "signature": tx.signature, + })) + } + 107 => serde_json::to_value( + StorageString::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage string transaction: {err}"))?, + ), + 108 => serde_json::to_value( + StorageI8::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage i8 transaction: {err}"))?, + ), + 109 => serde_json::to_value( + StorageI16::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage i16 transaction: {err}"))?, + ), + 110 => serde_json::to_value( + StorageI32::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage i32 transaction: {err}"))?, + ), + 111 => serde_json::to_value( + StorageI64::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage i64 transaction: {err}"))?, + ), + 112 => { + let tx = StorageI128::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage i128 transaction: {err}"))?; + Ok(serde_json::json!({ + "unsigned_storagei128": { + "txtype": tx.unsigned_storagei128.txtype, + "time": tx.unsigned_storagei128.time, + "storagekey": tx.unsigned_storagei128.storagekey, + "key": tx.unsigned_storagei128.key, + "value": tx.unsigned_storagei128.value.to_string(), + "address": tx.unsigned_storagei128.address, + "txfee": tx.unsigned_storagei128.txfee, + }, + "signature": tx.signature, + })) + } + 113 => serde_json::to_value( + DeleteKey::from_bytes(txtype, body) + .await + .map_err(|err| format!("Failed to decode storage delete transaction: {err}"))?, + ), + _ => return Err(format!("Unknown transaction type: {txtype}")), + } .map_err(|err| format!("Failed to serialize decoded transaction: {err}")) } diff --git a/src/wallet_app/types.rs b/src/wallet_app/types.rs index e92620a..e9a8744 100644 --- a/src/wallet_app/types.rs +++ b/src/wallet_app/types.rs @@ -299,6 +299,11 @@ struct CollateralClaimContractView { fee: String, } +#[derive(Serialize)] +struct CollateralStatusView { + claimed: bool, +} + #[derive(Serialize)] struct BroadcastUnbroadcastResult { response: String, diff --git a/src/wallet_app/wallet_files.rs b/src/wallet_app/wallet_files.rs index 845c161..7e5ad42 100644 --- a/src/wallet_app/wallet_files.rs +++ b/src/wallet_app/wallet_files.rs @@ -1,6 +1,6 @@ -async fn saved_private_key_image(wallet_path: &str) -> Option { +async fn saved_private_key_image(wallet_path: &str) -> Option { let bytes = read(wallet_path).await.ok()?; - let saved_wallet: SavedWallet = blockchain::from_slice(&bytes).ok()?; + let saved_wallet: SavedWallet = contractless::from_slice(&bytes).ok()?; if saved_wallet.private_key.trim().is_empty() { None } else { @@ -103,7 +103,7 @@ async fn import_private_key_file( }; let output = to_string_pretty(&saved_wallet) .map_err(|err| format!("Failed to serialize imported wallet: {err}"))?; - blockchain::tokio::fs::write(wallet_path, output) + contractless::tokio::fs::write(wallet_path, output) .await .map_err(|err| format!("Failed to write imported wallet file: {err}"))?; @@ -111,7 +111,7 @@ async fn import_private_key_file( } async fn private_key_from_png_image(image_path: &Path, wallet_key: &str) -> Result { - let image_bytes = blockchain::tokio::fs::read(image_path) + let image_bytes = contractless::tokio::fs::read(image_path) .await .map_err(|err| format!("Failed to read private key image: {err}"))?; let image_base64 = STANDARD.encode(image_bytes); @@ -250,7 +250,7 @@ dbname = contractless_gui public_ip = public_ip ); - blockchain::tokio::fs::write(&settings_path, settings) + contractless::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);
On-chain application data stored by the active address.
${escapeAddressDataText(selectedGroup.storageId)}