let loansContracts = []; let loansLoadedForKey = ""; let loansLoadInFlight = false; let selectedLoanContractHash = ""; let activeLoansStatusFilter = "all"; let loansStateMessage = ""; function loanFormatAtomic(value) { if (typeof atomicToDecimal === "function") { return atomicToDecimal(BigInt(value || "0")); } if (typeof formatAtomicBalance === "function") { return formatAtomicBalance(value || "0"); } return String(value || "0"); } function loanShort(value, left = 8, right = 8) { if (typeof truncateMiddle === "function") { return truncateMiddle(value, left, right); } const text = String(value || ""); return text.length > left + right + 3 ? `${text.slice(0, left)}...${text.slice(-right)}` : text; } function escapeLoanText(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function activeLoanRole(contract) { const active = String(activeWalletAddressShort || "").trim().toLowerCase(); if (String(contract.lender || "").trim().toLowerCase() === active) { return "lender"; } if (String(contract.borrower || "").trim().toLowerCase() === active) { return "borrower"; } return "unknown"; } function loanRoleLabel(role) { return role === "lender" ? "Lending" : role === "borrower" ? "Borrowing" : "Related"; } function loanCounterparty(contract) { return activeLoanRole(contract) === "lender" ? contract.borrower : contract.lender; } function loanStatusClass(status) { const normalized = String(status || "").toLowerCase(); if (normalized === "delinquent") { return "due"; } if (normalized === "inactive") { return "claimable"; } return "active"; } function loanStatusLabel(status) { const normalized = String(status || "").toLowerCase(); if (normalized === "inactive") { return "Completed"; } return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Unknown"; } function loanPaymentCount(contract) { return Array.isArray(contract.payments) ? contract.payments.length : 0; } function loanPeriodLabel(period) { const normalized = String(period || "").trim().toLowerCase(); if (normalized === "d" || normalized === "daily") { return "daily"; } if (normalized === "w" || normalized === "weekly") { return "weekly"; } if (normalized === "m" || normalized === "monthly") { return "monthly"; } return normalized || "unknown"; } function loanAmountLabel(value, asset) { return `${loanFormatAtomic(value)} ${String(asset || "").trim()}`.trim(); } function addUtcMonths(timestampSeconds, monthsToAdd) { const start = new Date(Number(timestampSeconds || 0) * 1000); if (Number.isNaN(start.getTime())) { return null; } const year = start.getUTCFullYear(); const month = start.getUTCMonth() + Number(monthsToAdd || 0); const day = start.getUTCDate(); const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); return new Date(Date.UTC( year, month, Math.min(day, lastDay), start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds() )); } function loanDueDateForPayment(contract, paymentIndex) { const startTimestamp = Number(contract.start_timestamp || 0); if (!startTimestamp || !paymentIndex) { return null; } const period = String(contract.payment_period || "").trim().toLowerCase(); if (period === "daily") { return new Date((startTimestamp + paymentIndex * 86_400) * 1000); } if (period === "weekly") { return new Date((startTimestamp + paymentIndex * 7 * 86_400) * 1000); } if (period === "monthly") { return addUtcMonths(startTimestamp, paymentIndex); } return null; } function formatLoanDueDate(date) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) { return "--"; } return date.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }); } function nextLoanPaymentDueLabel(contract) { if (String(contract.status || "").toLowerCase() === "inactive") { return "No remaining payments"; } const paymentAmount = BigInt(contract.payment_amount || "0"); const confirmedPaid = BigInt(contract.confirmed_paid || "0"); const paymentNumber = Number(contract.payment_number || 0); if (!paymentNumber || paymentAmount <= 0n) { return "--"; } const completedPayments = Number(confirmedPaid / paymentAmount); if (completedPayments >= paymentNumber) { return "No remaining payments"; } return formatLoanDueDate(loanDueDateForPayment(contract, completedPayments + 1)); } function loanTransactionTime(transaction) { return Number( transaction?.unsigned_loan_contract?.time ?? transaction?.unsigned_loan_contract?.timestamp ?? transaction?.unsigned_contract_payment?.time ?? transaction?.unsigned_contract_payment?.timestamp ?? transaction?.unsigned_collateral_claim?.time ?? transaction?.unsigned_collateral_claim?.timestamp ?? 0 ) || 0; } function loanContractRecordFromCache(txid, record) { const tx = record?.transaction?.unsigned_loan_contract; if (!tx) { return null; } const contractHash = txid || record?.txid || ""; const totalDue = BigInt(tx.payment_amount || 0) * BigInt(tx.payment_number || 0); return { contract: contractHash, status: "active", start_timestamp: Number(tx.time ?? tx.timestamp ?? 0) || 0, lender: tx.lender || "", borrower: tx.borrower || "", loan_asset: tx.loan_coin || activeBaseCoin(), loan_amount: String(tx.loan_amount || 0), collateral: tx.collateral || activeBaseCoin(), collateral_amount: String(tx.collateral_amount || 0), payment_period: loanPeriodLabel(tx.payment_period), payment_number: Number(tx.payment_number || 0), payment_amount: String(tx.payment_amount || 0), grace_period: Number(tx.grace_period || 0), max_late_value: String(tx.max_late_value || 0), confirmed_paid: "0", pending_paid: "0", remaining_balance: String(totalDue), remaining_after_pending: String(totalDue), payments: [], collateral_claimed_by: "" }; } function loanPaymentsDueCount(contract, nowSeconds = Math.floor(Date.now() / 1000)) { const startTimestamp = Number(contract?.start_timestamp || 0); const paymentNumber = Number(contract?.payment_number || 0); if (!startTimestamp || !paymentNumber || nowSeconds <= startTimestamp) { return 0; } const period = String(contract.payment_period || "").trim().toLowerCase(); let due = 0; if (period === "daily") { due = Math.floor((nowSeconds - startTimestamp) / 86_400); } else if (period === "weekly") { due = Math.floor((nowSeconds - startTimestamp) / (7 * 86_400)); } else if (period === "monthly") { const start = new Date(startTimestamp * 1000); const now = new Date(nowSeconds * 1000); due = (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + (now.getUTCMonth() - start.getUTCMonth()); const dueDate = addUtcMonths(startTimestamp, due); if (dueDate && now.getTime() < dueDate.getTime()) { due -= 1; } } return Math.max(0, Math.min(paymentNumber, due)); } function loanFinalGracePeriodPassed(contract, nowSeconds = Math.floor(Date.now() / 1000)) { const paymentNumber = Number(contract?.payment_number || 0); const gracePeriod = Number(contract?.grace_period || 0); if (!paymentNumber) { return false; } const dueDate = loanDueDateForPayment(contract, paymentNumber + gracePeriod); if (!dueDate) { return false; } return nowSeconds >= Math.floor(dueDate.getTime() / 1000) + 86_400; } function finalizeLocalLoanContract(contract) { const totalPaid = (contract.payments || []).reduce( (sum, payment) => sum + BigInt(payment.amount_atomic || payment.amount || 0), 0n ); const paymentAmount = BigInt(contract.payment_amount || 0); const totalDue = paymentAmount * BigInt(contract.payment_number || 0); const remaining = totalDue > totalPaid ? totalDue - totalPaid : 0n; const shouldHavePaid = BigInt(loanPaymentsDueCount(contract)) * paymentAmount; const claimed = Boolean(contract.collateral_claimed_by); contract.payments.sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0)); contract.confirmed_paid = String(totalPaid); contract.pending_paid = "0"; contract.remaining_balance = String(remaining); contract.remaining_after_pending = String(remaining); contract.status = claimed ? "inactive" : (shouldHavePaid > totalPaid ? "delinquent" : "active"); return contract; } async function applyRemoteCollateralStatuses(contracts) { const invoke = window.__TAURI__?.core?.invoke; const broadcastNode = typeof activeBroadcastNode === "function" ? activeBroadcastNode() : ""; if (!invoke || !broadcastNode || !Array.isArray(contracts) || !contracts.length) { return contracts; } await Promise.all(contracts.map(async (contract) => { if (!contract?.contract || contract.collateral_claimed_by) { return; } try { const status = await invoke("collateral_status", { broadcastNode, contractHash: contract.contract }); if (status?.claimed) { contract.collateral_claimed_by = "remote"; contract.status = "inactive"; } } catch (_) { // Keep the locally synced view usable if the lightweight status check is unavailable. } })); return contracts; } async function loadLocalLoanContractsFromCache(walletToken = activeWalletCacheToken()) { if (typeof readTransactionIndexObject !== "function" || typeof readTransactionCacheRecords !== "function") { return []; } const index = await readTransactionIndexObject(walletToken); let typeTxids = [7, 8, 9].flatMap((type) => Array.isArray(index.type_to_txids?.[String(type)]) ? index.type_to_txids[String(type)] : []); if (!typeTxids.length) { typeTxids = Array.isArray(index.ordered_txids) ? index.ordered_txids : []; } const uniqueTxids = Array.from(new Set(typeTxids.filter(Boolean))); if (!uniqueTxids.length) { return []; } const cache = await readTransactionCacheRecords(index, uniqueTxids, walletToken); const contracts = new Map(); Object.entries(cache.transactions || {}).forEach(([txid, record]) => { const contract = loanContractRecordFromCache(txid, record); if (contract && (isActiveWalletAddress(contract.lender) || isActiveWalletAddress(contract.borrower))) { contracts.set(contract.contract, contract); } }); Object.entries(cache.transactions || {}).forEach(([txid, record]) => { const payment = record?.transaction?.unsigned_contract_payment; if (payment?.contract_hash && contracts.has(payment.contract_hash)) { const contract = contracts.get(payment.contract_hash); contract.payments.push({ txid, amount: String(payment.payback_amount || 0), amount_atomic: String(payment.payback_amount || 0), payer: payment.address || "", timestamp: Number(payment.time ?? payment.timestamp ?? 0) || 0, date: formatHistoryDate(loanTransactionTime(record.transaction)) }); } const claim = record?.transaction?.unsigned_collateral_claim; if (claim?.contract_hash && contracts.has(claim.contract_hash)) { contracts.get(claim.contract_hash).collateral_claimed_by = claim.address || ""; } }); return Array.from(contracts.values()).map(finalizeLocalLoanContract); } async function loadLocalActiveBorrowerLoanContracts(walletToken = activeWalletCacheToken()) { const activeAddress = String(activeWalletAddressShort || "").trim().toLowerCase(); const contracts = await applyRemoteCollateralStatuses(await loadLocalLoanContractsFromCache(walletToken)); return contracts.filter((contract) => ( String(contract.borrower || "").trim().toLowerCase() === activeAddress && String(contract.status || "").toLowerCase() !== "inactive" && BigInt(contract.remaining_after_pending || 0) > 0n )); } function localCollateralClaimReason(contract) { if (!contract || String(contract.status || "").toLowerCase() === "inactive") { return ""; } const role = activeLoanRole(contract); const remainingBalance = BigInt(contract.remaining_balance || "0"); if (role === "borrower") { return remainingBalance === 0n ? "Loan is fully repaid, so the borrower can reclaim collateral." : ""; } if (role !== "lender") { return ""; } if (remainingBalance > 0n && loanFinalGracePeriodPassed(contract)) { return "The full loan term plus allowed grace has passed and an unpaid balance remains."; } const paymentsDue = loanPaymentsDueCount(contract); const gracePeriod = Number(contract.grace_period || 0); if (paymentsDue <= gracePeriod) { return ""; } const confirmedPaid = BigInt(contract.confirmed_paid || "0"); const paymentAmount = BigInt(contract.payment_amount || "0"); const maxLateValue = BigInt(contract.max_late_value || "0"); const shouldHavePaid = BigInt(paymentsDue) * paymentAmount; const overdueValue = shouldHavePaid > confirmedPaid ? shouldHavePaid - confirmedPaid : 0n; return overdueValue > maxLateValue ? `Borrower is late by ${overdueValue - maxLateValue} atomic units beyond the allowed threshold.` : ""; } async function loadLocalClaimableCollateralContracts(walletToken = activeWalletCacheToken()) { const contracts = await applyRemoteCollateralStatuses(await loadLocalLoanContractsFromCache(walletToken)); return contracts .map((contract) => { const reason = localCollateralClaimReason(contract); if (!reason) { return null; } const role = activeLoanRole(contract); return { contract: contract.contract, role, reason, lender: contract.lender, borrower: contract.borrower, loan_asset: contract.loan_asset, loan_amount: contract.loan_amount, collateral: contract.collateral, collateral_amount: contract.collateral_amount, confirmed_paid: contract.confirmed_paid, pending_paid: contract.pending_paid, remaining_balance: contract.remaining_balance, remaining_after_pending: contract.remaining_after_pending, fee: String(COLLATERAL_CLAIM_FEE_ATOMIC || 300000000n) }; }) .filter(Boolean) .sort((left, right) => left.contract.localeCompare(right.contract)); } function sortedLoans() { const query = String(loansSearch?.value || "").trim().toLowerCase(); const roleFilter = loansRoleFilter?.value || "all"; return loansContracts .filter((contract) => { const role = activeLoanRole(contract); const status = String(contract.status || "").toLowerCase(); const searchText = [ contract.contract, contract.lender, contract.borrower, contract.loan_asset, contract.collateral, status, role ].join(" ").toLowerCase(); if (roleFilter !== "all" && role !== roleFilter) { return false; } if (activeLoansStatusFilter !== "all" && status !== activeLoansStatusFilter) { return false; } return !query || searchText.includes(query); }) .sort((left, right) => Number(right.start_timestamp || 0) - Number(left.start_timestamp || 0)); } function renderLoanCard(contract) { const role = activeLoanRole(contract); const selected = contract.contract === selectedLoanContractHash; const counterparty = loanCounterparty(contract); const principal = loanAmountLabel(contract.loan_amount, contract.loan_asset); const remaining = loanAmountLabel(contract.remaining_after_pending, contract.loan_asset); const payments = `${loanPaymentCount(contract)} / ${contract.payment_number || 0}`; const nextDue = nextLoanPaymentDueLabel(contract); return ` `; } function selectedLoanContract() { return loansContracts.find((contract) => contract.contract === selectedLoanContractHash) || null; } function detailItem(label, value) { return `
${escapeLoanText(label)}${escapeLoanText(value || "--")}
`; } function renderLoanPaymentHistory(contract) { if (!loanPaymentHistory) { return; } const payments = Array.isArray(contract?.payments) ? contract.payments : []; if (!payments.length) { loanPaymentHistory.innerHTML = '
No confirmed payments recorded for this loan.
'; return; } loanPaymentHistory.innerHTML = ` ${payments.map((payment) => ` `).join("")}
Date Amount Payer Tx Hash
${escapeLoanText(payment.date || "--")} ${escapeLoanText(payment.amount || "0")} ${escapeLoanText(contract.loan_asset || "")} ${escapeLoanText(loanShort(payment.payer || "", 8, 8))} ${escapeLoanText(loanShort(payment.txid || "", 8, 8))}
`; } function renderLoanDetail() { const contract = selectedLoanContract(); if (!loanDetailGrid) { return; } if (!contract) { loanDetailGrid.innerHTML = detailItem("Selected Loan", "Select a loan to view details."); if (loanPaymentHistory) { loanPaymentHistory.innerHTML = '
No loan selected.
'; } loanCopyContractButton?.toggleAttribute("disabled", true); loanMakePaymentButton?.toggleAttribute("disabled", true); loanClaimCollateralButton?.toggleAttribute("disabled", true); return; } const role = activeLoanRole(contract); const collateral = loanAmountLabel(contract.collateral_amount, contract.collateral); const payment = loanAmountLabel(contract.payment_amount, contract.loan_asset); const confirmed = loanAmountLabel(contract.confirmed_paid, contract.loan_asset); const pending = loanAmountLabel(contract.pending_paid, contract.loan_asset); const remaining = loanAmountLabel(contract.remaining_after_pending, contract.loan_asset); loanDetailGrid.innerHTML = [ detailItem("Contract Hash", contract.contract), detailItem("Role", loanRoleLabel(role)), detailItem("Status", loanStatusLabel(contract.status)), detailItem("Principal", loanAmountLabel(contract.loan_amount, contract.loan_asset)), detailItem("Payment Amount", payment), detailItem("Collateral", collateral), detailItem("Payment Schedule", `${contract.payment_number || 0} ${contract.payment_period || "payments"}`), detailItem("Confirmed Paid", confirmed), detailItem("Pending Total", pending), detailItem("Remaining", remaining), detailItem("Lender", contract.lender), detailItem("Borrower", contract.borrower) ].join(""); renderLoanPaymentHistory(contract); loanCopyContractButton?.toggleAttribute("disabled", false); loanMakePaymentButton?.toggleAttribute( "disabled", !(role === "borrower" && String(contract.status || "").toLowerCase() !== "inactive") ); loanClaimCollateralButton?.toggleAttribute("disabled", String(contract.status || "").toLowerCase() === "inactive"); } function renderLoansPage() { if (!loansList) { return; } const rows = sortedLoans(); if (!selectedLoanContractHash && rows.length) { selectedLoanContractHash = rows[0].contract; } if (selectedLoanContractHash && !loansContracts.some((contract) => contract.contract === selectedLoanContractHash)) { selectedLoanContractHash = rows[0]?.contract || ""; } loansList.innerHTML = rows.map(renderLoanCard).join(""); if (!walletLoaded) { loansState && (loansState.textContent = "Load a wallet to view loans."); } else if (loansLoadInFlight && !loansContracts.length) { loansState && (loansState.textContent = "Loading loans..."); } else if (loansStateMessage) { loansState && (loansState.textContent = loansStateMessage); } else if (!loansContracts.length) { loansState && (loansState.textContent = "No loan contracts found for this wallet."); } else if (!rows.length) { loansState && (loansState.textContent = "No loans match the current filters."); } else { loansState && (loansState.textContent = `${rows.length} loan${rows.length === 1 ? "" : "s"} shown`); } renderLoanDetail(); } async function loadLoansPage({ force = false } = {}) { if (!walletLoaded) { loansContracts = []; selectedLoanContractHash = ""; loansStateMessage = "Load a wallet to view loans."; renderLoansPage(); return; } const walletToken = activeWalletCacheToken(); const key = `${walletToken}|local-loans`; if (!force && loansLoadedForKey === key) { renderLoansPage(); return; } if (loansLoadInFlight) { return; } loansLoadInFlight = true; loansStateMessage = "Loading loans..."; renderLoansPage(); try { const contracts = await loadLocalLoanContractsFromCache(walletToken); loansContracts = await applyRemoteCollateralStatuses(Array.isArray(contracts) ? contracts : []); loansLoadedForKey = key; selectedLoanContractHash = loansContracts[0]?.contract || ""; loansStateMessage = loansContracts.length ? "" : "No locally synced loan contracts found for this wallet."; } catch (error) { loansContracts = []; selectedLoanContractHash = ""; loansStateMessage = `Local loan cache lookup failed: ${String(error)}`; } finally { loansLoadInFlight = false; renderLoansPage(); } } function prepareLoansPage({ force = false } = {}) { renderLoansPage(); loadLoansPage({ force }); } function resetLoansPage() { loansContracts = []; loansLoadedForKey = ""; loansLoadInFlight = false; selectedLoanContractHash = ""; activeLoansStatusFilter = "all"; loansStateMessage = ""; loansSearch && (loansSearch.value = ""); loansRoleFilter && (loansRoleFilter.value = "all"); loansStatusButtons.forEach((button) => { button.classList.toggle("active", button.dataset.loansStatusFilter === "all"); }); renderLoansPage(); } loansSearch?.addEventListener("input", renderLoansPage); loansRoleFilter?.addEventListener("change", renderLoansPage); loansRefreshButton?.addEventListener("click", () => loadLoansPage({ force: true })); loansCreateButton?.addEventListener("click", () => { setActiveView("send"); setActiveTransactionType("Create Loan"); }); loansStatusButtons.forEach((button) => { button.addEventListener("click", () => { activeLoansStatusFilter = button.dataset.loansStatusFilter || "all"; loansStatusButtons.forEach((item) => item.classList.toggle("active", item === button)); selectedLoanContractHash = ""; renderLoansPage(); }); }); loansList?.addEventListener("click", (event) => { const card = event.target.closest("[data-loan-contract]"); if (!card) { return; } selectedLoanContractHash = card.dataset.loanContract || ""; renderLoansPage(); }); loanCopyContractButton?.addEventListener("click", async () => { const contract = selectedLoanContract(); if (!contract) { return; } const copied = typeof writeClipboardText === "function" ? await writeClipboardText(contract.contract) : false; if (!copied) { return; } const original = loanCopyContractButton.textContent; loanCopyContractButton.textContent = "Copied"; setTimeout(() => { loanCopyContractButton.textContent = original; }, 900); }); loanMakePaymentButton?.addEventListener("click", () => { const contract = selectedLoanContract(); if (typeof openTransactionType === "function") { openTransactionType("Loan Payment"); } else { setActiveView("send"); setActiveTransactionType("Loan Payment"); showTransactionForm("Loan Payment"); } const selectContract = () => { if (contract?.contract && loanPaymentContractSelect) { loanPaymentContractSelect.value = contract.contract; updateLoanPaymentDerivedFields(true); } }; if (typeof loadLoanPaymentContracts === "function") { loadLoanPaymentContracts(false).then(selectContract).catch(() => {}); } else { setTimeout(selectContract, 0); } }); loanClaimCollateralButton?.addEventListener("click", () => { const contract = selectedLoanContract(); if (typeof openTransactionType === "function") { openTransactionType("Collateral Claim"); } else { setActiveView("send"); setActiveTransactionType("Collateral Claim"); showTransactionForm("Collateral Claim"); } const selectContract = () => { if (contract?.contract && collateralClaimContractSelect) { collateralClaimContractSelect.value = contract.contract; updateCollateralClaimDetails(); } }; if (typeof loadCollateralClaimContracts === "function") { loadCollateralClaimContracts(false).then(selectContract).catch(() => {}); } else { setTimeout(selectContract, 0); } });