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

404 lines
14 KiB
JavaScript
Raw Normal View History

2026-07-01 16:01:30 +00:00
const SWAP_FEE_ATOMIC = 100000000n;
let swapOfferLoadPromise = null;
let swapNetworkAssets = [];
let swapNftKeys = new Set();
let swapOfferAssetsKey = "";
function setSwapOfferMessage(message, type = "") {
setScopedTransactionMessage("Swap", swapOfferMessage, message, type);
}
function swapAssetKey(asset) {
return `${asset.kind}|${asset.asset}|${Number(asset.nftSeries || 0)}`;
}
function swapAssetLabel(asset, includeBalance = false) {
const series = Number(asset.nftSeries || 0);
const name = asset.kind === "nft" && series > 0
? `${asset.asset} #${series}`
: asset.asset;
if (!includeBalance) {
return asset.kind === "nft" ? `${name} (NFT)` : name;
}
if (asset.kind === "nft") {
return `${name} (1 item)`;
}
return `${name} (${formatAtomicBalance(String(asset.balance || "0"))})`;
}
function selectedSwapAsset(select) {
const option = select?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
nftSeries: Number(option.dataset.nftSeries || 0),
kind: option.dataset.kind || "token",
balance: BigInt(option.dataset.balance || "0")
};
}
function swapMinimumTip(value, asset) {
if (asset?.kind === "nft") {
return 0n;
}
return (value + 99n) / 100n;
}
function populateSwapAssetSelect(select, assets, includeBalance) {
if (!select) {
return;
}
const previous = select.value;
select.innerHTML = "";
assets.forEach((asset) => {
const option = document.createElement("option");
option.value = swapAssetKey(asset);
option.textContent = swapAssetLabel(asset, includeBalance);
option.dataset.asset = asset.asset;
option.dataset.nftSeries = String(Number(asset.nftSeries || 0));
option.dataset.kind = asset.kind;
option.dataset.balance = String(asset.balance || "0");
select.appendChild(option);
});
if (!assets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = walletLoaded ? "No assets available" : "Load a wallet first";
select.appendChild(option);
} else if ([...select.options].some((option) => option.value === previous)) {
select.value = previous;
}
}
function swapRegistryNftKeys(nfts) {
return new Set(nfts.map((nft) => {
return `${String(nft.nft_name || "").trim().toLowerCase()}|${Number(nft.series || 0)}`;
}));
}
function refreshSwapOfferSelects() {
const offered = activeWalletBalances
.filter((balance) => String(balance.asset || "").trim() && balanceAtomicValue(balance) > 0n)
.map((balance) => {
const asset = String(balance.asset || "").trim();
const nftSeries = Number(balance.nft_series || 0);
const isNft = swapNftKeys.has(`${asset.toLowerCase()}|${nftSeries}`);
return {
asset,
nftSeries,
kind: isNft ? "nft" : (isBaseTransferAsset(asset) ? "coin" : "token"),
balance: String(balance.balance || "0")
};
});
offered.sort((left, right) => swapAssetLabel(left).localeCompare(swapAssetLabel(right)));
populateSwapAssetSelect(swapOfferAssetSelect, offered, true);
populateSwapAssetSelect(swapRequestAssetSelect, swapNetworkAssets, false);
updateSwapOfferDerivedFields();
}
async function loadSwapOfferAssets() {
if (swapOfferLoadPromise) {
return swapOfferLoadPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
return;
}
const assetsKey = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (swapOfferAssetsKey === assetsKey && swapNetworkAssets.length) {
refreshSwapOfferSelects();
return;
}
swapOfferLoadPromise = (async () => {
setSwapOfferMessage("Loading swap assets...");
const [balances, tokens, nfts] = await Promise.all([
activeWalletBalances.length
? Promise.resolve(activeWalletBalances)
: invoke("get_active_wallet_balances", { broadcastNode }),
invoke("token_list", { broadcastNode }),
invoke("nft_list", { broadcastNode })
]);
activeWalletBalances = Array.isArray(balances) ? balances : [];
const tokenRows = Array.isArray(tokens) ? tokens : [];
const nftRows = Array.isArray(nfts) ? nfts : [];
swapNftKeys = swapRegistryNftKeys(nftRows);
swapNetworkAssets = [
{ asset: activeBaseCoin(), nftSeries: 0, kind: "coin", balance: "0" },
...tokenRows.map((token) => ({
asset: String(token.ticker || "").trim(),
nftSeries: 0,
kind: "token",
balance: "0"
})),
...nftRows.map((nft) => ({
asset: String(nft.nft_name || "").trim(),
nftSeries: Number(nft.series || 0),
kind: "nft",
balance: "0"
}))
];
swapNetworkAssets.sort((left, right) => swapAssetLabel(left).localeCompare(swapAssetLabel(right)));
swapOfferAssetsKey = assetsKey;
refreshSwapOfferSelects();
setSwapOfferMessage("");
})().catch((error) => {
setSwapOfferMessage(String(error), "error");
throw error;
}).finally(() => {
swapOfferLoadPromise = null;
});
return swapOfferLoadPromise;
}
function updateSwapSide(asset, amountInput, tipInput) {
if (!asset) {
amountInput.disabled = false;
tipInput.disabled = false;
tipInput.value = "";
return;
}
if (asset.kind === "nft") {
amountInput.value = "1";
amountInput.disabled = true;
tipInput.value = "0";
tipInput.disabled = true;
return;
}
amountInput.disabled = false;
tipInput.disabled = false;
const amount = decimalToAtomic(amountInput.value);
if (!amount || amount <= 0n) {
if (!tipInput.value) {
tipInput.value = "";
}
return;
}
const minimum = swapMinimumTip(amount, asset);
const current = decimalToAtomic(tipInput.value);
if (current === null || current < minimum) {
tipInput.value = atomicToDecimal(minimum);
}
}
function updateSwapOfferDerivedFields() {
const offered = selectedSwapAsset(swapOfferAssetSelect);
const requested = selectedSwapAsset(swapRequestAssetSelect);
updateSwapSide(offered, swapOfferAmountInput, swapOfferTipInput);
updateSwapSide(requested, swapRequestAmountInput, swapRequestTipInput);
if (swapSender) {
swapSender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (swapAvailable) {
swapAvailable.textContent = offered
? (offered.kind === "nft"
? "1 NFT"
: `${formatAtomicBalance(String(offered.balance))} ${offered.asset}`)
: "--";
}
if (swapFees) {
swapFees.textContent = `${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} each`;
}
}
function validateSwapSide(asset, amountInput, tipInput, label) {
if (!asset) {
return `Select the asset ${label}.`;
}
const amount = asset.kind === "nft" ? 1n : decimalToAtomic(amountInput.value);
if (amount === null || amount <= 0n) {
return `Enter a valid amount ${label}.`;
}
const tip = decimalToAtomic(tipInput.value || "0");
if (tip === null) {
return `Enter a valid miner tip ${label}.`;
}
if (asset.kind === "nft" && tip !== 0n) {
return `NFT miner tips must be zero ${label}.`;
}
if (asset.kind !== "nft" && tip < swapMinimumTip(amount, asset)) {
return `Miner tip must be at least 1% of the amount ${label}.`;
}
return "";
}
function validateSwapOfferForm() {
if (!walletLoaded) {
return "Load a wallet before creating a swap offer.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a swap offer.";
}
const offered = selectedSwapAsset(swapOfferAssetSelect);
const requested = selectedSwapAsset(swapRequestAssetSelect);
let error = validateSwapSide(offered, swapOfferAmountInput, swapOfferTipInput, "you offer");
if (error) {
return error;
}
error = validateSwapSide(requested, swapRequestAmountInput, swapRequestTipInput, "you request");
if (error) {
return error;
}
const counterparty = String(swapCounterpartyInput?.value || "").trim();
if (!counterparty) {
return "Enter the counterparty wallet address.";
}
if (
counterparty.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase() ||
counterparty.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase()
) {
return "Counterparty cannot be the active wallet address.";
}
const expiration = Number.parseInt(swapExpirationInput?.value || "0", 10);
if (!Number.isInteger(expiration) || expiration < 1 || expiration > 720) {
return "Offer expiration must be between 1 and 720 hours.";
}
const amount = offered.kind === "nft" ? 1n : decimalToAtomic(swapOfferAmountInput.value);
const tip = offered.kind === "nft" ? 0n : decimalToAtomic(swapOfferTipInput.value);
if (amount + tip > offered.balance) {
return "Offered amount plus miner tip exceeds the available asset balance.";
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(offered.asset)) {
if (amount + tip + SWAP_FEE_ATOMIC > offered.balance) {
return `The offered amount, tip, and ${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`;
}
} else if (baseBalance < SWAP_FEE_ATOMIC) {
return `Creating this offer requires ${atomicToDecimal(SWAP_FEE_ATOMIC)} ${activeBaseCoin()} for your fee.`;
}
return "";
}
function swapOfferPayload() {
const offered = selectedSwapAsset(swapOfferAssetSelect);
const requested = selectedSwapAsset(swapRequestAssetSelect);
const value1 = offered.kind === "nft" ? 1n : decimalToAtomic(swapOfferAmountInput.value);
const value2 = requested.kind === "nft" ? 1n : decimalToAtomic(swapRequestAmountInput.value);
const tip1 = offered.kind === "nft" ? 0n : decimalToAtomic(swapOfferTipInput.value);
const tip2 = requested.kind === "nft" ? 0n : decimalToAtomic(swapRequestTipInput.value);
return {
ticker1: offered.asset,
nftSeries1: offered.nftSeries,
value1: String(value1),
ticker2: requested.asset,
nftSeries2: requested.nftSeries,
value2: String(value2),
sender2: String(swapCounterpartyInput.value || "").trim(),
tip1: String(tip1),
tip2: String(tip2),
expirationHours: Number.parseInt(swapExpirationInput.value, 10)
};
}
function clearSwapOfferForm() {
swapOfferAmountInput.value = "";
swapOfferTipInput.value = "";
swapRequestAmountInput.value = "";
swapRequestTipInput.value = "";
swapCounterpartyInput.value = "";
swapExpirationInput.value = "72";
updateSwapOfferDerivedFields();
}
function setSavedSwapOfferPath(path = "") {
if (swapSavedPathInput) {
swapSavedPathInput.value = path;
}
swapSavedPathPanel?.classList.toggle("hidden", !path);
}
async function saveSwapOffer() {
const validationError = validateSwapOfferForm();
if (validationError) {
setSwapOfferMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setSwapOfferMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setSwapOfferMessage("Signing cancelled.");
return;
}
const button = document.querySelector("[data-swap-offer-save]");
button.disabled = true;
setSavedSwapOfferPath("");
setSwapOfferMessage("Signing and saving swap offer...");
try {
const result = await invoke("save_swap_offer", {
...swapOfferPayload(),
broadcastNode: activeBroadcastNode(),
walletKey
});
clearSwapOfferForm();
setSavedSwapOfferPath(result.file_path || "");
setSwapOfferMessage(
`Swap offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the counterparty.`,
"success"
);
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} catch (error) {
setSwapOfferMessage(String(error), "error");
} finally {
button.disabled = false;
}
}
function prepareSwapOfferForm() {
setSwapOfferMessage("");
loadSwapOfferAssets().catch(() => {
// The form already displays the lookup error.
});
}
function resetSwapOfferSession() {
swapOfferLoadPromise = null;
swapNetworkAssets = [];
swapNftKeys = new Set();
swapOfferAssetsKey = "";
clearSwapOfferForm();
setSavedSwapOfferPath("");
if (swapOfferAssetSelect) {
swapOfferAssetSelect.innerHTML = "";
}
if (swapRequestAssetSelect) {
swapRequestAssetSelect.innerHTML = "";
}
setSwapOfferMessage("");
}
swapOfferAssetSelect?.addEventListener("change", updateSwapOfferDerivedFields);
swapRequestAssetSelect?.addEventListener("change", updateSwapOfferDerivedFields);
swapOfferAmountInput?.addEventListener("input", updateSwapOfferDerivedFields);
swapRequestAmountInput?.addEventListener("input", updateSwapOfferDerivedFields);
swapOfferTipInput?.addEventListener("input", () => setSwapOfferMessage(""));
swapRequestTipInput?.addEventListener("input", () => setSwapOfferMessage(""));
swapCounterpartyInput?.addEventListener("input", () => setSwapOfferMessage(""));
swapExpirationInput?.addEventListener("input", () => setSwapOfferMessage(""));
document.querySelector("[data-swap-offer-save]")?.addEventListener("click", saveSwapOffer);
swapCopyPathButton?.addEventListener("click", async () => {
const path = String(swapSavedPathInput?.value || "");
if (!path || !(await writeClipboardText(path))) {
setSwapOfferMessage("Unable to copy the saved offer path.", "error");
return;
}
const original = swapCopyPathButton.textContent;
swapCopyPathButton.textContent = "Copied";
setTimeout(() => {
swapCopyPathButton.textContent = original;
}, 1200);
});