'use client'; import { useEffect, useMemo, useState } from 'react'; import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract, useSendTransaction } from 'wagmi'; import { base, arbitrum } from 'wagmi/chains'; import { Abi, parseUnits } from 'viem'; import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import SettingsModal from '@/components/SettingsModal'; import { useLocalStorage } from '@/utils/useLocalStorage'; import type { NotificationSettings, PositionNotification } from '@/types/notifications'; import { scheduleJob, cancelScheduledJob, purgeNtfyTopicSchedules } from '@/utils/scheduler'; // Minimal ERC-721 ABI for balance/owner/enumeration const erc721Abi = [ { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'ownerOf', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] }, { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ name: 'interfaceId', type: 'bytes4' }], outputs: [{ type: 'bool' }] }, // ERC721Enumerable { type: 'function', name: 'tokenOfOwnerByIndex', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'index', type: 'uint256' }], outputs: [{ type: 'uint256' }] }, ] as const satisfies Abi; const DEFAULTS = { [base.id]: { nft: '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe', debt: '0xe93131620945a1273b48f57f453983d270b62dc7', }, [arbitrum.id]: { nft: '0xedE6F5F8A9D6B90b1392Dcc9E7FD8A5B0192Bfe1', debt: '0x9Be2Cf73E62DD3b5dF4334D9A36888394822A33F', }, } as const; // Presets per chain (selectable pairs) const PRESETS: Record = { [base.id]: [ { key: 'cbBTC-USDC', label: 'cbBTC-USDC', nft: '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe', debt: '0xe93131620945a1273b48f57f453983d270b62dc7' }, { key: 'WETH-USDC', label: 'WETH-USDC', nft: '0xab825f45e9e5d2459fb7a1527a8d0ca082c582f4', debt: '0x1be87d273d47c3832ab7853812e9a995a4de9eea' }, ], [arbitrum.id]: [ { key: 'USDTO-WBTC', label: 'USDTO-WBTC', nft: '0xedE6F5F8A9D6B90b1392Dcc9E7FD8A5B0192Bfe1', debt: '0x9Be2Cf73E62DD3b5dF4334D9A36888394822A33F' }, ], }; export default function DappPage() { const { address, isConnected } = useAccount(); const chainId = useChainId(); const { switchChain } = useSwitchChain(); const searchParams = useSearchParams(); const [selectedChainId, setSelectedChainId] = useState(base.id); const [nftAddress, setNftAddress] = useState(DEFAULTS[base.id].nft); const [debtAddress, setDebtAddress] = useState(DEFAULTS[base.id].debt); const [presetKey, setPresetKey] = useState('cbBTC-USDC'); const [manualWallet, setManualWallet] = useState(''); const [manualTokenId, setManualTokenId] = useState(''); const [detectedTokenIds, setDetectedTokenIds] = useState([]); const [scanBusy, setScanBusy] = useState(false); const [scanComplete, setScanComplete] = useState(false); const [payInputs, setPayInputs] = useState>({}); const publicClient = usePublicClient({ chainId: selectedChainId }); const { writeContractAsync, isPending: writePending } = useWriteContract(); const { sendTransactionAsync, isPending: txPending } = useSendTransaction(); // Notification settings (global) and per-position preferences const [settingsOpen, setSettingsOpen] = useState(false); useEffect(() => { if (searchParams?.get('settings') === '1') setSettingsOpen(true); }, [searchParams]); const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, ''); const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, ''); const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim(); const [notifSettings, setNotifSettings] = useLocalStorage('notif:settings', { provider: '', scheduler: 'schedy', ntfyServer: ENV_NTFY, ntfyTopic: '', gotifyServer: '', gotifyToken: '', snsRegion: '', snsTopicArn: '', snsAccessKeyId: '', snsSecretAccessKey: '', schedyBaseUrl: ENV_SCHEDY, schedyApiKey: ENV_SCHEDY_API_KEY, email: '', daysBefore: 10, }); useEffect(() => { console.log('[Settings] Current', notifSettings); }, [notifSettings]); // One-time migration: minutesBefore -> daysBefore useEffect(() => { const anySettings: any = notifSettings as any; if (anySettings && anySettings.minutesBefore !== undefined && anySettings.daysBefore === undefined) { const mins = Number(anySettings.minutesBefore) || 0; const days = Math.max(0, Math.round(mins / 1440)); const next = { ...notifSettings, daysBefore: days } as NotificationSettings; delete (next as any).minutesBefore; console.log('[Settings] Migrating minutesBefore -> daysBefore', { mins, days }); setNotifSettings(next); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // One-time migration: legacy scheduler providers -> schedy; remove legacy fields useEffect(() => { const s: any = notifSettings as any; if (!s) return; let changed = false; const next: any = { ...notifSettings }; // Normalize scheduler if (!s.scheduler || s.scheduler !== 'schedy') { next.scheduler = 'schedy'; changed = true; } // Remove legacy keys if present const legacyKeys = ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']; for (const k of legacyKeys) { if (k in next) { delete next[k]; changed = true; } } if (changed) { console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields'); setNotifSettings(next as NotificationSettings); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const positionsStoreKey = useMemo(() => `notif:positions:v1:${selectedChainId}:${debtAddress?.toLowerCase()}`, [selectedChainId, debtAddress]); const [positionsNotif, setPositionsNotif] = useLocalStorage>(positionsStoreKey, {}); // Cache helpers type WalletCache = { lastScannedIndex: number; tokenIds: string[]; balance?: string | null; updatedAt: number; complete?: boolean; // when true, stop further ownerOf scans (gap limit reached) }; // Utility: sleep for backoff const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type ContractCache = { wallets: Record; enumerable?: boolean; }; const effectiveWallet = useMemo(() => (manualWallet?.trim() ? manualWallet.trim() : address) || '', [manualWallet, address]); const cacheKey = useMemo(() => { if (!nftAddress) return ''; return `nftScan:v1:${selectedChainId}:${nftAddress.toLowerCase()}`; }, [nftAddress, selectedChainId]); const loadCache = (): ContractCache | null => { try { if (typeof window === 'undefined' || !cacheKey) return null; const raw = localStorage.getItem(cacheKey); if (!raw) return null; return JSON.parse(raw) as ContractCache; } catch { return null; } }; const saveCache = (update: ContractCache) => { try { if (typeof window === 'undefined' || !cacheKey) return; localStorage.setItem(cacheKey, JSON.stringify(update)); } catch {} }; // Hydrate detected tokenIds from cache on changes useEffect(() => { if (!effectiveWallet || !cacheKey) return; const cache = loadCache() || { wallets: {} }; const w = cache.wallets[effectiveWallet.toLowerCase()]; if (w) { const ids = (w.tokenIds || []).map((s) => BigInt(s)); setDetectedTokenIds(Array.from(new Set(ids))); setScanComplete(Boolean(w.complete)); console.log('[NFT cache] Hydrated from cache', { cacheKey, wallet: effectiveWallet.toLowerCase(), lastScannedIndex: w.lastScannedIndex, count: w.tokenIds?.length || 0, }); } else { setDetectedTokenIds([]); setScanComplete(false); console.log('[NFT cache] No wallet entry in cache. Starting fresh', { cacheKey, wallet: effectiveWallet.toLowerCase() }); } }, [effectiveWallet, cacheKey]); // OwnerOf scan (batch of 12) const scanMore = async () => { if (!publicClient || !effectiveWallet || !nftAddress) return; // Respect completed scans const existing = loadCache(); const existingEntry = existing?.wallets?.[effectiveWallet.toLowerCase()]; if (existingEntry?.complete) { console.log('[Scan] Already marked complete via gap-limit; skipping.'); return; } setScanBusy(true); try { console.log('[Scan] Starting batch scan…', { nftAddress, wallet: effectiveWallet, chainId: selectedChainId }); const cache = loadCache() || { wallets: {} }; const key = effectiveWallet.toLowerCase(); if (!cache.wallets[key]) { cache.wallets[key] = { lastScannedIndex: -1, tokenIds: [], balance: null, updatedAt: Math.floor(Date.now() / 1000), complete: false }; } const w = cache.wallets[key]; const start = w.lastScannedIndex + 1; const end = start + 11; const indices = Array.from({ length: 12 }, (_, i) => start + i); console.log('[Scan] Indices', { start, end, indices }); const results: Array<{ i: number; owner: string | null; rateLimited?: boolean }> = []; let hadRateLimit = false; let consecutiveGaps = 0; // count of consecutive nonexistent token errors let gapLimitTriggered = false; for (const i of indices) { console.log('[Scan] ownerOf call', { tokenId: i }); let attempt = 0; let success = false; let owner: string | null = null; let wasRateLimitedForThis = false; while (attempt < 3 && !success) { try { owner = await publicClient.readContract({ abi: erc721Abi, address: nftAddress as `0x${string}`, functionName: 'ownerOf', args: [BigInt(i)], }) as string; success = true; console.log('[Scan] ownerOf result', { tokenId: i, owner }); } catch (err: any) { const msg = String(err?.message || err); const code = (err && (err.code || err.status)) as any; const is429 = msg.includes('429') || msg.toLowerCase().includes('rate') || code === 429; console.warn('[Scan] ownerOf error', { tokenId: i, attempt, is429, err }); if (is429) { hadRateLimit = true; wasRateLimitedForThis = true; const backoff = 300 * Math.pow(2, attempt); // 300ms, 600ms, 1200ms console.log('[Scan] Backing off due to 429/rate limit', { tokenId: i, backoffMs: backoff }); await sleep(backoff); } else { // Non rate-limit error, do not retry more than once break; } } attempt++; } // Track gap-limit: count only definitive non-existent (not rate limited) if (!success && !wasRateLimitedForThis) { consecutiveGaps++; console.log('[Scan] Nonexistent token encountered; consecutive gaps =', consecutiveGaps); if (consecutiveGaps >= 5) { gapLimitTriggered = true; console.warn('[Scan] Gap limit reached; stopping scan and marking as complete.'); } } else if (success) { consecutiveGaps = 0; } results.push({ i, owner, rateLimited: !success && wasRateLimitedForThis }); // Small pacing to avoid bursts await sleep(50); if (gapLimitTriggered) break; } const mine = results.filter((r) => (r.owner as string)?.toLowerCase() === effectiveWallet.toLowerCase()).map((r) => r.i.toString()); console.log('[Scan] Batch done', { foundForMe: mine, total: results.length }); // Update cache const nextIds = Array.from(new Set([...(w.tokenIds || []), ...mine])); cache.wallets[key] = { ...w, lastScannedIndex: hadRateLimit ? w.lastScannedIndex : gapLimitTriggered ? (results.at(-1)?.i ?? w.lastScannedIndex) : end, tokenIds: nextIds, updatedAt: Math.floor(Date.now() / 1000), complete: gapLimitTriggered ? true : w.complete || false, }; saveCache(cache); setDetectedTokenIds(nextIds.map((s) => BigInt(s))); setScanComplete(Boolean(cache.wallets[key].complete)); if (hadRateLimit) { console.warn('[Scan] Rate limited encountered; did not advance lastScannedIndex. Try again shortly.'); } } finally { setScanBusy(false); } }; const resetScan = () => { if (!effectiveWallet || !cacheKey) return; const cache = loadCache() || { wallets: {} }; const key = effectiveWallet.toLowerCase(); cache.wallets[key] = { lastScannedIndex: -1, tokenIds: [], balance: null, updatedAt: Math.floor(Date.now() / 1000), complete: false }; saveCache(cache); setDetectedTokenIds([]); setScanComplete(false); console.log('[Scan] Reset wallet cache', { cacheKey, wallet: effectiveWallet.toLowerCase() }); }; const deleteLocalCache = () => { if (!cacheKey) return; try { localStorage.removeItem(cacheKey); setDetectedTokenIds([]); console.log('[Cache] Deleted contract-scoped cache', { cacheKey }); } catch (e) { console.warn('[Cache] Failed to delete cache', { cacheKey, e }); } }; const [canEnumerate, setCanEnumerate] = useState(null); // Ensure selected chain for reads useEffect(() => { if (isConnected && chainId && chainId !== selectedChainId) { try { switchChain({ chainId: selectedChainId }); } catch (e) { // ignore } } }, [isConnected, chainId, selectedChainId, switchChain]); // Helper to load chain defaults into address inputs const loadChainDefaults = (id: number) => { const d = DEFAULTS[id as keyof typeof DEFAULTS]; if (d) { setNftAddress(d.nft); setDebtAddress(d.debt); // set first preset for chain const p = PRESETS[id]?.[0]; if (p) setPresetKey(p.key); console.log('[UI] Loaded defaults for chain', { selectedChainId: id, nft: d.nft, debt: d.debt }); } }; // Auto-load defaults when the selected network changes useEffect(() => { loadChainDefaults(selectedChainId); }, [selectedChainId]); // Detect ERC721Enumerable support: interfaceId 0x780e9d63 const { data: supportsEnumerable } = useReadContract({ abi: erc721Abi, address: nftAddress as `0x${string}`, functionName: 'supportsInterface', args: ['0x780e9d63'], chainId: selectedChainId, query: { enabled: !!nftAddress }, }); useEffect(() => { if (supportsEnumerable !== undefined) setCanEnumerate(Boolean(supportsEnumerable)); }, [supportsEnumerable]); // Fetch balance const { data: balance } = useReadContract({ abi: erc721Abi, functionName: 'balanceOf', args: [effectiveWallet as `0x${string}`], chainId: selectedChainId, query: { enabled: !!effectiveWallet }, }); // Enumerate first up to 10 tokenIds (adjust as needed) useEffect(() => { const fetchTokens = async () => { if (!effectiveWallet || !balance || !canEnumerate) return; const count = Number(balance); const max = Math.min(count, 10); const reads = Array.from({ length: max }, (_, i) => ({ abi: erc721Abi, address: nftAddress as `0x${string}`, functionName: 'tokenOfOwnerByIndex' as const, args: [effectiveWallet as `0x${string}`, BigInt(i)], chainId: selectedChainId, })); try { const res = await (window as any).ethereum; // just to ensure provider exists if (!res) return; } catch {} // We'll use a simple loop via public client later if needed; for now, rely on wagmi's useReadContracts not in hook }; fetchTokens(); }, [effectiveWallet, balance, canEnumerate, nftAddress, selectedChainId]); // Resolve tokenIds: either detected or manual entry const tokenIds = useMemo(() => { const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : []; return [...detectedTokenIds, ...manual]; }, [detectedTokenIds, manualTokenId]); // Build reads for debt contract const debtReads = useMemo(() => { if (!tokenIds.length) return [] as any[]; return tokenIds.flatMap((id) => [ { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'openDebt', args: [id], chainId: selectedChainId, }, { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'feeSize', args: [id], chainId: selectedChainId, }, { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'coinSize', args: [id], chainId: selectedChainId, }, { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'amountPaid', args: [id], chainId: selectedChainId, }, { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'startDate', args: [id], chainId: selectedChainId, }, { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'expiration', args: [id], chainId: selectedChainId, }, { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'baseSize', args: [id], chainId: selectedChainId, }, ]); }, [tokenIds, debtAddress, selectedChainId]); const { data: debtResults, isLoading: debtLoading, error: debtError, refetch } = useReadContracts({ contracts: debtReads as any, query: { enabled: debtReads.length > 0 }, }); // Global reads (chain-level): APR and token addresses const { data: apr } = useReadContract({ abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'calculateAPR', args: [], chainId: selectedChainId, query: { enabled: !!debtAddress }, }); const { data: stableAddr } = useReadContract({ abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'stablecoin', args: [], chainId: selectedChainId, query: { enabled: !!debtAddress }, }); const { data: contractCoinAddr } = useReadContract({ abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'contractCoin', args: [], chainId: selectedChainId, query: { enabled: !!debtAddress }, }); // Minimal ERC20 ABI for symbol/decimals const erc20Abi = useMemo(() => ([ { type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] }, { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'allowance', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }], outputs: [{ type: 'uint256' }] }, { type: 'function', name: 'approve', stateMutability: 'nonpayable', inputs: [{ name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }], outputs: [{ type: 'bool' }] }, ]) as const satisfies Abi, []); const { data: stableDecimals } = useReadContract({ abi: erc20Abi, address: (stableAddr as `0x${string}`) || undefined, functionName: 'decimals', chainId: selectedChainId, query: { enabled: !!stableAddr }, }); const { data: stableSymbol } = useReadContract({ abi: erc20Abi, address: (stableAddr as `0x${string}`) || undefined, functionName: 'symbol', chainId: selectedChainId, query: { enabled: !!stableAddr }, }); const { data: coinDecimals } = useReadContract({ abi: erc20Abi, address: (contractCoinAddr as `0x${string}`) || undefined, functionName: 'decimals', chainId: selectedChainId, query: { enabled: !!contractCoinAddr }, }); const { data: coinSymbol } = useReadContract({ abi: erc20Abi, address: (contractCoinAddr as `0x${string}`) || undefined, functionName: 'symbol', chainId: selectedChainId, query: { enabled: !!contractCoinAddr }, }); // Debt token (ERC20 implemented at debtAddress) metadata and balance const { data: debtTokenDecimals } = useReadContract({ abi: erc20Abi, address: (debtAddress as `0x${string}`) || undefined, functionName: 'decimals', chainId: selectedChainId, query: { enabled: !!debtAddress }, }); const { data: debtTokenSymbol } = useReadContract({ abi: erc20Abi, address: (debtAddress as `0x${string}`) || undefined, functionName: 'symbol', chainId: selectedChainId, query: { enabled: !!debtAddress }, }); const { data: debtTokenBalance } = useReadContract({ abi: erc20Abi, address: (debtAddress as `0x${string}`) || undefined, functionName: 'balanceOf', args: [address as `0x${string}`], chainId: selectedChainId, query: { enabled: !!debtAddress && !!address }, }); // Stablecoin balance and allowance to debt const { data: contractCoinBalance } = useReadContract({ abi: erc20Abi, address: (contractCoinAddr as `0x${string}`) || undefined, functionName: 'balanceOf', args: [address as `0x${string}`], chainId: selectedChainId, query: { enabled: !!contractCoinAddr && !!address }, }); const { data: stableBalance } = useReadContract({ abi: erc20Abi, address: (stableAddr as `0x${string}`) || undefined, functionName: 'balanceOf', args: [address as `0x${string}`], chainId: selectedChainId, query: { enabled: !!stableAddr && !!address }, }); const { data: stableAllowance } = useReadContract({ abi: erc20Abi, address: (stableAddr as `0x${string}`) || undefined, functionName: 'allowance', args: [address as `0x${string}`, debtAddress as `0x${string}`], chainId: selectedChainId, query: { enabled: !!stableAddr && !!address && !!debtAddress }, }); // Payment input setter and actions const setPaymentAmount = (tokenId: bigint, value: string) => { setPayInputs((prev) => ({ ...prev, [tokenId.toString()]: value })); }; const handleApprove = async (tokenId: bigint) => { try { if (!stableAddr || !debtAddress) return; const dec = Number(stableDecimals ?? 6); const raw = payInputs[tokenId.toString()] || '0'; const amount = parseUnits(raw || '0', dec); await writeContractAsync({ abi: erc20Abi, address: stableAddr as `0x${string}`, functionName: 'approve', args: [debtAddress as `0x${string}`, amount], chainId: selectedChainId, }); // No direct refetch for allowance; rely on revalidation } catch (e) { console.warn('[Approve] Failed', e); } }; const handlePay = async (tokenId: bigint) => { try { if (!debtAddress) return; const dec = Number(stableDecimals ?? 6); const raw = payInputs[tokenId.toString()] || '0'; let amount = parseUnits(raw || '0', dec); const max = payMaxByTokenId[tokenId.toString()]; if (typeof max === 'bigint' && amount > max) amount = max; // cap to reset-timer amount if (amount === BigInt(0)) return; await writeContractAsync({ abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'payDownContract', args: [tokenId, amount], chainId: selectedChainId, }); // Refresh debt data after payment try { await refetch(); } catch {} } catch (e) { console.warn('[Pay] Failed', e); } }; // Helpers to construct provider webhook URL/body for cronhost const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string) => { const provider = notifSettings.provider; if (!provider) throw new Error('Notification provider not configured'); if (provider === 'ntfy') { const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, ''); const topic = notifSettings.ntfyTopic || ''; const headers: Record = { 'Content-Type': 'text/plain' }; if ((notifSettings.email || '').trim()) headers['X-Email'] = String(notifSettings.email); return { url: `${baseUrl}/${encodeURIComponent(topic)}`, method: 'POST' as const, body: message, headers, }; } if (provider === 'gotify') { const baseUrl = (notifSettings.gotifyServer || '').replace(/\/$/, ''); const token = notifSettings.gotifyToken || ''; return { url: `${baseUrl}/message?token=${encodeURIComponent(token)}`, method: 'POST' as const, body: { title: 'MortgageFi Alert', message, priority: 5 }, headers: { 'Content-Type': 'application/json' }, }; } if (provider === 'sns') { // Note: Direct SNS publish requires AWS SigV4; recommend server-side relay. Here we simply target a placeholder you control. // User should set up a relay endpoint that publishes to SNS. const relay = '/api/sns-relay'; return { url: relay, method: 'POST' as const, body: { topicArn: notifSettings.snsTopicArn, region: notifSettings.snsRegion, message, email: notifSettings.email }, headers: { 'Content-Type': 'application/json' }, }; } throw new Error('Unsupported provider'); }; // Single schedule at configured lead time (or 1 day by default) const scheduleNotification = async (row: { tokenId: bigint; secondsTillLiq?: bigint }) => { const seconds = Number(row.secondsTillLiq ?? 0); if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Invalid liquidation timer'); const nowSec = Math.floor(Date.now() / 1000); const leadSecsCfg = Math.max(0, Math.floor(Number(notifSettings.daysBefore ?? 0) * 86400)); const offset = leadSecsCfg > 0 ? leadSecsCfg : 86400; // default to 1 day // Helper: humanize a duration in seconds const human = (rem: number) => { if (rem <= 0) return '0m'; const days = Math.floor(rem / 86400); const hours = Math.floor((rem % 86400) / 3600); const mins = Math.floor((rem % 3600) / 60); if (days >= 2) return `${days}d ${hours}h`; if (days === 1) return `1d ${hours}h`; if (hours >= 1) return `${hours}h ${mins}m`; return `${mins}m`; }; // Amount suggestion: prefer total debt remaining; fallback to 1.5x monthly payment; else current payment pending const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined; const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined; const debtRemaining = (row as any)?.debtAtThisSize as bigint | undefined; const dappUrl = (typeof window !== 'undefined' && window.location?.origin) ? `${window.location.origin}/dapp` : 'https://markets.mortgagefi.app/dashboard'; const humanLeft = human(offset); const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2); const formatToken = (v: bigint | undefined) => { if (v === undefined) return null; const dec = Number(stableDecimals ?? 6); const factor = BigInt(10) ** BigInt(dec); const int = v / factor; const frac = (v % factor).toString().padStart(dec, '0').slice(0, 6); return `${int.toString()}.${frac}`; }; const payAmt15 = monthly !== undefined ? mul15(monthly) : undefined; const payAmtStr = formatToken(debtRemaining ?? payAmt15 ?? paymentPending ?? BigInt(0)); const payClause = payAmtStr ? `Pay at least ${payAmtStr} USDC to avoid liquidation.` : `Make a payment to avoid liquidation.`; // Collateral at risk: use same formatting as UI Loan Collateral const collateralStr = fmt((row as any)?.coinSize as bigint | undefined, Number(coinDecimals ?? 8), 8); const collateralSym = String(coinSymbol ?? ''); const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; const runAt = nowSec + Math.max(0, seconds - offset); if (runAt <= nowSec) throw new Error('Computed run time is in the past'); const req = buildNotificationRequest(row, msg); const res = await scheduleJob(notifSettings, { runAtEpoch: runAt, method: req.method, url: req.url, body: req.body, headers: req.headers }); return { jobId: res.jobId, runAt }; }; const cancelNotification = async (p: PositionNotification) => { // Cancel new multi-jobs first if (p.jobs && p.jobs.length) { for (const j of p.jobs) { try { await cancelScheduledJob(notifSettings, j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); } } } // Back-compat: single jobId if (p.jobId) { try { await cancelScheduledJob(notifSettings, p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); } } }; // Scheduled notifications helpers const scheduledList = useMemo(() => { const entries = Object.entries(positionsNotif || {}); return entries .filter(([, v]) => v && v.enabled && ((v.jobs && v.jobs.length) || v.jobId || v.scheduledAt)) .map(([k, v]) => { const earliest = v.jobs && v.jobs.length ? Math.min(...v.jobs.map(j => j.at)) : (v.scheduledAt || 0); return { key: k, ...v, scheduledAt: earliest }; }) .sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0)); }, [positionsNotif]); const deleteSchedule = async (key: string) => { const p = positionsNotif[key]; if (!p) return; try { await cancelNotification(p); } catch {} setPositionsNotif((prev) => { const next = { ...prev }; delete next[key]; return next; }); }; const parsed = useMemo(() => { if (!debtResults || !tokenIds.length) return [] as Array<{ tokenId: bigint; currentPaymentPending?: bigint; debtAtThisSize?: bigint; secondsTillLiq?: bigint; feeSize?: bigint; coinSize?: bigint; amountPaid?: bigint; startDate?: bigint; expiration?: bigint; baseSize?: bigint; }>; const out: Array = []; for (let i = 0; i < tokenIds.length; i++) { const baseIdx = i * 7; // openDebt, feeSize, coinSize, amountPaid, startDate, expiration, baseSize const openDebtRes = debtResults[baseIdx + 0]?.result as any; const feeRes = debtResults[baseIdx + 1]?.result as any; const coinSizeRes = debtResults[baseIdx + 2]?.result as any; const amountPaidRes = debtResults[baseIdx + 3]?.result as any; const startDateRes = debtResults[baseIdx + 4]?.result as any; const expirationRes = debtResults[baseIdx + 5]?.result as any; const baseSizeRes = debtResults[baseIdx + 6]?.result as any; out.push({ tokenId: tokenIds[i], currentPaymentPending: openDebtRes?.[0] as bigint | undefined, debtAtThisSize: openDebtRes?.[1] as bigint | undefined, secondsTillLiq: openDebtRes?.[2] as bigint | undefined, feeSize: feeRes as bigint | undefined, coinSize: coinSizeRes as bigint | undefined, amountPaid: amountPaidRes as bigint | undefined, startDate: startDateRes as bigint | undefined, expiration: expirationRes as bigint | undefined, baseSize: baseSizeRes as bigint | undefined, }); } return out; }, [debtResults, tokenIds]); // Auto-reschedule when secondsTillLiq changes for enabled positions useEffect(() => { (async () => { for (const row of parsed) { const key = row.tokenId.toString(); const p = positionsNotif[key]; if (!p || !p.enabled) continue; const seconds = Number(row.secondsTillLiq ?? 0); if (!Number.isFinite(seconds) || seconds <= 0) continue; const lead = Math.max(0, Number(notifSettings.daysBefore ?? 0) * 86400); const targetRunAt = Math.floor(Date.now() / 1000) + Math.max(0, seconds - (lead > 0 ? lead : 86400)); const drift = Math.abs((p.scheduledAt || 0) - targetRunAt); if (!p.jobId || drift > 60) { // reschedule single if (p) try { await cancelNotification(p); } catch {} try { const { jobId, runAt } = await scheduleNotification(row); setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt } })); } catch (e) { console.warn('[notif] reschedule failed', e); } } } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [parsed.map(r => String(r.tokenId)+':'+String(r.secondsTillLiq)).join(','), notifSettings.daysBefore, positionsStoreKey]); // Map tokenId -> max payable (Amount to reset timer) const payMaxByTokenId = useMemo(() => { const m: Record = {}; for (const row of parsed) { if (row.currentPaymentPending !== undefined) m[row.tokenId.toString()] = row.currentPaymentPending as bigint; } return m; }, [parsed]); const fmt = (v?: bigint, decimals = 0, precision = 6) => { if (v === undefined) return '-'; const factor = BigInt(10) ** BigInt(decimals); const int = v / factor; const frac = v % factor; const fracStr = (factor + frac).toString().slice(1).padStart(Number(decimals), '0').slice(0, precision); return `${int.toString()}${precision > 0 ? '.' + fracStr : ''}`; }; // Initialize default pay amounts to the "Amount to reset timer" when parsed data loads useEffect(() => { if (!parsed.length) return; const dec = Number(stableDecimals ?? 6); setPayInputs((prev) => { const next = { ...prev }; for (const row of parsed) { const key = row.tokenId.toString(); if (next[key] === undefined && row.currentPaymentPending !== undefined) { next[key] = fmt(row.currentPaymentPending, dec, dec); } } return next; }); }, [parsed, stableDecimals]); const aprDisplay = useMemo(() => { if (apr === undefined) return '-'; // Try to infer scale: common is APR in basis points (1e2) or 1e18. const n = BigInt(apr as any); // Heuristic: if n > 1e9 assume 1e18 scale, else basis points (1e2) if (n > BigInt(1000000000)) { // percent with 2 decimals: (n / 1e16) const percentScaled = n / BigInt(10000000000000000); // 1e16 return `${fmt(percentScaled, 2, 2)} %`; } // basis points -> percent with 2 decimals return `${fmt(n, 2, 2)} %`; }, [apr]); const fmtDuration = (seconds?: bigint) => { if (seconds === undefined) return '-'; let s = Number(seconds); if (!Number.isFinite(s) || s < 0) return '-'; const d = Math.floor(s / 86400); s %= 86400; const h = Math.floor(s / 3600); s %= 3600; const m = Math.floor(s / 60); return `${d}d ${h}h ${m}m`; }; // Compound action: raw tx to debt token contract with provided data const handleCompound = async () => { try { if (!debtAddress) return; await sendTransactionAsync({ to: debtAddress as `0x${string}`, data: '0x4e71d92d', chainId: selectedChainId, }); } catch (e) { console.warn('[Compound] Failed', e); } }; return (

