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);