Files
mortgagefi-helper/app/dapp/page.tsx

927 lines
40 KiB
TypeScript

'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';
// 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<number, { key: string; label: string; nft: string; debt: string }[]> = {
[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 [selectedChainId, setSelectedChainId] = useState<number>(base.id);
const [nftAddress, setNftAddress] = useState<string>(DEFAULTS[base.id].nft);
const [debtAddress, setDebtAddress] = useState<string>(DEFAULTS[base.id].debt);
const [presetKey, setPresetKey] = useState<string>('cbBTC-USDC');
const [manualWallet, setManualWallet] = useState<string>('');
const [manualTokenId, setManualTokenId] = useState('');
const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]);
const [scanBusy, setScanBusy] = useState(false);
const [scanComplete, setScanComplete] = useState(false);
const [payInputs, setPayInputs] = useState<Record<string, string>>({});
const publicClient = usePublicClient({ chainId: selectedChainId });
const { writeContractAsync, isPending: writePending } = useWriteContract();
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
// 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<string, WalletCache>;
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<boolean | null>(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);
}
};
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<any> = [];
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]);
// Map tokenId -> max payable (Amount to reset timer)
const payMaxByTokenId = useMemo(() => {
const m: Record<string, bigint> = {};
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 (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-semibold text-gray-100">MortgageFi DApp</h1>
<p className="text-sm text-gray-100">Connect wallet, detect your NFTs, and fetch debt details.</p>
{!isConnected && (
<div className="rounded border p-4 bg-yellow-50">Please connect your wallet using the Connect Wallet button in the navbar.</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded border p-4 bg-white space-y-3">
<div className="font-medium text-gray-900">Inputs</div>
<label className="block text-sm text-gray-900">
<span className="text-gray-900">Network</span>
<select
value={selectedChainId}
onChange={(e) => setSelectedChainId(Number(e.target.value))}
className="mt-1 w-full border rounded px-2 py-1"
>
<option value={base.id}>Base</option>
<option value={arbitrum.id}>Arbitrum</option>
</select>
</label>
{/* Preset selector for pair addresses */}
<label className="block text-sm text-gray-900">
<span className="text-gray-900">Preset</span>
<select
className="mt-1 w-full border rounded px-2 py-1"
value={presetKey}
onChange={(e) => {
const key = e.target.value;
setPresetKey(key);
const list = PRESETS[selectedChainId] || [];
const found = list.find((x) => x.key === key);
if (found) {
setNftAddress(found.nft);
setDebtAddress(found.debt);
}
}}
>
{(PRESETS[selectedChainId] || []).map((p) => (
<option key={p.key} value={p.key}>{p.label}</option>
))}
</select>
</label>
<label className="block text-sm text-gray-900">
<span className="text-gray-900">ERC-721 Address</span>
<input value={nftAddress} onChange={(e) => setNftAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.nft} className="mt-1 w-full border rounded px-2 py-1" />
</label>
<label className="block text-sm text-gray-900">
<span className="text-gray-900">Debt Contract</span>
<input value={debtAddress} onChange={(e) => setDebtAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.debt} className="mt-1 w-full border rounded px-2 py-1" />
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => loadChainDefaults(selectedChainId)}
className="px-3 py-1.5 bg-gray-200 text-gray-900 rounded"
>
Load defaults for selected network
</button>
</div>
<label className="block text-sm text-gray-900">
<span className="text-gray-900">Manual Wallet (optional)</span>
<input value={manualWallet} onChange={(e) => setManualWallet(e.target.value)} placeholder="0xYourWallet" className="mt-1 w-full border rounded px-2 py-1" />
<span className="text-xs text-gray-900">If set, scans and reads will use this address instead of the connected one.</span>
</label>
<label className="block text-sm text-gray-900">
<span className="text-gray-900">Manual Token ID (optional)</span>
<input value={manualTokenId} onChange={(e) => setManualTokenId(e.target.value)} placeholder="e.g., 103" className="mt-1 w-full border rounded px-2 py-1" />
</label>
<div className="flex items-center gap-2">
<button onClick={scanMore} disabled={!effectiveWallet || scanBusy || !nftAddress || scanComplete} className="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50">
{scanBusy ? 'Scanning…' : scanComplete ? 'Scan Complete' : 'Scan 12 more (ownerOf)'}
</button>
<button onClick={resetScan} disabled={!effectiveWallet || scanBusy || !nftAddress} className="px-3 py-1.5 bg-gray-200 text-gray-900 rounded">
Reset
</button>
<button onClick={deleteLocalCache} disabled={scanBusy || !nftAddress} className="px-3 py-1.5 bg-gray-200 text-gray-900 rounded">
Delete Local Cache
</button>
</div>
{scanComplete && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded p-2 mt-2">
Owner scan completed (gap limit reached). Reset or delete cache to rescan.
</div>
)}
<div className="text-xs text-gray-900">
Cached IDs: {detectedTokenIds.length ? detectedTokenIds.map((id) => id.toString()).join(', ') : '—'}
</div>
</div>
<div className="rounded border p-4 bg-white">
<div className="font-medium text-gray-900">About</div>
<p className="text-sm text-gray-900 mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
<p className="text-xs text-gray-900 mt-2">Base network is required. The app will attempt to detect your token IDs via on-chain queries.</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-gray-100">
<button onClick={() => refetch()} disabled={!tokenIds.length} className="px-4 py-2 bg-indigo-600 text-white rounded disabled:opacity-50">Fetch Debt Data</button>
<span className="text-sm">Chain: {selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Other'}</span>
<span className="text-sm">{String(coinSymbol ?? '')} Balance: {fmt(contractCoinBalance as bigint | undefined, Number(coinDecimals ?? 8), 8)} {String(coinSymbol ?? '')}</span>
<span className="text-sm">Stable Balance: {fmt(stableBalance as bigint | undefined, Number(stableDecimals ?? 6), 6)} {String(stableSymbol ?? '')}</span>
<span className="text-sm">Allowance Debt: {fmt(stableAllowance as bigint | undefined, Number(stableDecimals ?? 6), 6)} {String(stableSymbol ?? '')}</span>
<span className="text-sm flex items-center gap-2">
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 (
<button
className="px-2 py-1 bg-emerald-600 text-white rounded disabled:opacity-50 text-xs"
disabled={!canCompound}
onClick={handleCompound}
title={manualSet ? 'Compound disabled when Manual Wallet is set' : (!hasOnePlus ? 'Requires > 1 token balance' : undefined)}
>
{txPending ? 'Compounding…' : 'Compound'}
</button>
);
})()}
</span>
</div>
{debtError && <div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-800">{String(debtError?.message || debtError)}</div>}
<div className="space-y-3">
{debtLoading && <div>Loading...</div>}
{!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 (
<div key={row.tokenId.toString()} className={`rounded border p-4 shadow-sm ${isClosed ? 'bg-gray-50 border-gray-200' : 'bg-white'}`}>
<div className="flex items-center gap-2">
<div className="font-semibold text-gray-900">Loan Details for {String(stableSymbol ?? 'USDC')}-{String(coinSymbol ?? 'cbBTC')} - ID: {row.tokenId.toString()}</div>
{isClosed && (
<span className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
Closed/Defaulted
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm mt-2">
<div className="text-gray-800">Loan Collateral</div>
<div className={`text-gray-900 ${isClosed ? 'line-through' : ''}`}>{fmt(row.coinSize, coinDec, 8)} {String(coinSymbol ?? '')}</div>
<div className="text-gray-800">Interest Rate</div>
<div className="text-gray-900">{aprDisplay}</div>
<div className="text-gray-800">Loan Dollar Value</div>
<div className="text-gray-900">{fmt(row.baseSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Cost to close Loan</div>
<div className="text-gray-900">{fmt(costToClose2pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Cost to Close</div>
<div className="text-gray-900">{fmt(totalCostToClose102pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Interest to pay over Length of Loan</div>
<div className="text-gray-900">{fmt(row.feeSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Repaid</div>
<div className="text-gray-900">{fmt(row.amountPaid, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Debt Remaining</div>
<div className="text-gray-900">{fmt(row.debtAtThisSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Amount to reset timer</div>
<div className="text-gray-900">{fmt(row.currentPaymentPending, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Monthly Payment Size</div>
<div className="text-gray-900">{fmt(monthlyPaymentFixed, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Loan Term</div>
<div className="text-gray-900">{loanTermDisplay}</div>
<div className="text-gray-800">Start Date</div>
<div className="text-gray-900">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
<div className="text-gray-800">Seconds Till Liquidation</div>
<div className="text-gray-900">{fmtDuration(row.secondsTillLiq)}</div>
</div>
{/* Pay controls (hidden for Closed/Defaulted loans) */}
{!isClosed && (
<div className="mt-3 border-t pt-3">
<div className="text-sm font-medium text-gray-900 mb-2">Make a Payment</div>
<div className="flex flex-wrap items-center gap-2">
{(() => {
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 (
<>
<input
type="text"
inputMode="decimal"
placeholder={`Amount in ${String(stableSymbol ?? '')}`}
className="border rounded px-2 py-1 text-sm w-40 text-gray-900 placeholder-gray-500"
value={inputVal}
onChange={(e) => setPaymentAmount(row.tokenId, e.target.value)}
/>
{!hasAllowance ? (
<button
className="px-3 py-1.5 bg-gray-200 text-gray-900 rounded"
onClick={() => handleApprove(row.tokenId)}
disabled={disableAll || amount === null || amount === BigInt(0)}
>
Approve
</button>
) : null}
<button
className="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50"
onClick={() => handlePay(row.tokenId)}
disabled={disableAll || amount === null || amount === BigInt(0) || !hasAllowance || !hasBalance}
title={!hasAllowance ? 'Approve the stablecoin first' : (!hasBalance ? 'Insufficient balance' : undefined)}
>
Pay
</button>
{!hasBalance && amount !== null && (
<span className="text-xs text-red-600">Insufficient balance</span>
)}
</>
);
})()}
</div>
</div>
)}
</div>
);
})}
</div>
<div className="text-xs text-gray-500">
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.
</div>
</div>
);
}