Initial commit: simple frontend helper for mortgageFi, basic debt repayment and debt viewing functionality works, tested for both cbbtc and wbtc and not weth
This commit is contained in:
1
ABIs/mortgagefiusdccbbtcupgraded.json
Normal file
1
ABIs/mortgagefiusdccbbtcupgraded.json
Normal file
File diff suppressed because one or more lines are too long
58
README.md
58
README.md
@@ -1,36 +1,48 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
## MortgageFi DApp
|
||||
|
||||
## Getting Started
|
||||
This is the MortgageFi DApp frontend (Next.js App Router). The landing page is the DApp itself.
|
||||
|
||||
First, run the development server:
|
||||
### First-time NFT scan and cache
|
||||
|
||||
- On first load (or after deleting cache), click the button: "Scan 12 more (ownerOf)" repeatedly until your NFTs are discovered and cached.
|
||||
- The app stores results per chain and per ERC-721 contract in localStorage. If you add a new NFT later, click "Scan 12 more (ownerOf)" again to extend the cache.
|
||||
- You can Reset (current wallet only) or Delete Local Cache (entire contract cache) from the Inputs panel.
|
||||
|
||||
### Paying another wallet's debt (Manual Wallet)
|
||||
|
||||
- To pay a different wallet’s debt (e.g., a multisig), enter its address into the "Manual Wallet" field. The app will scan and read using this wallet.
|
||||
- Stablecoin balance and allowance checks still use your connected wallet for approvals and payments, but loan reads will target the manual wallet’s NFTs.
|
||||
|
||||
### Blockchain logs
|
||||
|
||||
- Verbose on-chain logs are printed to the browser console (scan, reads, and actions). Open DevTools → Console to view progress and diagnostics.
|
||||
|
||||
### Networks and presets
|
||||
|
||||
- Supported networks include Base and Arbitrum. Use the Network selector to switch.
|
||||
- Presets are available per chain to quickly populate ERC-721 and Debt contract addresses (e.g., cbBTC–USDC, WETH–USDC, USDTO–WBTC).
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### Environment
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
- Set `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` in `.env.local` for WalletConnect.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
### Deployment on Vercel
|
||||
|
||||
## Learn More
|
||||
- Monorepo: set the project root to `mortgagefi-frontend/` in Vercel.
|
||||
- Build command: `next build` (Turbopack is enabled by default via script).
|
||||
- Environment vars: set `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` in Vercel.
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
#### Using Gitea with Vercel
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- Vercel does not natively integrate with Gitea. Options:
|
||||
- Mirror your Gitea repository to GitHub/GitLab/Bitbucket and connect that to Vercel.
|
||||
- Or use the Vercel CLI to deploy from your local machine or CI pipeline.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
926
app/dapp/page.tsx
Normal file
926
app/dapp/page.tsx
Normal file
@@ -0,0 +1,926 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { Web3Provider } from "@/providers/Web3Provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: 'MortgageFi - Decentralized Mortgage Lending',
|
||||
description: 'Secure, flexible, and innovative solutions for your digital assets',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<body className={`${inter.className} bg-gray-50 min-h-screen`}>
|
||||
<Web3Provider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="bg-white border-t border-gray-200 py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} MortgageFi. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Web3Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
103
app/page.tsx
103
app/page.tsx
@@ -1,103 +1,6 @@
|
||||
import Image from "next/image";
|
||||
// Make the landing page the DApp page
|
||||
import DappPage from './dapp/page';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
return <DappPage />;
|
||||
}
|
||||
|
||||
22
app/readme/page.tsx
Normal file
22
app/readme/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default async function ReadmePage() {
|
||||
const filePath = path.join(process.cwd(), 'README.md');
|
||||
let content = 'README not found.';
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf-8');
|
||||
} catch (e) {
|
||||
content = 'README not found.';
|
||||
}
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">README</h1>
|
||||
<pre className="whitespace-pre-wrap text-sm leading-6 bg-white rounded border p-4">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/ConnectButton.tsx
Normal file
40
components/ConnectButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useWeb3Modal } from '@web3modal/wagmi/react';
|
||||
import { useAccount, useDisconnect } from 'wagmi';
|
||||
import { formatAddress } from '@/utils/format';
|
||||
|
||||
export function ConnectButton() {
|
||||
const { open } = useWeb3Modal();
|
||||
const { address, isConnected } = useAccount();
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
const handleConnect = () => {
|
||||
open();
|
||||
};
|
||||
|
||||
if (isConnected && address) {
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{formatAddress(address)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => disconnect()}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Connect Wallet
|
||||
</button>
|
||||
);
|
||||
}
|
||||
96
components/Navbar.tsx
Normal file
96
components/Navbar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { ConnectButton } from '@/components/ConnectButton';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'DApp', href: '/dapp', current: false },
|
||||
{ name: 'README', href: 'https://git.manko.yoga/manawenuz/mortgagefi-helper', current: false, external: true },
|
||||
];
|
||||
|
||||
function classNames(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
<Disclosure as="nav" className="bg-white shadow-sm">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<span className="text-xl font-bold text-indigo-600">MortgageFi</span>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : "_self"}
|
||||
rel={item.external ? "noopener noreferrer" : ""}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'border-indigo-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
||||
'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium'
|
||||
)}
|
||||
aria-current={item.current ? 'page' : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
<ConnectButton />
|
||||
</div>
|
||||
<div className="-mr-2 flex items-center sm:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
|
||||
<span className="sr-only">Open main menu</span>
|
||||
{open ? (
|
||||
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<Bars3Icon className="block h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Disclosure.Panel className="sm:hidden">
|
||||
<div className="pt-2 pb-3 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<Disclosure.Button
|
||||
key={item.name}
|
||||
as="a"
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : "_self"}
|
||||
rel={item.external ? "noopener noreferrer" : ""}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
|
||||
: 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700',
|
||||
'block pl-3 pr-4 py-2 border-l-4 text-base font-medium'
|
||||
)}
|
||||
aria-current={item.current ? 'page' : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Disclosure.Button>
|
||||
))}
|
||||
<div className="pt-4 pb-3 border-t border-gray-200">
|
||||
<div className="px-4">
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
42
config/web3.ts
Normal file
42
config/web3.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createConfig, http } from 'wagmi';
|
||||
import { mainnet, sepolia, base, arbitrum } from 'wagmi/chains';
|
||||
import { createWeb3Modal } from '@web3modal/wagmi';
|
||||
|
||||
// 1. Get projectId at https://cloud.walletconnect.com
|
||||
const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '';
|
||||
if (!projectId) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Web3] Missing NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID. WalletConnect wallet list will be limited.');
|
||||
}
|
||||
|
||||
// 2. Create wagmiConfig
|
||||
const metadata = {
|
||||
name: 'MortgageFi',
|
||||
description: 'Decentralized Mortgage Lending Platform',
|
||||
url: 'https://mortgagefi.app',
|
||||
icons: ['https://mortgagefi.app/logo.png']
|
||||
};
|
||||
|
||||
export const config = createConfig({
|
||||
chains: [base, arbitrum, mainnet, sepolia],
|
||||
transports: {
|
||||
[base.id]: http(),
|
||||
[arbitrum.id]: http(),
|
||||
[mainnet.id]: http(),
|
||||
[sepolia.id]: http(),
|
||||
},
|
||||
ssr: true,
|
||||
});
|
||||
|
||||
// 3. Create modal
|
||||
export const web3Modal = createWeb3Modal({
|
||||
wagmiConfig: config,
|
||||
projectId: projectId || 'missing_project_id',
|
||||
enableAnalytics: true,
|
||||
enableOnramp: true,
|
||||
themeMode: 'light',
|
||||
themeVariables: {
|
||||
'--w3m-accent': '#4F46E5',
|
||||
'--w3m-font-family': 'Inter, sans-serif',
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
6512
package-lock.json
generated
6512
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -9,19 +9,28 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@web3-react/core": "^8.2.3",
|
||||
"@web3-react/injected-connector": "^6.0.7",
|
||||
"@web3modal/wagmi": "^5.1.11",
|
||||
"framer-motion": "^12.23.12",
|
||||
"next": "15.5.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.0"
|
||||
"viem": "^2.35.1",
|
||||
"wagmi": "^2.16.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.0",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
61
providers/Web3Provider.tsx
Normal file
61
providers/Web3Provider.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { createWeb3Modal } from '@web3modal/wagmi/react';
|
||||
import { WagmiProvider, createConfig, http } from 'wagmi';
|
||||
import { mainnet, sepolia, base, arbitrum } from 'wagmi/chains';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
|
||||
const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '';
|
||||
if (!projectId) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Web3] Missing NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID. WalletConnect will be limited.');
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
name: 'MortgageFi',
|
||||
description: 'Decentralized Mortgage Lending Platform',
|
||||
url: 'https://mortgagefi.app',
|
||||
icons: ['https://mortgagefi.app/logo.png']
|
||||
};
|
||||
|
||||
const config = createConfig({
|
||||
chains: [base, arbitrum, mainnet, sepolia],
|
||||
transports: {
|
||||
[base.id]: http(),
|
||||
[arbitrum.id]: http(),
|
||||
[mainnet.id]: http(),
|
||||
[sepolia.id]: http(),
|
||||
},
|
||||
ssr: true,
|
||||
});
|
||||
|
||||
createWeb3Modal({
|
||||
wagmiConfig: config,
|
||||
projectId: projectId || 'missing_project_id',
|
||||
enableAnalytics: true,
|
||||
enableOnramp: true,
|
||||
themeMode: 'light',
|
||||
themeVariables: {
|
||||
'--w3m-accent': '#4F46E5',
|
||||
'--w3m-font-family': 'Inter, sans-serif',
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function Web3Provider({ children }: PropsWithChildren) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{mounted && children}
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
}
|
||||
21
utils/format.ts
Normal file
21
utils/format.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function formatAddress(address: string): string {
|
||||
if (!address) return '';
|
||||
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency: string = 'USD'): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatAPY(apy: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(apy / 100);
|
||||
}
|
||||
Reference in New Issue
Block a user