Contractless-GUI-Wallet/frontend/js/address_data_page.js

594 lines
19 KiB
JavaScript
Raw Permalink Normal View History

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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = '<option value="">Select storage key</option>';
addressDataSelect.disabled = true;
return;
}
addressDataSelect.innerHTML = '<option value="">Select storage key</option>' + addressDataRecords.map((group) => {
const optionLabel = addressDataLabels.labels[group.storageId] || group.storageId;
return `<option value="${escapeAddressDataText(group.storageId)}">${escapeAddressDataText(optionLabel)}</option>`;
}).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 ? `
<label class="address-data-label-field">
<span>Application Label</span>
<input type="text" maxlength="80" value="${escapeAddressDataText(addressDataLabels.labels[selectedGroup.storageId] || "")}" placeholder="Add a label" data-address-data-label="${escapeAddressDataText(selectedGroup.storageId)}">
<small data-address-data-label-status></small>
</label>
<div class="address-data-selected-id">
<span class="mini-label">Storage Key</span>
<code>${escapeAddressDataText(selectedGroup.storageId)}</code>
</div>
` : "";
}
addressDataGroups.innerHTML = selectedGroup ? `
<section class="address-data-group">
<div class="address-data-fields">
<div class="address-data-field address-data-field-header" aria-hidden="true">
<strong>Key</strong>
<strong>Value</strong>
<strong>Type</strong>
<strong>Action</strong>
</div>
${selectedGroup.fields.length ? selectedGroup.fields.map((field) => `
<div class="address-data-field">
<div class="address-data-field-key">
<strong>${escapeAddressDataText(field.key || "<empty>")}</strong>
</div>
<div class="address-data-field-value">${escapeAddressDataText(field.value)}</div>
<div class="address-data-field-type">${escapeAddressDataText(field.type)}</div>
<div class="address-data-field-action">
<button class="address-data-delete-button" type="button" data-address-data-delete="${escapeAddressDataText(field.key)}">Delete</button>
</div>
</div>
`).join("") : '<div class="subtle-state">No values have been stored under this storage ID.</div>'}
</div>
</section>
` : "";
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 = '<option value="">Select storage key</option>';
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();
}
});