'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 [deeplinkTokenId, setDeeplinkTokenId] = 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: '', backupEmail: '', backupDelayDays: 1, 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) }; // Consume deep-link payload from /dapp/position/[network]/[preset]/[tokenId] useEffect(() => { try { if (typeof window === 'undefined') return; const raw = localStorage.getItem('dapp:deeplink:v1'); if (!raw) return; localStorage.removeItem('dapp:deeplink:v1'); const parsed = JSON.parse(raw || '{}') as { network?: string; preset?: number; tokenId?: string; ts?: number }; const age = Math.floor(Date.now() / 1000) - (parsed.ts || 0); if (age > 600) return; // ignore stale links >10min const net = String(parsed.network || '').toUpperCase(); const presetIdx = Math.max(1, Number(parsed.preset || 1)); const nextChainId = net === 'BASE' ? base.id : ((net === 'ARBITRUM' || net === 'ARB') ? arbitrum.id : selectedChainId); if (nextChainId !== selectedChainId) { setSelectedChainId(nextChainId); loadChainDefaults(nextChainId); } const list = PRESETS[nextChainId] || []; const item = list[presetIdx - 1]; if (item?.key) setPresetKey(item.key); // Force single-position view for deep link if (parsed.tokenId) setDeeplinkTokenId(String(parsed.tokenId)); } catch {} // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 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(() => { if (deeplinkTokenId.trim()) { try { return [BigInt(deeplinkTokenId.trim())]; } catch { return []; } } const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : []; return [...detectedTokenIds, ...manual]; }, [detectedTokenIds, manualTokenId, deeplinkTokenId]); // 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, opts?: { email?: 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' }; const targetEmail = (opts?.email || notifSettings.email || '').trim(); if (targetEmail) headers['X-Email'] = String(targetEmail); 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 origin = (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : 'https://markets.mortgagefi.app'; const dappUrl = `${origin}/dapp`; const networkSlug = selectedChainId === base.id ? 'BASE' : (selectedChainId === arbitrum.id ? 'ARBITRUM' : 'UNKNOWN'); const presetIdx = Math.max(0, (PRESETS[selectedChainId]?.findIndex(p => p.key === presetKey) ?? 0)) + 1; // 1-based const positionUrl = `${origin}/dapp/position/${networkSlug}/${presetIdx}/${row.tokenId.toString()}`; 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}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; const liqAt = nowSec + Math.max(0, seconds); const runAt1 = liqAt - offset; if (runAt1 <= nowSec) throw new Error('Computed run time is in the past'); // Primary job (to main email) const req1 = buildNotificationRequest(row, msg); const res1 = await scheduleJob(notifSettings, { runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers }); // Optional backup job const jobs: Array<{ id: string; at: number; label: 'lead' | 'half' | 'last' }> = [ { id: res1.jobId, at: runAt1, label: 'lead' }, ]; const bEmail = (notifSettings.backupEmail || '').trim(); const bDelayDays = Math.max(0, Number(notifSettings.backupDelayDays ?? 0)); if (bEmail && bDelayDays >= 1) { const requested = runAt1 + Math.floor(bDelayDays * 86400); const min1dBeforeLiq = liqAt - 86400; // must be >= 1 day before liq const runAt2 = Math.min(requested, min1dBeforeLiq); if (runAt2 > nowSec && runAt2 > runAt1) { const primaryEmail = (notifSettings.email || '').trim(); const prefix = `You are the backup contact for this position. Please contact the primary contact${primaryEmail ? ' ' + primaryEmail : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`; const humanLeftBackup = human(Math.max(0, liqAt - runAt2)); const msgBackupCore = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeftBackup}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; const backupMsg = `${prefix}\n\n${msgBackupCore}`; const req2 = buildNotificationRequest(row, backupMsg, { email: bEmail }); const res2 = await scheduleJob(notifSettings, { runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers }); jobs.push({ id: res2.jobId, at: runAt2, label: 'last' }); } } return { jobs }; }; 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 scheduledAtLead = (p.jobs?.find(j => j.label === 'lead')?.at) || (p.scheduledAt || 0); const drift = Math.abs(scheduledAtLead - targetRunAt); const hasAnyJob = (p.jobs && p.jobs.length > 0) || Boolean(p.jobId); if (!hasAnyJob || drift > 60) { // reschedule if (p) try { await cancelNotification(p); } catch {} try { const { jobs } = await scheduleNotification(row); setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs } })); } 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.

{selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Selected'} network is required. The app will attempt to detect your token IDs via on-chain queries when supported.

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 { jobs } = await scheduleNotification(row); setPositionsNotif((prev) => ({ ...prev, [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs }, })); } 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 )} ); })()}
)}
); })}
{/* Footer note removed per requirements */} {/* Settings Modal */} setSettingsOpen(false)} onSave={(s) => { console.log('[Settings] Save requested', s); setNotifSettings(s); setSettingsOpen(false); }} />
); }