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