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

498 lines
15 KiB
JavaScript
Raw Normal View History

2026-07-01 16:01:30 +00:00
const transferSaveButton = document.querySelector("[data-transfer-save]");
const transferBroadcastButton = document.querySelector("[data-transfer-broadcast]");
const ATOMIC_UNITS_PER_COIN = 100000000n;
const NON_BASE_TRANSFER_MIN_FEE_ATOMIC = 100000000n;
let transferBalancesRefreshPromise = null;
function setTransferMessage(message, type = "") {
setScopedTransactionMessage("Transfer", transferMessage, message, type);
}
function balanceAtomicValue(balance) {
try {
return BigInt(balance?.balance || "0");
} catch (_) {
return 0n;
}
}
function transferAssetKey(balance) {
return `${balance?.asset || ""}|${Number(balance?.nft_series || 0)}`;
}
function transferAssetLabel(balance) {
const amount = formatAtomicBalance(balance?.balance || "0");
const series = Number(balance?.nft_series || 0);
if (series > 0) {
return `${balance.asset} #${series} (${amount})`;
}
return `${balance.asset} (${amount})`;
}
function decimalToAtomic(value) {
const text = String(value || "").trim();
if (!/^\d+(\.\d{0,8})?$/.test(text)) {
return null;
}
const [whole, fraction = ""] = text.split(".");
const paddedFraction = fraction.padEnd(8, "0");
return BigInt(whole || "0") * ATOMIC_UNITS_PER_COIN + BigInt(paddedFraction || "0");
}
function atomicToDecimal(value) {
const atomic = BigInt(value || 0);
const whole = atomic / ATOMIC_UNITS_PER_COIN;
const fraction = String(atomic % ATOMIC_UNITS_PER_COIN).padStart(8, "0");
return `${whole}.${fraction}`;
}
function isBaseTransferAsset(asset) {
return String(asset || "").trim().toUpperCase() === activeBaseCoin();
}
function minimumTransferFee(amount, asset) {
if (isBaseTransferAsset(asset)) {
return (amount + 99n) / 100n;
}
return NON_BASE_TRANSFER_MIN_FEE_ATOMIC;
}
function baseCoinBalanceAtomic() {
const baseCoin = activeBaseCoin();
const baseBalance = (Array.isArray(activeWalletBalances) ? activeWalletBalances : []).find((balance) => {
return String(balance.asset || "").trim().toUpperCase() === baseCoin && !Number(balance.nft_series || 0);
});
return balanceAtomicValue(baseBalance);
}
function selectedTransferAsset() {
if (!transferAssetSelect?.value) {
return null;
}
const option = transferAssetSelect.selectedOptions?.[0];
if (!option) {
return null;
}
return {
asset: option.dataset.asset || "",
nftSeries: Number(option.dataset.nftSeries || 0),
balance: BigInt(option.dataset.balance || "0")
};
}
function updateTransferDerivedFields() {
const selected = selectedTransferAsset();
const sender = activeWalletAddressShort || activeWalletAddressHex || "--";
if (transferSender) {
transferSender.textContent = sender;
}
if (!selected) {
if (transferCoin) {
transferCoin.textContent = "--";
}
if (transferNftSeries) {
transferNftSeries.textContent = "0";
}
if (transferBalance) {
transferBalance.textContent = "--";
}
if (transferAmountInput) {
transferAmountInput.disabled = false;
}
if (transferFeeInput) {
transferFeeInput.value = "";
}
return;
}
if (transferCoin) {
transferCoin.textContent = selected.asset || "--";
}
if (transferNftSeries) {
transferNftSeries.textContent = String(selected.nftSeries || 0);
}
if (transferBalance) {
const seriesLabel = selected.nftSeries > 0 ? ` #${selected.nftSeries}` : "";
transferBalance.textContent = `${formatAtomicBalance(String(selected.balance))} ${selected.asset}${seriesLabel}`;
}
if (transferAmountInput) {
if (selected.nftSeries > 0) {
transferAmountInput.value = "1";
transferAmountInput.disabled = true;
} else {
transferAmountInput.disabled = false;
}
}
updateTransferFee();
}
function updateTransferFee() {
if (!transferFeeInput) {
return;
}
const selected = selectedTransferAsset();
if (!selected) {
transferFeeInput.value = "";
return;
}
const amount = selected.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value);
if (amount === null || amount <= 0n) {
transferFeeInput.value = "";
return;
}
transferFeeInput.value = atomicToDecimal(minimumTransferFee(amount, selected.asset));
}
function refreshTransferAssetOptions() {
if (!transferAssetSelect) {
return;
}
const previous = transferAssetSelect.value;
transferAssetSelect.innerHTML = "";
const spendableBalances = (Array.isArray(activeWalletBalances) ? activeWalletBalances : [])
.filter((balance) => (balance.asset || "").trim() && balanceAtomicValue(balance) > 0n)
.sort((left, right) => {
const leftSeries = Number(left.nft_series || 0);
const rightSeries = Number(right.nft_series || 0);
if ((left.asset || "") !== (right.asset || "")) {
return (left.asset || "").localeCompare(right.asset || "");
}
return leftSeries - rightSeries;
});
if (!spendableBalances.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = walletLoaded ? "No spendable assets found" : "Load a wallet first";
transferAssetSelect.appendChild(option);
updateTransferDerivedFields();
return;
}
spendableBalances.forEach((balance) => {
const option = document.createElement("option");
option.value = transferAssetKey(balance);
option.textContent = transferAssetLabel(balance);
option.dataset.asset = balance.asset || "";
option.dataset.nftSeries = String(Number(balance.nft_series || 0));
option.dataset.balance = String(balance.balance || "0");
transferAssetSelect.appendChild(option);
});
if ([...transferAssetSelect.options].some((option) => option.value === previous)) {
transferAssetSelect.value = previous;
}
updateTransferDerivedFields();
}
async function refreshTransferBalancesIfNeeded() {
if (activeWalletBalances.length || !walletLoaded || !registrationRegistered) {
refreshTransferAssetOptions();
return;
}
if (transferBalancesRefreshPromise) {
return transferBalancesRefreshPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode) {
refreshTransferAssetOptions();
return;
}
transferBalancesRefreshPromise = (async () => {
setTransferMessage("Loading spendable assets...");
try {
const balances = await invoke("get_active_wallet_balances", { broadcastNode });
activeWalletBalances = Array.isArray(balances) ? balances : [];
refreshTransferAssetOptions();
setTransferMessage("");
} catch (error) {
refreshTransferAssetOptions();
setTransferMessage(String(error), "error");
throw error;
} finally {
transferBalancesRefreshPromise = null;
}
})();
return transferBalancesRefreshPromise;
}
function parseTransferPaymentUri(value) {
const text = String(value || "").trim();
let uri;
try {
uri = new URL(text);
} catch (_) {
throw new Error("Enter a valid Contractless payment URI.");
}
if (uri.protocol.toLowerCase() !== "contractless:") {
throw new Error("Payment URI must begin with contractless:.");
}
const encodedAddress = uri.hostname || uri.pathname.replace(/^\/+/, "");
let receiver;
try {
receiver = decodeURIComponent(encodedAddress).trim();
} catch (_) {
throw new Error("Payment URI contains an invalid receiver address.");
}
if (!receiver) {
throw new Error("Payment URI does not contain a receiver address.");
}
const asset = (uri.searchParams.get("asset") || activeBaseCoin()).trim();
if (!asset) {
throw new Error("Payment URI does not contain a valid asset.");
}
const amount = (uri.searchParams.get("amount") || "").trim();
if (amount) {
const atomicAmount = decimalToAtomic(amount);
if (atomicAmount === null || atomicAmount <= 0n) {
throw new Error("Payment URI contains an invalid amount.");
}
}
const label = (uri.searchParams.get("label") || "").trim();
return { receiver, asset, amount, label };
}
async function loadTransferPaymentUri(value) {
const payment = parseTransferPaymentUri(value);
openTransactionType("Transfer");
await refreshTransferBalancesIfNeeded();
const matchingOption = [...(transferAssetSelect?.options || [])].find((option) => {
return (
String(option.dataset.asset || "").trim().toLowerCase() === payment.asset.toLowerCase() &&
Number(option.dataset.nftSeries || 0) === 0
);
});
if (!matchingOption) {
throw new Error(`The active wallet does not have a spendable ${payment.asset} balance.`);
}
transferAssetSelect.value = matchingOption.value;
transferReceiverInput.value = payment.receiver;
transferAmountInput.value = payment.amount;
if (typeof addPaymentUriAddressBookEntry === "function") {
await addPaymentUriAddressBookEntry(payment.receiver, payment.label);
}
updateTransferDerivedFields();
updateTransferFee();
setTransferMessage("");
}
function validateTransferForm() {
const selected = selectedTransferAsset();
if (!walletLoaded) {
return "Load a wallet before creating a transfer.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a transfer.";
}
if (!selected) {
return "Select a coin, token, or NFT to transfer.";
}
const receiver = String(transferReceiverInput?.value || "").trim();
if (!receiver) {
return "Enter a receiver address.";
}
if (
receiver.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase() ||
receiver.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase()
) {
return "Receiver cannot be the active wallet address.";
}
const amount = selected.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value);
if (amount === null || amount <= 0n) {
return "Enter a valid amount with up to 8 decimal places.";
}
if (selected.nftSeries > 0 && String(transferAmountInput?.value || "").trim() !== "1") {
return "NFT transfers must send exactly 1 item.";
}
if (amount > selected.balance) {
return "Amount exceeds the available balance for this asset.";
}
const expectedFee = minimumTransferFee(amount, selected.asset);
const fee = decimalToAtomic(transferFeeInput?.value || "0");
if (fee === null) {
return "Transaction fee could not be calculated.";
}
if (fee < expectedFee) {
return `Transaction fee must be at least ${atomicToDecimal(expectedFee)} ${activeBaseCoin()}.`;
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(selected.asset)) {
if (amount + fee > selected.balance) {
return "Amount plus transaction fee exceeds the available base coin balance.";
}
} else if (fee > baseBalance) {
return `This transfer needs ${atomicToDecimal(fee)} ${activeBaseCoin()} for the transaction fee.`;
}
return "";
}
function transferPayload() {
const selected = selectedTransferAsset();
const value = selected?.nftSeries > 0 ? 1n : decimalToAtomic(transferAmountInput?.value);
const fee = decimalToAtomic(transferFeeInput?.value || "0");
return {
receiver: String(transferReceiverInput?.value || "").trim(),
coin: selected?.asset || "",
nftSeries: Number(selected?.nftSeries || 0),
value: String(value || 0n),
txfee: String(fee || 0n)
};
}
function setTransferButtonsDisabled(disabled) {
if (transferSaveButton) {
transferSaveButton.disabled = disabled;
}
if (transferBroadcastButton) {
transferBroadcastButton.disabled = disabled;
}
}
function clearTransferForm() {
if (transferReceiverInput) {
transferReceiverInput.value = "";
}
if (transferAmountInput) {
transferAmountInput.value = "";
}
if (transferAssetSelect) {
transferAssetSelect.selectedIndex = -1;
}
updateTransferDerivedFields();
}
async function handleTransferAction(action) {
const error = validateTransferForm();
if (error) {
setTransferMessage(error, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setTransferMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setTransferMessage("Signing cancelled.");
return;
}
setTransferButtonsDisabled(true);
try {
if (action === "save") {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before saving.");
}
setTransferMessage("Saving transfer...");
const review = await invoke("save_transfer_transaction", {
...transferPayload(),
broadcastNode,
walletKey
});
clearTransferForm();
setTransferMessage(`Transfer saved to unbroadcast transactions: ${review.file_name}`, "success");
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} else {
const broadcastNode = activeBroadcastNode();
if (!broadcastNode) {
throw new Error("Select a broadcast node before broadcasting.");
}
setTransferMessage("Broadcasting transfer...");
const result = await invoke("broadcast_transfer_transaction", {
...transferPayload(),
broadcastNode,
walletKey
});
clearTransferForm();
setTransferMessage(result.response || "Transfer broadcast.", "success");
}
} catch (error) {
setTransferMessage(String(error), "error");
} finally {
setTransferButtonsDisabled(false);
}
}
function prepareTransferForm() {
setTransferMessage("");
refreshTransferBalancesIfNeeded().catch(() => {
// The form already displays the RPC error; keep navigation responsive.
});
}
transferAssetSelect?.addEventListener("change", () => {
updateTransferDerivedFields();
setTransferMessage("");
});
transferReceiverInput?.addEventListener("input", () => setTransferMessage(""));
transferAmountInput?.addEventListener("input", () => {
updateTransferFee();
setTransferMessage("");
});
transferSaveButton?.addEventListener("click", () => handleTransferAction("save"));
transferBroadcastButton?.addEventListener("click", () => handleTransferAction("broadcast"));
topbarPaymentUriInput?.addEventListener("input", () => {
topbarPaymentUriInput.setCustomValidity("");
});
topbarPaymentUriForm?.addEventListener("submit", async (event) => {
event.preventDefault();
topbarPaymentUriInput?.setCustomValidity("");
try {
await loadTransferPaymentUri(topbarPaymentUriInput?.value);
if (topbarPaymentUriInput) {
topbarPaymentUriInput.value = "";
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (topbarPaymentUriInput) {
topbarPaymentUriInput.setCustomValidity(message);
topbarPaymentUriInput.reportValidity();
}
if (currentView === "send") {
setTransferMessage(message, "error");
}
}
});