498 lines
15 KiB
JavaScript
498 lines
15 KiB
JavaScript
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");
|
|
}
|
|
}
|
|
});
|