diff --git a/components/dashboard/DepositFiatButton.tsx b/components/dashboard/DepositFiatButton.tsx new file mode 100644 index 0000000..d0852d6 --- /dev/null +++ b/components/dashboard/DepositFiatButton.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { + getConnectedWallet, + getWalletProviderLabel, +} from "@/lib/stellar/wallet"; +import { getSep24Config } from "@/lib/stellar/sep24-config"; +import { + authenticate, + discoverAnchor, + pollTransaction, + startInteractiveDeposit, + SEP24_STATUS_LABEL, + type AnchorEndpoints, + type Sep24Transaction, +} from "@/lib/stellar/sep24"; +import { FocusTrap } from "@/components/ui/FocusTrap"; + +interface DepositFiatButtonProps { + /** User wallet address (G...). If null the button prompts to connect. */ + walletAddress: string | null; +} + +type Step = + | "idle" + | "connecting" + | "discovering" + | "authenticating" + | "initiating" + | "interactive" + | "done" + | "error"; + +const STEP_LABEL: Record = { + idle: "", + connecting: "Connecting wallet…", + discovering: "Discovering anchor…", + authenticating: "Authenticating (SEP-10)…", + initiating: "Starting deposit…", + interactive: "Complete the steps in the anchor window", + done: "", + error: "", +}; + +export function DepositFiatButton({ walletAddress }: DepositFiatButtonProps) { + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + const [step, setStep] = useState("idle"); + const [error, setError] = useState(null); + const [tx, setTx] = useState(null); + const [interactiveUrl, setInteractiveUrl] = useState(null); + const abortRef = useRef(null); + + const config = getSep24Config(); + const busy = step !== "idle" && step !== "done" && step !== "error"; + const modalRef = useRef(null); + const startButtonRef = useRef(null); + const closeButtonRef = useRef(null); + + const reset = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setStep("idle"); + setError(null); + setTx(null); + setInteractiveUrl(null); + setAmount(""); + }, []); + + // Clean up any in-flight polling when the modal closes / unmounts. + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && open && !busy) { + setOpen(false); + reset(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, busy, reset]); + + // Return focus to trigger when modal closes + useEffect(() => { + if (!open) { + const trigger = document.querySelector('[data-deposit-trigger]') as HTMLElement; + trigger?.focus(); + } else { + // Focus the start button or close button when modal opens + setTimeout(() => { + if (step === "idle" && startButtonRef.current) { + startButtonRef.current.focus(); + } else if (closeButtonRef.current) { + closeButtonRef.current.focus(); + } + }, 0); + } + }, [open, step]); + + const startDeposit = useCallback(async () => { + setError(null); + setTx(null); + setInteractiveUrl(null); + + try { + // 1. Resolve the connected wallet (provider + address). + setStep("connecting"); + const wallet = await getConnectedWallet(); + const account = walletAddress ?? wallet.address; + + // 2. Discover anchor endpoints from stellar.toml. + setStep("discovering"); + const endpoints: AnchorEndpoints = await discoverAnchor(config); + + // 3. SEP-10 auth → JWT (wallet signs the challenge). + setStep("authenticating"); + const jwt = await authenticate(endpoints, account, { + provider: wallet.provider, + config, + }); + + // 4. Start the interactive deposit → get the anchor's hosted URL. + setStep("initiating"); + const session = await startInteractiveDeposit(endpoints, jwt, { + account, + assetCode: config.assetCode, + amount: amount.trim() || undefined, + }); + + // 5. Open the interactive URL in a popup for the user to provide fiat + // payment details. Fall back to an inline link if it's blocked. + setInteractiveUrl(session.url); + const popup = window.open( + session.url, + "trustlend_sep24_deposit", + "width=480,height=720,menubar=no,toolbar=no" + ); + if (!popup) { + setError( + "Popup blocked — use the link below to open the secure anchor window." + ); + } + setStep("interactive"); + + // 6. Poll the anchor for status until the transfer reaches a terminal state. + const controller = new AbortController(); + abortRef.current = controller; + const finalTx = await pollTransaction( + endpoints, + jwt, + session.id, + (update) => setTx(update), + { signal: controller.signal } + ); + setTx(finalTx); + setStep("done"); + if (!popup?.closed) popup?.close(); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setError(err instanceof Error ? err.message : "Deposit failed."); + setStep("error"); + } + }, [amount, config, walletAddress]); + + if (!walletAddress) { + return ( +
+

