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

466 lines
15 KiB
JavaScript
Raw Permalink Normal View History

2026-07-01 16:01:30 +00:00
const LOAN_FEE_ATOMIC = 300000000n;
let loanOfferLoadPromise = null;
let loanCollateralAssets = [];
let loanNftKeys = new Set();
let loanOfferAssetsKey = "";
function setLoanOfferMessage(message, type = "") {
setScopedTransactionMessage("Create Loan", loanOfferMessage, message, type);
}
function loanNftKey(asset, series) {
return `${String(asset || "").trim().toLowerCase()}|${Number(series || 0)}`;
}
function selectedLoanAsset() {
const option = loanAssetSelect?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
balance: BigInt(option.dataset.balance || "0")
};
}
function selectedLoanCollateral() {
const option = loanCollateralSelect?.selectedOptions?.[0];
if (!option?.value) {
return null;
}
return {
asset: option.dataset.asset || "",
kind: option.dataset.kind || "token",
series: Number(option.dataset.series || 0),
wireName: option.dataset.wireName || option.dataset.asset || ""
};
}
function loanLocalInputValue(timestampMs) {
const date = new Date(timestampMs);
const pad = (value) => String(value).padStart(2, "0");
return [
date.getFullYear(),
"-",
pad(date.getMonth() + 1),
"-",
pad(date.getDate()),
"T",
pad(date.getHours()),
":",
pad(date.getMinutes())
].join("");
}
function loanStartTimestamp() {
const value = String(loanStartInput?.value || "");
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
if (!match) {
return null;
}
const timestamp = new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
0
).getTime();
if (!Number.isFinite(timestamp) || timestamp < 0) {
return null;
}
return Math.floor(timestamp / 1000);
}
function populateLoanAssetSelect() {
if (!loanAssetSelect) {
return;
}
const previous = loanAssetSelect.value;
const assets = (Array.isArray(activeWalletBalances) ? activeWalletBalances : [])
.filter((balance) => {
const asset = String(balance.asset || "").trim();
const series = Number(balance.nft_series || 0);
return asset
&& balanceAtomicValue(balance) > 0n
&& !loanNftKeys.has(loanNftKey(asset, series));
})
.sort((left, right) => String(left.asset).localeCompare(String(right.asset)));
loanAssetSelect.innerHTML = "";
assets.forEach((balance) => {
const option = document.createElement("option");
option.value = String(balance.asset || "").trim();
option.textContent =
`${option.value} (${formatAtomicBalance(String(balance.balance || "0"))})`;
option.dataset.asset = option.value;
option.dataset.balance = String(balance.balance || "0");
loanAssetSelect.appendChild(option);
});
if (!assets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = walletLoaded ? "No lendable assets available" : "Load a wallet first";
loanAssetSelect.appendChild(option);
} else if ([...loanAssetSelect.options].some((option) => option.value === previous)) {
loanAssetSelect.value = previous;
}
}
function populateLoanCollateralSelect() {
if (!loanCollateralSelect) {
return;
}
const previous = loanCollateralSelect.value;
loanCollateralSelect.innerHTML = "";
loanCollateralAssets.forEach((asset) => {
const option = document.createElement("option");
option.value = `${asset.kind}|${asset.wireName}`;
option.textContent = asset.label;
option.dataset.asset = asset.asset;
option.dataset.kind = asset.kind;
option.dataset.series = String(asset.series || 0);
option.dataset.wireName = asset.wireName;
loanCollateralSelect.appendChild(option);
});
if (!loanCollateralAssets.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No collateral assets available";
loanCollateralSelect.appendChild(option);
} else if ([...loanCollateralSelect.options].some((option) => option.value === previous)) {
loanCollateralSelect.value = previous;
}
}
function refreshLoanOfferSelects() {
populateLoanAssetSelect();
populateLoanCollateralSelect();
updateLoanOfferDerivedFields();
}
async function loadLoanOfferAssets() {
if (loanOfferLoadPromise) {
return loanOfferLoadPromise;
}
const invoke = window.__TAURI__?.core?.invoke;
const broadcastNode = activeBroadcastNode();
if (!invoke || !broadcastNode || !walletLoaded || !registrationRegistered) {
return;
}
const key = `${activeWalletAddressShort || activeWalletAddressHex}|${broadcastNode}`;
if (loanOfferAssetsKey === key && loanCollateralAssets.length) {
refreshLoanOfferSelects();
return;
}
loanOfferLoadPromise = (async () => {
setLoanOfferMessage("Loading loan 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 : [];
loanNftKeys = new Set(nftRows.map((nft) => loanNftKey(nft.nft_name, nft.series)));
loanCollateralAssets = [
{
asset: activeBaseCoin(),
wireName: activeBaseCoin(),
series: 0,
kind: "coin",
label: `${activeBaseCoin()} (Base Coin)`
},
...tokenRows
.filter((token) => {
return String(token.ticker || "").trim().toLowerCase()
!== activeBaseCoin().toLowerCase();
})
.map((token) => {
const asset = String(token.ticker || "").trim();
return { asset, wireName: asset, series: 0, kind: "token", label: `${asset} (Token)` };
}),
...nftRows.map((nft) => {
const asset = String(nft.nft_name || "").trim();
const series = Number(nft.series || 0);
return {
asset,
wireName: series > 0 ? `${asset}_${series}` : asset,
series,
kind: "nft",
label: series > 0 ? `${asset} #${series} (NFT)` : `${asset} (NFT)`
};
})
].filter((asset) => {
return asset.asset
&& asset.wireName.length <= 21
&& (asset.kind !== "nft" || asset.series <= 99999);
});
loanCollateralAssets.sort((left, right) => left.label.localeCompare(right.label));
loanOfferAssetsKey = key;
refreshLoanOfferSelects();
setLoanOfferMessage("");
})().catch((error) => {
setLoanOfferMessage(String(error), "error");
throw error;
}).finally(() => {
loanOfferLoadPromise = null;
});
return loanOfferLoadPromise;
}
function updateLoanOfferDerivedFields() {
const loanAsset = selectedLoanAsset();
const collateral = selectedLoanCollateral();
if (loanLender) {
loanLender.textContent = activeWalletAddressShort || activeWalletAddressHex || "--";
}
if (loanAvailable) {
loanAvailable.textContent = loanAsset
? `${formatAtomicBalance(String(loanAsset.balance))} ${loanAsset.asset}`
: "--";
}
if (loanFeeInput) {
loanFeeInput.value = atomicToDecimal(LOAN_FEE_ATOMIC);
}
if (collateral?.kind === "nft") {
loanCollateralAmountInput.value = "1";
loanCollateralAmountInput.disabled = true;
} else if (loanCollateralAmountInput) {
loanCollateralAmountInput.disabled = false;
}
const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value);
const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10);
if (loanTotalRepayment) {
loanTotalRepayment.textContent =
paymentAmount !== null && paymentAmount > 0n && paymentNumber > 0
? `${atomicToDecimal(paymentAmount * BigInt(paymentNumber))} ${loanAsset?.asset || ""}`.trim()
: "--";
}
}
function validateLoanOfferForm() {
if (!walletLoaded) {
return "Load a wallet before creating a loan offer.";
}
if (!registrationRegistered) {
return "Address registration is required before creating a loan offer.";
}
const loanAsset = selectedLoanAsset();
const collateral = selectedLoanCollateral();
if (!loanAsset) {
return "Select an asset to lend.";
}
if (!collateral) {
return "Select the collateral required from the borrower.";
}
const borrower = String(loanBorrowerInput?.value || "").trim();
if (!borrower) {
return "Enter the borrower wallet address.";
}
if (
borrower.toLowerCase() === String(activeWalletAddressShort || "").toLowerCase()
|| borrower.toLowerCase() === String(activeWalletAddressHex || "").toLowerCase()
) {
return "Borrower cannot be the active lender wallet.";
}
const loanAmount = decimalToAtomic(loanAmountInput?.value);
const collateralAmount = collateral.kind === "nft"
? ATOMIC_UNITS_PER_COIN
: decimalToAtomic(loanCollateralAmountInput?.value);
const paymentAmount = decimalToAtomic(loanPaymentAmountInput?.value);
const maxLateValue = decimalToAtomic(loanMaxLateValueInput?.value);
if (loanAmount === null || loanAmount <= 0n) {
return "Enter a valid loan amount.";
}
if (collateralAmount === null || collateralAmount <= 0n) {
return "Enter a valid collateral amount.";
}
if (paymentAmount === null || paymentAmount <= 0n) {
return "Enter a valid payment amount.";
}
if (maxLateValue === null) {
return "Enter a valid maximum overdue value.";
}
if (paymentAmount > loanAmount) {
return "A single payment cannot exceed the loan amount.";
}
const paymentNumber = Number.parseInt(loanPaymentNumberInput?.value || "0", 10);
const gracePeriod = Number.parseInt(loanGracePeriodInput?.value || "0", 10);
if (!Number.isInteger(paymentNumber) || paymentNumber < 1 || paymentNumber > 255) {
return "Number of payments must be between 1 and 255.";
}
if (!Number.isInteger(gracePeriod) || gracePeriod < 0 || gracePeriod > paymentNumber) {
return "Missed payments allowed must be between 0 and the payment count.";
}
if (paymentAmount * BigInt(paymentNumber) < loanAmount) {
return "Scheduled payments must repay at least the loan principal.";
}
if (maxLateValue > loanAmount) {
return "Maximum overdue value cannot exceed the loan amount.";
}
const startTimestamp = loanStartTimestamp();
if (startTimestamp === null || startTimestamp > 4294967295) {
return "Enter a valid loan start date and time.";
}
if (loanAmount > loanAsset.balance) {
return "Loan amount exceeds the available asset balance.";
}
const baseBalance = baseCoinBalanceAtomic();
if (isBaseTransferAsset(loanAsset.asset)) {
if (loanAmount + LOAN_FEE_ATOMIC > loanAsset.balance) {
return `The loan amount and ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} fee exceed the available balance.`;
}
} else if (baseBalance < LOAN_FEE_ATOMIC) {
return `Creating a loan requires ${atomicToDecimal(LOAN_FEE_ATOMIC)} ${activeBaseCoin()} for the lender fee.`;
}
return "";
}
function loanOfferPayload() {
const loanAsset = selectedLoanAsset();
const collateral = selectedLoanCollateral();
const collateralAmount = collateral.kind === "nft"
? ATOMIC_UNITS_PER_COIN
: decimalToAtomic(loanCollateralAmountInput.value);
return {
broadcastNode: activeBroadcastNode(),
loanCoin: loanAsset.asset,
loanAmount: String(decimalToAtomic(loanAmountInput.value)),
borrower: String(loanBorrowerInput.value || "").trim(),
collateral: collateral.wireName,
collateralAmount: String(collateralAmount),
paymentPeriod: String(loanPaymentPeriodSelect.value || "d"),
paymentNumber: Number.parseInt(loanPaymentNumberInput.value, 10),
paymentAmount: String(decimalToAtomic(loanPaymentAmountInput.value)),
gracePeriod: Number.parseInt(loanGracePeriodInput.value, 10),
maxLateValue: String(decimalToAtomic(loanMaxLateValueInput.value)),
startTimestamp: loanStartTimestamp()
};
}
function clearLoanOfferForm() {
loanAmountInput.value = "";
loanBorrowerInput.value = "";
loanCollateralAmountInput.value = "";
loanPaymentPeriodSelect.value = "d";
loanPaymentNumberInput.value = "1";
loanPaymentAmountInput.value = "";
loanGracePeriodInput.value = "0";
loanMaxLateValueInput.value = "";
loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000);
updateLoanOfferDerivedFields();
}
function setSavedLoanOfferPath(path = "") {
if (loanSavedPathInput) {
loanSavedPathInput.value = path;
}
loanSavedPathPanel?.classList.toggle("hidden", !path);
}
async function saveLoanOffer() {
const validationError = validateLoanOfferForm();
if (validationError) {
setLoanOfferMessage(validationError, "error");
return;
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setLoanOfferMessage("Wallet bridge is not available.", "error");
return;
}
const walletKey = await requestSigningWalletKey();
if (guiRuntimeSettings.require_key_before_signing !== false && !walletKey) {
setLoanOfferMessage("Signing cancelled.");
return;
}
const button = document.querySelector("[data-loan-offer-save]");
button.disabled = true;
setSavedLoanOfferPath("");
setLoanOfferMessage("Signing and saving loan offer...");
try {
const result = await invoke("save_loan_offer", {
...loanOfferPayload(),
walletKey
});
clearLoanOfferForm();
setSavedLoanOfferPath(result.file_path || "");
setLoanOfferMessage(
`Loan offer signed and saved as ${result.review?.file_name || "a local transaction file"}. Send this file to the borrower for review and signature.`,
"success"
);
if (typeof refreshUnbroadcastTransactions === "function") {
refreshUnbroadcastTransactions();
}
} catch (error) {
setLoanOfferMessage(String(error), "error");
} finally {
button.disabled = false;
}
}
function prepareLoanOfferForm() {
if (!loanStartInput.value) {
loanStartInput.value = loanLocalInputValue(Date.now() + 24 * 60 * 60 * 1000);
}
setLoanOfferMessage("");
loadLoanOfferAssets().catch(() => {
// The form displays the lookup error.
});
updateLoanOfferDerivedFields();
}
function resetLoanOfferSession() {
loanOfferLoadPromise = null;
loanCollateralAssets = [];
loanNftKeys = new Set();
loanOfferAssetsKey = "";
clearLoanOfferForm();
setSavedLoanOfferPath("");
loanAssetSelect.innerHTML = "";
loanCollateralSelect.innerHTML = "";
setLoanOfferMessage("");
}
[
loanAssetSelect,
loanCollateralSelect,
loanAmountInput,
loanCollateralAmountInput,
loanPaymentPeriodSelect,
loanPaymentNumberInput,
loanPaymentAmountInput,
loanGracePeriodInput,
loanMaxLateValueInput,
loanStartInput
].forEach((element) => element?.addEventListener("input", () => {
setLoanOfferMessage("");
updateLoanOfferDerivedFields();
}));
loanBorrowerInput?.addEventListener("input", () => setLoanOfferMessage(""));
document.querySelector("[data-loan-offer-save]")?.addEventListener("click", saveLoanOffer);
loanCopyPathButton?.addEventListener("click", async () => {
const path = String(loanSavedPathInput?.value || "");
if (!path || !(await writeClipboardText(path))) {
setLoanOfferMessage("Unable to copy the saved loan offer path.", "error");
return;
}
const original = loanCopyPathButton.textContent;
loanCopyPathButton.textContent = "Copied";
setTimeout(() => {
loanCopyPathButton.textContent = original;
}, 1200);
});