594 lines
19 KiB
JavaScript
594 lines
19 KiB
JavaScript
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 = '<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();
|
|
}
|
|
});
|