💳 Deposit Fiat

+

+ Connect your Stellar wallet to deposit fiat and receive USDC via a Stellar Anchor. +

+
+ ); + } + + return ( + <> +
+
+
+

+ 💳 Deposit Fiat +

+

+ Deposit fiat currency and receive {config.assetCode} in your wallet via a + Stellar Anchor ({config.homeDomain}). +

+
+ +
+
+ + {open && ( +
{ + if (!busy) { + setOpen(false); + reset(); + } + }} + > + +
e.stopPropagation()} + style={{ + background: "#fff", + borderRadius: "1rem", + padding: "1.75rem", + width: "100%", + maxWidth: "440px", + boxShadow: "0 24px 60px rgba(15,23,42,0.25)", + }} + > +
+

+ Deposit Fiat → {config.assetCode} +

+ +
+

+ Powered by Stellar Anchor SEP-24. You'll sign a one-time login + challenge in {getWalletProviderLabel(getConnectedWalletProviderSafe())}, + then provide your payment details in the anchor's secure window. +

+ + {step === "idle" && ( + <> + + setAmount(e.target.value)} + placeholder="Leave blank to choose in the anchor window" + style={{ + width: "100%", + padding: "0.6rem 0.75rem", + borderRadius: "0.5rem", + border: "1px solid #e5e7eb", + fontSize: "0.9rem", + }} + /> + + + )} + + {busy && ( +
+
+

+ {STEP_LABEL[step]} +

+ {tx && ( +

+ {SEP24_STATUS_LABEL[tx.status] ?? tx.status} +

+ )} + {step === "interactive" && interactiveUrl && ( + + Reopen anchor window ↗ + + )} +
+ )} + + {step === "done" && tx && ( +
+
+ {tx.status === "completed" ? "🎉" : "ℹ️"} +
+

+ {SEP24_STATUS_LABEL[tx.status] ?? tx.status} +

+ {tx.amount_out && ( +

+ You receive: {tx.amount_out} {config.assetCode} + {tx.amount_fee ? ` (fee ${tx.amount_fee})` : ""} +

+ )} + {tx.stellar_transaction_id && ( +

+ Stellar TX: {tx.stellar_transaction_id.slice(0, 8)}... +

+ )} + {tx.more_info_url && ( + + View transaction details ↗ + + )} + +
+ )} + + {step === "error" && ( +
+

+ {error} +

+ +
+ )} + + {error && step === "interactive" && ( +

+ {error} +

+ )} +
+ +
+ )} + + ); +} + +// Wallet provider label is best-effort for the helper copy; default to Freighter. +function getConnectedWalletProviderSafe() { + if (typeof window === "undefined") return "freighter" as const; + const stored = window.localStorage.getItem("wallet_provider"); + return stored === "albedo" ? ("albedo" as const) : ("freighter" as const); +} + +const primaryBtnStyle: CSSProperties = { + width: "100%", + marginTop: "1.1rem", + padding: "0.7rem 1rem", + background: "linear-gradient(135deg, #10b981 0%, #059669 100%)", + color: "#fff", + border: "none", + borderRadius: "0.6rem", + fontSize: "0.9rem", + fontWeight: 700, + cursor: "pointer", +}; + +const spinnerStyle: CSSProperties = { + width: "32px", + height: "32px", + margin: "0 auto 0.75rem", + border: "3px solid #d1fae5", + borderTopColor: "#10b981", + borderRadius: "50%", + animation: "sep24-spin 0.8s linear infinite", +}; diff --git a/contracts/lending/src/lib.rs b/contracts/lending/src/lib.rs index a2d8b39..0795649 100644 --- a/contracts/lending/src/lib.rs +++ b/contracts/lending/src/lib.rs @@ -246,6 +246,8 @@ pub enum DataKey { LoanReputationTier(u32), /// Referral Rewards contract address (Issue #266) ReferralContract, + /// Grace period start timestamp for undercollateralized loans (Issue #157) + GracePeriodStart(u32), } /// Default platform fee = 1 % of interest (100 bps) until governance changes it. @@ -281,6 +283,9 @@ const MIN_COLLATERAL_FACTOR_BPS: u32 = 1000; /// Minimum borrow amount constraint to prevent spam/dust loans (1 XLM = 10_000_000 stroops). pub const MIN_BORROW_AMOUNT: i128 = 10_000_000; +/// Grace period before liquidation: 12 hours = 43,200 seconds (Issue #157) +const GRACE_PERIOD_SECONDS: u64 = 43_200; + // ─── Contract ───────────────────────────────────────────────────────────────── #[contract] @@ -625,9 +630,16 @@ impl LendingContract { panic!("Fee exceeds MAX_PLATFORM_FEE_BPS"); } + let old_fee_bps = Self::get_platform_fee_bps(env.clone()); env.storage() .instance() .set(&DataKey::PlatformFeeBps, &new_fee_bps); + + // Emit event for indexers + env.events().publish( + (symbol_short!("admin"), symbol_short!("fee_upd")), + (symbol_short!("platform"), old_fee_bps, new_fee_bps), + ); } pub fn get_uncollected_fees(env: Env) -> i128 { @@ -922,6 +934,27 @@ impl LendingContract { env.storage().persistent().set(&key, &new_entries); + // Check if health factor improved after deposit (Issue #157) + // If health factor is now >= 1.0, clear grace periods for all borrower's loans + let health_factor = Self::get_health_factor(env.clone(), borrower.clone()); + if health_factor >= 10_000 { + // Health factor is healthy, clear any active grace periods + let loan_count = Self::get_borrower_loan_count(env.clone(), borrower.clone()); + for i in 0..loan_count { + let loan_id = Self::get_borrower_loan_at(env.clone(), borrower.clone(), i); + let grace_key = DataKey::GracePeriodStart(loan_id); + if env.storage().persistent().has(&grace_key) { + env.storage().persistent().remove(&grace_key); + + // Emit grace period cleared event + env.events().publish( + (symbol_short!("grace"), symbol_short!("cleared")), + (loan_id, borrower.clone(), health_factor), + ); + } + } + } + env.events().publish( (symbol_short!("collat"), symbol_short!("deposit")), (borrower, asset, amount), @@ -1368,6 +1401,8 @@ impl LendingContract { } /// Mark a loan as defaulted (called by DefaultManagementContract or admin). + /// Now includes grace period check (Issue #157) - borrowers get 12 hours + /// to top up collateral before liquidation can proceed. pub fn mark_defaulted(env: Env, caller: Address, loan_id: u32) { caller.require_auth(); Self::assert_admin(&env, &caller); @@ -1377,13 +1412,26 @@ impl LendingContract { if loan.status != LoanStatus::Active { panic!("Only ACTIVE loans can be defaulted"); } + + // Check if loan is eligible for liquidation (respects 12-hour grace period) + if !Self::check_liquidation_eligibility(env.clone(), loan_id) { + panic!("Loan is not eligible for liquidation yet - grace period active or health factor is healthy"); + } + loan.status = LoanStatus::Defaulted; env.storage() .persistent() .set(&DataKey::Loan(loan_id), &loan); - env.events() - .publish((symbol_short!("loan"), symbol_short!("default")), loan_id); + // Clean up grace period tracking since loan is now defaulted + env.storage().persistent().remove(&DataKey::GracePeriodStart(loan_id)); + + // Emit liquidation event with health factor context + let health_factor = Self::get_health_factor(env.clone(), loan.borrower.clone()); + env.events().publish( + (symbol_short!("loan"), symbol_short!("default")), + (loan_id, health_factor), + ); } // ── Rate model switching ───────────────────────────────────────────────── @@ -1634,6 +1682,114 @@ impl LendingContract { total_debt } + /// Calculate the health factor for a borrower. + /// + /// Health Factor = (borrowing_power * 10_000) / total_active_debt + /// + /// - Health Factor >= 10_000 means the position is healthy (>= 1.0) + /// - Health Factor < 10_000 means undercollateralized (< 1.0) + /// - Returns u32::MAX if total_debt is 0 (no debt = infinitely healthy) + /// + /// Example: If borrowing_power = 100 XLM and debt = 80 XLM, + /// health_factor = (100 * 10_000) / 80 = 12_500 (1.25x) + pub fn get_health_factor(env: Env, borrower: Address) -> u32 { + let total_debt = Self::get_total_active_debt_of_borrower(&env, &borrower); + + // No debt means infinitely healthy + if total_debt <= 0 { + return u32::MAX; + } + + let entries = Self::get_user_collateral_entries(env.clone(), borrower); + let borrowing_power = Self::compute_borrowing_power_from_entries(&env, &entries); + + // If no borrowing power but has debt, health factor is 0 + if borrowing_power <= 0 { + return 0; + } + + // Calculate: (borrowing_power * 10_000) / total_debt + let numerator = borrowing_power + .checked_mul(10_000) + .expect("Overflow calculating health factor numerator"); + + let health_factor = numerator + .checked_div(total_debt) + .expect("Division by zero in health factor"); + + // Cap at u32::MAX for very healthy positions + if health_factor > u32::MAX as i128 { + u32::MAX + } else { + health_factor as u32 + } + } + + /// Check if a loan is eligible for liquidation (Issue #157). + /// + /// Returns true if the loan can be liquidated, false otherwise. + /// + /// Liquidation eligibility requirements: + /// 1. The loan must have an active borrower with health factor < 1.0 (< 10_000 bps) + /// 2. If this is the first time undercollateralized, grace period starts + /// 3. After 12-hour grace period expires, liquidation is allowed + /// + /// This gives borrowers time to top up collateral before liquidation. + pub fn check_liquidation_eligibility(env: Env, loan_id: u32) -> bool { + let loan = Self::get_loan(env.clone(), loan_id); + + // Only active loans can be liquidated + if loan.status != LoanStatus::Active { + return false; + } + + let health_factor = Self::get_health_factor(env.clone(), loan.borrower.clone()); + + // If health factor >= 1.0 (10_000 bps), position is healthy + if health_factor >= 10_000 { + // Clear any existing grace period since position is now healthy + if env.storage().persistent().has(&DataKey::GracePeriodStart(loan_id)) { + env.storage().persistent().remove(&DataKey::GracePeriodStart(loan_id)); + } + return false; + } + + // Health factor < 1.0, check grace period + let now = env.ledger().timestamp(); + let grace_start: Option = env + .storage() + .persistent() + .get(&DataKey::GracePeriodStart(loan_id)); + + match grace_start { + None => { + // First time undercollateralized - start grace period + env.storage() + .persistent() + .set(&DataKey::GracePeriodStart(loan_id), &now); + + // Emit grace period start event + env.events().publish( + (symbol_short!("grace"), symbol_short!("start")), + (loan_id, loan.borrower.clone(), health_factor, now), + ); + + false // Not eligible yet, grace period just started + } + Some(start_time) => { + let elapsed = now.saturating_sub(start_time); + + if elapsed >= GRACE_PERIOD_SECONDS { + // Grace period expired, loan is eligible for liquidation + true + } else { + // Still within grace period + false + } + } + } + } + // ── Private helpers ─────────────────────────────────────────────────────── /// Compute borrowing power from a set of collateral entries. diff --git a/scripts/liquidation-keeper.ts b/scripts/liquidation-keeper.ts index 5ae4ae8..0fa5adb 100644 --- a/scripts/liquidation-keeper.ts +++ b/scripts/liquidation-keeper.ts @@ -316,6 +316,23 @@ async function getLiquidationThresholdBps( return Number(result); } +/** + * Check if a loan is eligible for liquidation (Issue #157). + * Returns true if the grace period has expired and the loan can be liquidated. + */ +async function checkLiquidationEligibility( + cfg: KeeperConfig, + loanId: number +): Promise { + const result = await invokeReadOnly({ + contractId: cfg.lendingContractId, + method: "check_liquidation_eligibility", + args: [u32(loanId)], + sourceAddress: cfg.adminAddress, + }); + return Boolean(result); +} + // ─── Candidate discovery ────────────────────────────────────────────────────── /** Resolve an on-chain loan id for a Supabase loan row from its funding ledger entry. */ @@ -479,6 +496,17 @@ export async function runLiquidationKeeper( continue; } + // Check grace period eligibility (Issue #157) + // If health factor is below 1.0, borrower gets 12 hours to top up collateral + const isEligible = await checkLiquidationEligibility(cfg, loanId); + if (!isEligible) { + summary.skipped++; + console.log( + `[liquidation-keeper] Loan #${loanId}: undercollateralized but grace period active — skipping.` + ); + continue; + } + const detail = `Loan #${loanId} (borrower ${loan.borrower.slice(0, 6)}…) — LTV ${(ltvBps / 100).toFixed(2)}% >= threshold ${(thresholdBps / 100).toFixed(2)}%`; if (cfg.dryRun) {