2026-07-01 16:01:30 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 14:46:52 +00:00
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 16:01:30 +00:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 14:46:52 +00:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 16:01:30 +00:00
|
|
|
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 `
|
|
|
|
|
<button class="loan-card ${selected ? "active" : ""}" type="button" data-loan-contract="${escapeLoanText(contract.contract)}">
|
|
|
|
|
<div class="loan-card-top">
|
|
|
|
|
<div>
|
|
|
|
|
<strong>Loan ${escapeLoanText(loanShort(contract.contract, 6, 6))}</strong>
|
|
|
|
|
<span>${activeLoanRole(contract) === "lender" ? "Borrower" : "Lender"} ${escapeLoanText(loanShort(counterparty, 8, 8))}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="loan-badges">
|
|
|
|
|
<span class="role-badge ${role === "lender" ? "lending" : "borrowing"}">${escapeLoanText(loanRoleLabel(role))}</span>
|
|
|
|
|
<span class="loan-status ${loanStatusClass(contract.status)}">${escapeLoanText(loanStatusLabel(contract.status))}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="loan-metrics">
|
|
|
|
|
<div><span>Principal</span><b>${escapeLoanText(principal)}</b></div>
|
|
|
|
|
<div><span>Remaining</span><b>${escapeLoanText(remaining)}</b></div>
|
|
|
|
|
<div><span>Payments</span><b>${escapeLoanText(payments)}</b></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="loan-next-due">
|
|
|
|
|
<span>Next payment due by:</span>
|
|
|
|
|
<b>${escapeLoanText(nextDue)}</b>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectedLoanContract() {
|
|
|
|
|
return loansContracts.find((contract) => contract.contract === selectedLoanContractHash) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detailItem(label, value) {
|
|
|
|
|
return `<div><span>${escapeLoanText(label)}</span><b>${escapeLoanText(value || "--")}</b></div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderLoanPaymentHistory(contract) {
|
|
|
|
|
if (!loanPaymentHistory) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payments = Array.isArray(contract?.payments) ? contract.payments : [];
|
|
|
|
|
if (!payments.length) {
|
|
|
|
|
loanPaymentHistory.innerHTML = '<div class="subtle-state">No confirmed payments recorded for this loan.</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
loanPaymentHistory.innerHTML = `
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Date</th>
|
|
|
|
|
<th>Amount</th>
|
|
|
|
|
<th>Payer</th>
|
|
|
|
|
<th>Tx Hash</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
${payments.map((payment) => `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>${escapeLoanText(payment.date || "--")}</td>
|
|
|
|
|
<td>${escapeLoanText(payment.amount || "0")} ${escapeLoanText(contract.loan_asset || "")}</td>
|
|
|
|
|
<td>${escapeLoanText(loanShort(payment.payer || "", 8, 8))}</td>
|
|
|
|
|
<td>${escapeLoanText(loanShort(payment.txid || "", 8, 8))}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`).join("")}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = '<div class="subtle-state">No loan selected.</div>';
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-16 14:46:52 +00:00
|
|
|
const walletToken = activeWalletCacheToken();
|
|
|
|
|
const key = `${walletToken}|local-loans`;
|
2026-07-01 16:01:30 +00:00
|
|
|
if (!force && loansLoadedForKey === key) {
|
|
|
|
|
renderLoansPage();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (loansLoadInFlight) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loansLoadInFlight = true;
|
|
|
|
|
loansStateMessage = "Loading loans...";
|
|
|
|
|
renderLoansPage();
|
|
|
|
|
try {
|
2026-07-16 14:46:52 +00:00
|
|
|
const contracts = await loadLocalLoanContractsFromCache(walletToken);
|
|
|
|
|
loansContracts = await applyRemoteCollateralStatuses(Array.isArray(contracts) ? contracts : []);
|
2026-07-01 16:01:30 +00:00
|
|
|
loansLoadedForKey = key;
|
|
|
|
|
selectedLoanContractHash = loansContracts[0]?.contract || "";
|
|
|
|
|
loansStateMessage = loansContracts.length
|
|
|
|
|
? ""
|
2026-07-16 14:46:52 +00:00
|
|
|
: "No locally synced loan contracts found for this wallet.";
|
2026-07-01 16:01:30 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
loansContracts = [];
|
|
|
|
|
selectedLoanContractHash = "";
|
2026-07-16 14:46:52 +00:00
|
|
|
loansStateMessage = `Local loan cache lookup failed: ${String(error)}`;
|
2026-07-01 16:01:30 +00:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
});
|