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

632 lines
22 KiB
JavaScript
Raw Normal View History

2026-07-01 16:01:30 +00:00
function setUnbroadcastListMessage(message, type = "") {
if (!unbroadcastListMessage) {
return;
}
unbroadcastListMessage.textContent = message;
unbroadcastListMessage.classList.toggle("error", type === "error");
unbroadcastListMessage.classList.toggle("success", type === "success");
unbroadcastListMessage.classList.toggle("hidden", !message);
}
function setUnbroadcastReviewMessage(message, type = "") {
if (!unbroadcastReviewMessage) {
return;
}
unbroadcastReviewMessage.textContent = message;
unbroadcastReviewMessage.classList.toggle("error", type === "error");
unbroadcastReviewMessage.classList.toggle("success", type === "success");
unbroadcastReviewMessage.classList.toggle("hidden", !message);
}
function unbroadcastStatusLabel(item) {
switch (item?.status) {
case "ready":
return "Ready";
case "needs_active_signature":
return "Needs Your Signature";
case "needs_other_signature":
return "Needs Other Signature";
case "invalid_hash":
return "Invalid Hash";
case "invalid_signature":
return "Invalid Signature";
default:
return item?.status || "Unknown";
}
}
function truncateMiddle(value, left = 12, right = 12) {
const text = String(value || "");
if (text.length <= left + right + 3) {
return text;
}
return `${text.slice(0, left)}...${text.slice(-right)}`;
}
function clearUnbroadcastReview() {
activeUnbroadcastTransaction = null;
if (unbroadcastReviewGrid) {
unbroadcastReviewGrid.innerHTML = "";
}
if (unbroadcastJsonPreview) {
unbroadcastJsonPreview.textContent = "";
}
setUnbroadcastReviewMessage("");
}
function renderUnbroadcastList(items = []) {
if (!transactionList) {
return;
}
transactionList.innerHTML = "";
if (!items.length) {
setUnbroadcastListMessage("No unbroadcast transactions found.");
return;
}
setUnbroadcastListMessage("");
items.forEach((item) => {
const button = document.createElement("button");
button.className = "local-transaction";
button.type = "button";
button.dataset.unbroadcastFile = item.file_name;
button.classList.toggle(
"active",
activeUnbroadcastTransaction?.file_name === item.file_name
);
const type = document.createElement("span");
type.className = "tx-kind";
type.textContent = item.type_name || "Transaction";
const summary = document.createElement("strong");
summary.textContent = item.summary || item.file_name;
const status = document.createElement("span");
status.textContent = item.status_message || unbroadcastStatusLabel(item);
const file = document.createElement("span");
file.className = "muted-file";
file.textContent = item.txid ? truncateMiddle(item.txid) : item.file_name;
button.append(type, summary, status, file);
button.addEventListener("click", () => loadUnbroadcastTransaction(item.file_name));
transactionList.appendChild(button);
});
}
async function refreshUnbroadcastTransactions() {
if (unbroadcastRefreshInFlight) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setUnbroadcastListMessage("Tauri bridge is not available.", "error");
return;
}
unbroadcastRefreshInFlight = true;
setUnbroadcastListMessage("Loading unbroadcast transactions...");
try {
const items = await invoke("list_unbroadcast_transactions");
renderUnbroadcastList(Array.isArray(items) ? items : []);
} catch (error) {
transactionList.innerHTML = "";
setUnbroadcastListMessage(String(error), "error");
} finally {
unbroadcastRefreshInFlight = false;
}
}
function appendReviewField(label, value, wide = false) {
if (!unbroadcastReviewGrid) {
return;
}
const row = document.createElement("div");
row.classList.toggle("wide", wide);
const key = document.createElement("span");
key.textContent = label;
const val = document.createElement("b");
val.textContent = value === null || value === undefined || value === "" ? "--" : String(value);
row.append(key, val);
unbroadcastReviewGrid.appendChild(row);
}
function primitiveTransactionFields(transaction) {
return Object.entries(transaction || {}).filter(([, value]) => {
return value === null || ["string", "number", "boolean"].includes(typeof value);
});
}
function atomicReviewAmount(value) {
try {
return atomicToDecimal(BigInt(value || 0));
} catch (_) {
return String(value || "0");
}
}
function swapReviewAsset(transaction, side) {
const ticker = String(transaction?.[`ticker${side}`] || "").trim();
const series = Number(transaction?.[`nft_series${side}`] || 0);
return series > 0 ? `${ticker} #${series}` : ticker;
}
function renderSwapReviewFields(review) {
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
const sender1 = String(transaction.sender1 || "").trim();
const sender2 = String(transaction.sender2 || "").trim();
const activeIsSender1 = active === sender1;
const activeIsSender2 = active === sender2;
const expiration = Number(transaction.offer_expiration || 0);
if (activeIsSender1 || activeIsSender2) {
const sendSide = activeIsSender2 ? 2 : 1;
const receiveSide = activeIsSender2 ? 1 : 2;
const counterparty = activeIsSender2 ? sender1 : sender2;
appendReviewField("Your Role", activeIsSender1 ? "Offer Creator (Sender 1)" : "Counterparty (Sender 2)");
appendReviewField(
activeIsSender1 ? "You Offer" : "You Send",
`${atomicReviewAmount(transaction[`value${sendSide}`])} ${swapReviewAsset(transaction, sendSide)}`
);
appendReviewField(
activeIsSender1 ? "You Request" : "You Receive",
`${atomicReviewAmount(transaction[`value${receiveSide}`])} ${swapReviewAsset(transaction, receiveSide)}`
);
appendReviewField(
"Your Miner Tip",
`${atomicReviewAmount(transaction[`tip${sendSide}`])} ${swapReviewAsset(transaction, sendSide)}`
);
appendReviewField(
"Your Fee",
`${atomicReviewAmount(transaction[`txfee${sendSide}`])} ${activeBaseCoin()}`
);
appendReviewField("Counterparty", counterparty, true);
} else {
appendReviewField("Viewer Role", "Not a signer");
appendReviewField("Sender 1", sender1, true);
appendReviewField(
"Sender 1 Offers",
`${atomicReviewAmount(transaction.value1)} ${swapReviewAsset(transaction, 1)}`
);
appendReviewField("Sender 2", sender2, true);
appendReviewField(
"Sender 2 Offers",
`${atomicReviewAmount(transaction.value2)} ${swapReviewAsset(transaction, 2)}`
);
}
appendReviewField(
"Sender 1 Signature",
transaction.signature1
? (activeIsSender1 ? "Your signature is present" : "Signature supplied")
: "Missing"
);
appendReviewField(
"Sender 2 Signature",
transaction.signature2
? (activeIsSender2 ? "Your signature is present" : "Signature supplied")
: (activeIsSender2 ? "Awaiting your signature" : "Awaiting sender2 signature")
);
appendReviewField(
"Expires",
expiration ? new Date(expiration * 1000).toLocaleString() : "--"
);
}
function loanReviewPeriod(period) {
return {
d: "Daily",
w: "Weekly",
m: "Monthly"
}[String(period || "").trim()] || "Unknown";
}
function loanReviewCollateral(transaction) {
return String(transaction?.collateral || "").trim();
}
function renderLoanReviewFields(review) {
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
const lender = String(transaction.lender || "").trim();
const borrower = String(transaction.borrower || "").trim();
const activeIsLender = active === lender;
const activeIsBorrower = active === borrower;
const startTimestamp = Number(transaction.timestamp || transaction.time || 0);
const paymentCount = Number(transaction.payment_number || 0);
const paymentAmount = BigInt(transaction.payment_amount || 0);
const scheduledTotal = paymentAmount * BigInt(paymentCount);
appendReviewField(
"Your Role",
activeIsLender ? "Offer Creator (Lender)" : activeIsBorrower ? "Borrower" : "Not a signer"
);
appendReviewField("Lender", lender, true);
appendReviewField("Borrower", borrower, true);
appendReviewField(
"Loan",
`${atomicReviewAmount(transaction.loan_amount)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField(
"Collateral",
`${atomicReviewAmount(transaction.collateral_amount)} ${loanReviewCollateral(transaction)}`
);
appendReviewField(
"Start Date (UTC)",
startTimestamp ? new Date(startTimestamp * 1000).toLocaleString([], { timeZone: "UTC" }) : "--"
);
appendReviewField("Payment Period", loanReviewPeriod(transaction.payment_period));
appendReviewField("Number of Payments", paymentCount);
appendReviewField(
"Amount per Payment",
`${atomicReviewAmount(transaction.payment_amount)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField(
"Total Scheduled Repayment",
`${atomicReviewAmount(scheduledTotal)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField("Missed Payments Allowed", Number(transaction.grace_period || 0));
appendReviewField(
"Maximum Overdue Value",
`${atomicReviewAmount(transaction.max_late_value)} ${String(transaction.loan_coin || "").trim()}`
);
appendReviewField(
"Lender Fee",
`${atomicReviewAmount(transaction.txfee)} ${activeBaseCoin()}`
);
appendReviewField(
"Collateral Claim Rule",
"The missed-payment allowance and maximum overdue value must both be exceeded.",
true
);
appendReviewField(
"Lender Signature",
transaction.signature1
? (activeIsLender ? "Your signature is present" : "Signature supplied")
: (activeIsLender ? "Awaiting your signature" : "Missing")
);
appendReviewField(
"Borrower Signature",
transaction.signature2
? (activeIsBorrower ? "Your signature is present" : "Signature supplied")
: (activeIsBorrower ? "Awaiting your signature" : "Awaiting borrower signature")
);
}
function renderUnbroadcastReview(review) {
activeUnbroadcastTransaction = review;
if (transactionTitle) {
transactionTitle.textContent = `${review.type_name || "Transaction"} Review`;
}
if (transactionCopy) {
transactionCopy.textContent = review.status_message || "Inspect this local transaction before broadcasting.";
}
if (transactionList) {
transactionList.classList.add("hidden");
}
formPanel?.classList.add("hidden");
reviewPanel?.classList.remove("hidden");
if (unbroadcastReviewGrid) {
unbroadcastReviewGrid.innerHTML = "";
}
appendReviewField("Type", review.type_name);
appendReviewField("Status", unbroadcastStatusLabel(review));
appendReviewField("File", review.file_name, true);
appendReviewField("Transaction Hash", review.txid || review.transaction?.hash || "--", true);
appendReviewField("Summary", review.summary, true);
if (Number(review.transaction_type) === 6) {
renderSwapReviewFields(review);
} else if (Number(review.transaction_type) === 7) {
renderLoanReviewFields(review);
} else {
primitiveTransactionFields(review.transaction)
.filter(([key]) => !["txtype", "hash"].includes(key))
.forEach(([key, value]) => {
const isSignature = key.toLowerCase().includes("signature");
appendReviewField(
key,
isSignature ? (value ? truncateMiddle(value, 18, 18) : "Missing") : value,
String(value || "").length > 28
);
});
}
if (unbroadcastJsonPreview) {
unbroadcastJsonPreview.textContent = JSON.stringify(review.transaction || {}, null, 2);
}
if (unbroadcastSignButton) {
unbroadcastSignButton.disabled = !review.can_sign;
unbroadcastSignButton.classList.toggle("hidden", !review.can_sign);
unbroadcastSignButton.textContent =
[6, 7].includes(Number(review.transaction_type))
? "Sign and Broadcast"
: "Sign Transaction";
}
if (unbroadcastBroadcastButton) {
unbroadcastBroadcastButton.disabled = !review.can_broadcast;
}
setUnbroadcastReviewMessage(review.status_message || "");
}
async function validateImportedSwapBalance(review) {
if (Number(review?.transaction_type) !== 6) {
return;
}
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
if (active !== String(transaction.sender2 || "").trim()) {
throw new Error(`This swap requires sender2 wallet ${transaction.sender2}.`);
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
const [balances, nfts] = await Promise.all([
invoke("get_active_wallet_balances", { broadcastNode }),
invoke("nft_list", { broadcastNode })
]);
activeWalletBalances = Array.isArray(balances) ? balances : [];
const nftKeys = new Set((Array.isArray(nfts) ? nfts : []).map((nft) => {
return `${String(nft.nft_name || "").trim().toLowerCase()}|${Number(nft.series || 0)}`;
}));
const asset = String(transaction.ticker2 || "").trim();
const series = Number(transaction.nft_series2 || 0);
const isNft = nftKeys.has(`${asset.toLowerCase()}|${series}`);
const balance = activeWalletBalances.find((row) => {
return (
String(row.asset || "").trim().toLowerCase() === asset.toLowerCase() &&
Number(row.nft_series || 0) === series
);
});
const available = balanceAtomicValue(balance);
const amount = BigInt(transaction.value2 || 0);
const tip = BigInt(transaction.tip2 || 0);
const fee = BigInt(transaction.txfee2 || 0);
if (isNft && tip !== 0n) {
throw new Error("NFT swap tips must be zero.");
}
if (!isNft && tip < (amount + 99n) / 100n) {
throw new Error("The counterparty miner tip must be at least 1% of the swap amount.");
}
if (amount + tip > available) {
throw new Error(`This wallet does not have enough ${swapReviewAsset(transaction, 2)} for the swap.`);
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(asset)) {
if (amount + tip + fee > available) {
throw new Error(`This wallet cannot cover the requested amount, tip, and ${activeBaseCoin()} fee.`);
}
} else if (fee > baseBalance) {
throw new Error(`This wallet does not have enough ${activeBaseCoin()} for the swap fee.`);
}
}
async function validateImportedLoanBalance(review) {
if (Number(review?.transaction_type) !== 7) {
return;
}
const transaction = review.transaction || {};
const active = String(activeWalletAddressShort || activeWalletAddressHex || "").trim();
const borrower = String(transaction.borrower || "").trim();
if (active !== borrower) {
throw new Error(`This loan requires borrower wallet ${borrower}.`);
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
const balances = await invoke("get_active_wallet_balances", { broadcastNode });
activeWalletBalances = Array.isArray(balances) ? balances : [];
const collateral = loanReviewCollateral(transaction);
const split = collateral.match(/^(.*)_(\d{1,5})$/);
const asset = split ? split[1] : collateral;
const series = split ? Number(split[2]) : 0;
const balance = activeWalletBalances.find((row) => {
return (
String(row.asset || "").trim().toLowerCase() === asset.toLowerCase()
&& Number(row.nft_series || 0) === series
);
});
const available = balanceAtomicValue(balance);
const required = BigInt(transaction.collateral_amount || 0);
if (required > available) {
const label = series > 0 ? `${asset} #${series}` : asset;
throw new Error(
`This wallet requires ${atomicReviewAmount(required)} ${label} as collateral, but only ${atomicReviewAmount(available)} is available.`
);
}
}
async function importUnbroadcastTransaction() {
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setUnbroadcastListMessage("Select a broadcast node before importing.", "error");
return;
}
if (!walletLoaded) {
setUnbroadcastListMessage("Load a wallet before importing a transaction.", "error");
return;
}
setUnbroadcastListMessage("Validating imported transaction...");
try {
const review = await invoke("import_unbroadcast_transaction", { broadcastNode });
if (!review) {
setUnbroadcastListMessage("");
return;
}
renderUnbroadcastReview(review);
refreshUnbroadcastTransactions();
} catch (error) {
setUnbroadcastListMessage(String(error), "error");
}
}
async function loadUnbroadcastTransaction(fileName) {
if (unbroadcastReviewInFlight) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setUnbroadcastListMessage("Tauri bridge is not available.", "error");
return;
}
unbroadcastReviewInFlight = true;
setUnbroadcastListMessage("");
try {
const review = await invoke("load_unbroadcast_transaction", { fileName });
renderUnbroadcastReview(review);
refreshUnbroadcastTransactions();
} catch (error) {
setUnbroadcastListMessage(String(error), "error");
} finally {
unbroadcastReviewInFlight = false;
}
}
function showUnbroadcastList() {
if (transactionTitle) {
transactionTitle.textContent = "Unbroadcast Transactions";
}
if (transactionCopy) {
transactionCopy.textContent = "Transactions saved locally and waiting for review.";
}
transactionList?.classList.remove("hidden");
formPanel?.classList.add("hidden");
reviewPanel?.classList.add("hidden");
clearUnbroadcastReview();
refreshUnbroadcastTransactions();
}
async function signActiveUnbroadcastTransaction() {
if (!activeUnbroadcastTransaction?.file_name) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setUnbroadcastReviewMessage("Select a broadcast node before signing.", "error");
return;
}
try {
await validateImportedSwapBalance(activeUnbroadcastTransaction);
await validateImportedLoanBalance(activeUnbroadcastTransaction);
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setUnbroadcastReviewMessage("Signing cancelled.");
return;
}
unbroadcastSignButton.disabled = true;
const transactionType = Number(activeUnbroadcastTransaction.transaction_type);
const signAndBroadcast = [6, 7].includes(transactionType);
const signingType = transactionType === 7 ? "loan" : transactionType === 6 ? "swap" : "transaction";
setUnbroadcastReviewMessage(`Signing ${signingType}...`);
try {
const review = await invoke("sign_unbroadcast_transaction", {
walletKey,
broadcastNode,
fileName: activeUnbroadcastTransaction.file_name
});
renderUnbroadcastReview(review);
refreshUnbroadcastTransactions();
if (signAndBroadcast) {
const typeName = transactionType === 7 ? "Loan" : "Swap";
setUnbroadcastReviewMessage(`${typeName} signed. Broadcasting...`);
await broadcastActiveUnbroadcastTransaction();
} else {
setUnbroadcastReviewMessage("Transaction signed.", "success");
}
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
} finally {
unbroadcastSignButton.disabled = !activeUnbroadcastTransaction?.can_sign;
}
}
async function broadcastActiveUnbroadcastTransaction() {
if (!activeUnbroadcastTransaction?.file_name) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
setUnbroadcastReviewMessage("No broadcast node is selected.", "error");
return;
}
unbroadcastBroadcastButton.disabled = true;
setUnbroadcastReviewMessage("Broadcasting transaction...");
try {
const broadcastedTransaction = activeUnbroadcastTransaction?.transaction;
const result = await invoke("broadcast_unbroadcast_transaction", {
broadcastNode,
fileName: activeUnbroadcastTransaction.file_name
});
if (typeof applyActiveVanityDisplayFromTransaction === "function") {
applyActiveVanityDisplayFromTransaction(broadcastedTransaction);
}
const successMessage = result?.response || "Broadcast completed.";
transactionTitle.textContent = "Unbroadcast Transactions";
transactionCopy.textContent = "Transactions saved locally and waiting for review.";
transactionList?.classList.remove("hidden");
formPanel?.classList.add("hidden");
reviewPanel?.classList.add("hidden");
clearUnbroadcastReview();
await refreshUnbroadcastTransactions();
setUnbroadcastListMessage(successMessage, "success");
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
} finally {
unbroadcastBroadcastButton.disabled = !activeUnbroadcastTransaction?.can_broadcast;
}
}
async function deleteActiveUnbroadcastTransaction() {
if (!activeUnbroadcastTransaction?.file_name) {
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setUnbroadcastReviewMessage("Tauri bridge is not available.", "error");
return;
}
const fileName = activeUnbroadcastTransaction.file_name;
setUnbroadcastReviewMessage("Deleting local copy...");
try {
await invoke("delete_unbroadcast_transaction", { fileName });
showUnbroadcastList();
} catch (error) {
setUnbroadcastReviewMessage(String(error), "error");
}
}
unbroadcastBackButton?.addEventListener("click", showUnbroadcastList);
unbroadcastSignButton?.addEventListener("click", signActiveUnbroadcastTransaction);
unbroadcastBroadcastButton?.addEventListener("click", broadcastActiveUnbroadcastTransaction);
unbroadcastDeleteButton?.addEventListener("click", deleteActiveUnbroadcastTransaction);
document
.querySelector("[data-import-transaction]")
?.addEventListener("click", importUnbroadcastTransaction);