MortgageFi DApp

Connect wallet, detect your NFTs, and fetch debt details.

{!isConnected && (
Please connect your wallet using the Connect Wallet button in the navbar.
)}
Inputs
{/* Preset selector for pair addresses */}
{scanComplete && (
Owner scan completed (gap limit reached). Reset or delete cache to rescan.
)}
Cached IDs: {detectedTokenIds.length ? detectedTokenIds.map((id) => id.toString()).join(', ') : '—'}
About

Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.

Base network is required. The app will attempt to detect your token IDs via on-chain queries.

Scheduled notifications
{scheduledList.length === 0 ? (
No schedules yet.
) : (
    {scheduledList.map((it) => (
  • Token #{it.tokenId}
    {it.scheduledAt ? `Runs at ${new Date(it.scheduledAt * 1000).toLocaleString()}` : 'Scheduled'} {it.jobId ? ` · Job ${it.jobId}` : ''}
  • ))}
)}
Chain: {selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Other'} {String(coinSymbol ?? '')} Balance: {fmt(contractCoinBalance as bigint | undefined, Number(coinDecimals ?? 8), 8)} {String(coinSymbol ?? '')} Stable Balance: {fmt(stableBalance as bigint | undefined, Number(stableDecimals ?? 6), 6)} {String(stableSymbol ?? '')} Allowance → Debt: {fmt(stableAllowance as bigint | undefined, Number(stableDecimals ?? 6), 6)} {String(stableSymbol ?? '')} Debt Token Balance: {fmt(debtTokenBalance as bigint | undefined, Number(debtTokenDecimals ?? 18), 6)} {String(debtTokenSymbol ?? '')} {(() => { const dec = Number(debtTokenDecimals ?? 18); const bal = (debtTokenBalance as bigint | undefined) ?? undefined; const oneToken = BigInt(10) ** BigInt(dec); const hasOnePlus = bal !== undefined && bal > oneToken; const manualSet = Boolean(manualWallet?.trim()); const canCompound = !!address && !manualSet && !!debtAddress && hasOnePlus && !txPending; return ( ); })()}
{debtError &&
{String(debtError?.message || debtError)}
}
{debtLoading &&
Loading...
} {!debtLoading && parsed.map((row) => { const stableDec = Number(stableDecimals ?? 6); const coinDec = Number(coinDecimals ?? 8); // Interpret expiration: if small (<= 200), treat as years; otherwise treat as unix seconds let loanTermDisplay = '-'; if (row.expiration !== undefined) { const exp = Number(row.expiration); if (!Number.isNaN(exp)) { if (exp <= 200) { loanTermDisplay = `${exp} Years`; } else { loanTermDisplay = new Date(exp * 1000).toLocaleDateString(); } } } // Precompute values const costToClose2pct: bigint | undefined = row.baseSize !== undefined ? (row.baseSize as bigint) * BigInt(2) / BigInt(100) : undefined; const totalCostToClose102pct: bigint | undefined = row.baseSize !== undefined ? (row.baseSize as bigint) * BigInt(102) / BigInt(100) : undefined; const monthlyPaymentFixed: bigint | undefined = row.baseSize !== undefined && row.feeSize !== undefined ? ((row.baseSize as bigint) + (row.feeSize as bigint)) / BigInt(360) : undefined; const isClosed = row.coinSize !== undefined && row.coinSize === BigInt(0); return (
Loan Details for {String(stableSymbol ?? 'USDC')}-{String(coinSymbol ?? 'cbBTC')} - ID: {row.tokenId.toString()}
{isClosed && ( Closed/Defaulted )}
Loan Collateral
{fmt(row.coinSize, coinDec, 8)} {String(coinSymbol ?? '')}
Interest Rate
{aprDisplay}
Loan Dollar Value
{fmt(row.baseSize, stableDec, 6)} {String(stableSymbol ?? '')}
Cost to close Loan
{fmt(costToClose2pct, stableDec, 6)} {String(stableSymbol ?? '')}
Total Cost to Close
{fmt(totalCostToClose102pct, stableDec, 6)} {String(stableSymbol ?? '')}
Total Interest to pay over Length of Loan
{fmt(row.feeSize, stableDec, 6)} {String(stableSymbol ?? '')}
Total Repaid
{fmt(row.amountPaid, stableDec, 6)} {String(stableSymbol ?? '')}
Debt Remaining
{fmt(row.debtAtThisSize, stableDec, 6)} {String(stableSymbol ?? '')}
Amount to reset timer
{fmt(row.currentPaymentPending, stableDec, 6)} {String(stableSymbol ?? '')}
Monthly Payment Size
{fmt(monthlyPaymentFixed, stableDec, 6)} {String(stableSymbol ?? '')}
Loan Term
{loanTermDisplay}
Start Date
{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}
Seconds Till Liquidation
{fmtDuration(row.secondsTillLiq)} {!isClosed && ( (() => { const key = row.tokenId.toString(); const p = positionsNotif[key]; const checked = Boolean(p?.enabled); const disabled = row.secondsTillLiq === undefined || Number(row.secondsTillLiq) <= 0; const onToggle = async (next: boolean) => { // Ensure settings const haveProvider = (() => { if (!notifSettings.provider) return false; if (notifSettings.provider === 'ntfy') return Boolean(notifSettings.ntfyServer && notifSettings.ntfyTopic); if (notifSettings.provider === 'gotify') return Boolean(notifSettings.gotifyServer && notifSettings.gotifyToken); if (notifSettings.provider === 'sns') return Boolean(notifSettings.snsRegion && notifSettings.snsTopicArn && notifSettings.snsAccessKeyId && notifSettings.snsSecretAccessKey); return false; })(); const haveScheduler = (() => { const s = notifSettings.scheduler || 'schedy'; if (s !== 'schedy') return false; return Boolean(notifSettings.schedyBaseUrl && notifSettings.schedyApiKey); })(); const haveSettings = Boolean(haveProvider && haveScheduler && notifSettings.email); if (next && !haveSettings) { setSettingsOpen(true); return; } if (!next) { // disable and cancel if (p) await cancelNotification(p); setPositionsNotif((prev) => ({ ...prev, [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: false } })); return; } try { const { jobId, runAt } = await scheduleNotification(row); setPositionsNotif((prev) => ({ ...prev, [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt }, })); } catch (e) { console.warn('[notif] schedule failed', e); } }; return ( ); })() )}
{/* Pay controls (hidden for Closed/Defaulted loans) */} {!isClosed && (
Make a Payment
{(() => { const key = row.tokenId.toString(); const inputVal = payInputs[key] ?? ''; const dec = Number(stableDecimals ?? 6); let amount: bigint | null = null; try { amount = inputVal ? parseUnits(inputVal, dec) : null; } catch { amount = null; } const hasAllowance = stableAllowance !== undefined && amount !== null && (stableAllowance as bigint) >= amount; const hasBalance = stableBalance !== undefined && amount !== null && (stableBalance as bigint) >= amount; const disableAll = !stableAddr || !debtAddress; return ( <> setPaymentAmount(row.tokenId, e.target.value)} /> {!hasAllowance ? ( ) : null} {!hasBalance && amount !== null && ( Insufficient balance )} ); })()}
)}
); })}
If there's a specific "start date" function in the ABI, let me know its exact name so I can add it to the reads.
{/* Settings Modal */} setSettingsOpen(false)} onSave={(s) => { console.log('[Settings] Save requested', s); setNotifSettings(s); setSettingsOpen(false); }} />
); }