404 lines
14 KiB
JavaScript
404 lines
14 KiB
JavaScript
|
|
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);
|
||
|
|
});
|