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