Alerts: backup-contact phrasing; correct backup countdown; include per-position pay link; deep-link route rendering in-place; single-position view via deep-link; ARB alias; backup test message alignment

This commit is contained in:
Siavash Sameni
2025-08-28 11:29:37 +04:00
parent 6c4a8dfe83
commit 29c346e868
5 changed files with 199 additions and 30 deletions

View File

@@ -55,6 +55,7 @@ export default function DappPage() {
const [presetKey, setPresetKey] = useState<string>('cbBTC-USDC');
const [manualWallet, setManualWallet] = useState<string>('');
const [manualTokenId, setManualTokenId] = useState('');
const [deeplinkTokenId, setDeeplinkTokenId] = useState('');
const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]);
const [scanBusy, setScanBusy] = useState(false);
const [scanComplete, setScanComplete] = useState(false);
@@ -86,6 +87,8 @@ export default function DappPage() {
schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY,
email: '',
backupEmail: '',
backupDelayDays: 1,
daysBefore: 10,
});
useEffect(() => {
@@ -143,6 +146,32 @@ export default function DappPage() {
complete?: boolean; // when true, stop further ownerOf scans (gap limit reached)
};
// Consume deep-link payload from /dapp/position/[network]/[preset]/[tokenId]
useEffect(() => {
try {
if (typeof window === 'undefined') return;
const raw = localStorage.getItem('dapp:deeplink:v1');
if (!raw) return;
localStorage.removeItem('dapp:deeplink:v1');
const parsed = JSON.parse(raw || '{}') as { network?: string; preset?: number; tokenId?: string; ts?: number };
const age = Math.floor(Date.now() / 1000) - (parsed.ts || 0);
if (age > 600) return; // ignore stale links >10min
const net = String(parsed.network || '').toUpperCase();
const presetIdx = Math.max(1, Number(parsed.preset || 1));
const nextChainId = net === 'BASE' ? base.id : ((net === 'ARBITRUM' || net === 'ARB') ? arbitrum.id : selectedChainId);
if (nextChainId !== selectedChainId) {
setSelectedChainId(nextChainId);
loadChainDefaults(nextChainId);
}
const list = PRESETS[nextChainId] || [];
const item = list[presetIdx - 1];
if (item?.key) setPresetKey(item.key);
// Force single-position view for deep link
if (parsed.tokenId) setDeeplinkTokenId(String(parsed.tokenId));
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Utility: sleep for backoff
@@ -397,9 +426,12 @@ export default function DappPage() {
// Resolve tokenIds: either detected or manual entry
const tokenIds = useMemo(() => {
if (deeplinkTokenId.trim()) {
try { return [BigInt(deeplinkTokenId.trim())]; } catch { return []; }
}
const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : [];
return [...detectedTokenIds, ...manual];
}, [detectedTokenIds, manualTokenId]);
}, [detectedTokenIds, manualTokenId, deeplinkTokenId]);
// Build reads for debt contract
const debtReads = useMemo(() => {
@@ -622,14 +654,15 @@ export default function DappPage() {
};
// Helpers to construct provider webhook URL/body for cronhost
const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string) => {
const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string, opts?: { email?: string }) => {
const provider = notifSettings.provider;
if (!provider) throw new Error('Notification provider not configured');
if (provider === 'ntfy') {
const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, '');
const topic = notifSettings.ntfyTopic || '';
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
if ((notifSettings.email || '').trim()) headers['X-Email'] = String(notifSettings.email);
const targetEmail = (opts?.email || notifSettings.email || '').trim();
if (targetEmail) headers['X-Email'] = String(targetEmail);
return {
url: `${baseUrl}/${encodeURIComponent(topic)}`,
method: 'POST' as const,
@@ -685,7 +718,11 @@ export default function DappPage() {
const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined;
const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined;
const debtRemaining = (row as any)?.debtAtThisSize as bigint | undefined;
const dappUrl = (typeof window !== 'undefined' && window.location?.origin) ? `${window.location.origin}/dapp` : 'https://markets.mortgagefi.app/dashboard';
const origin = (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : 'https://markets.mortgagefi.app';
const dappUrl = `${origin}/dapp`;
const networkSlug = selectedChainId === base.id ? 'BASE' : (selectedChainId === arbitrum.id ? 'ARBITRUM' : 'UNKNOWN');
const presetIdx = Math.max(0, (PRESETS[selectedChainId]?.findIndex(p => p.key === presetKey) ?? 0)) + 1; // 1-based
const positionUrl = `${origin}/dapp/position/${networkSlug}/${presetIdx}/${row.tokenId.toString()}`;
const humanLeft = human(offset);
const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2);
@@ -705,13 +742,38 @@ export default function DappPage() {
const collateralStr = fmt((row as any)?.coinSize as bigint | undefined, Number(coinDecimals ?? 8), 8);
const collateralSym = String(coinSymbol ?? '');
const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
const runAt = nowSec + Math.max(0, seconds - offset);
if (runAt <= nowSec) throw new Error('Computed run time is in the past');
const req = buildNotificationRequest(row, msg);
const res = await scheduleJob(notifSettings, { runAtEpoch: runAt, method: req.method, url: req.url, body: req.body, headers: req.headers });
return { jobId: res.jobId, runAt };
const liqAt = nowSec + Math.max(0, seconds);
const runAt1 = liqAt - offset;
if (runAt1 <= nowSec) throw new Error('Computed run time is in the past');
// Primary job (to main email)
const req1 = buildNotificationRequest(row, msg);
const res1 = await scheduleJob(notifSettings, { runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers });
// Optional backup job
const jobs: Array<{ id: string; at: number; label: 'lead' | 'half' | 'last' }> = [
{ id: res1.jobId, at: runAt1, label: 'lead' },
];
const bEmail = (notifSettings.backupEmail || '').trim();
const bDelayDays = Math.max(0, Number(notifSettings.backupDelayDays ?? 0));
if (bEmail && bDelayDays >= 1) {
const requested = runAt1 + Math.floor(bDelayDays * 86400);
const min1dBeforeLiq = liqAt - 86400; // must be >= 1 day before liq
const runAt2 = Math.min(requested, min1dBeforeLiq);
if (runAt2 > nowSec && runAt2 > runAt1) {
const primaryEmail = (notifSettings.email || '').trim();
const prefix = `You are the backup contact for this position. Please contact the primary contact${primaryEmail ? ' ' + primaryEmail : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`;
const humanLeftBackup = human(Math.max(0, liqAt - runAt2));
const msgBackupCore = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeftBackup}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
const backupMsg = `${prefix}\n\n${msgBackupCore}`;
const req2 = buildNotificationRequest(row, backupMsg, { email: bEmail });
const res2 = await scheduleJob(notifSettings, { runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers });
jobs.push({ id: res2.jobId, at: runAt2, label: 'last' });
}
}
return { jobs };
};
const cancelNotification = async (p: PositionNotification) => {
@@ -800,13 +862,15 @@ export default function DappPage() {
if (!Number.isFinite(seconds) || seconds <= 0) continue;
const lead = Math.max(0, Number(notifSettings.daysBefore ?? 0) * 86400);
const targetRunAt = Math.floor(Date.now() / 1000) + Math.max(0, seconds - (lead > 0 ? lead : 86400));
const drift = Math.abs((p.scheduledAt || 0) - targetRunAt);
if (!p.jobId || drift > 60) {
// reschedule single
const scheduledAtLead = (p.jobs?.find(j => j.label === 'lead')?.at) || (p.scheduledAt || 0);
const drift = Math.abs(scheduledAtLead - targetRunAt);
const hasAnyJob = (p.jobs && p.jobs.length > 0) || Boolean(p.jobId);
if (!hasAnyJob || drift > 60) {
// reschedule
if (p) try { await cancelNotification(p); } catch {}
try {
const { jobId, runAt } = await scheduleNotification(row);
setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt } }));
const { jobs } = await scheduleNotification(row);
setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs } }));
} catch (e) {
console.warn('[notif] reschedule failed', e);
}
@@ -983,7 +1047,7 @@ export default function DappPage() {
<div className="rounded border p-4 bg-gray-800 border-gray-700">
<div className="font-medium text-gray-100">About</div>
<p className="text-sm text-gray-300 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-400 mt-2">Base network is required. The app will attempt to detect your token IDs via on-chain queries.</p>
<p className="text-xs text-gray-400 mt-2">{selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Selected'} network is required. The app will attempt to detect your token IDs via on-chain queries when supported.</p>
</div>
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
@@ -1168,10 +1232,10 @@ export default function DappPage() {
return;
}
try {
const { jobId, runAt } = await scheduleNotification(row);
const { jobs } = await scheduleNotification(row);
setPositionsNotif((prev) => ({
...prev,
[key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt },
[key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs },
}));
} catch (e) {
console.warn('[notif] schedule failed', e);
@@ -1243,9 +1307,7 @@ export default function DappPage() {
})}
</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>
{/* Footer note removed per requirements */}
{/* Settings Modal */}
<SettingsModal

View File

@@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import DappPage from "../../../../page";
export default function PositionDeepLinkPage() {
const params = useParams<{ network: string; preset: string; tokenId: string }>();
useEffect(() => {
try {
const network = String(params?.network || '').toUpperCase();
const preset = Number(params?.preset || '1');
const tokenId = String(params?.tokenId || '');
if (!network || !preset || !tokenId) return;
// Persist for Dapp page to consume on mount; keep URL unchanged
const payload = { network, preset, tokenId, ts: Math.floor(Date.now() / 1000) };
if (typeof window !== 'undefined') {
localStorage.setItem('dapp:deeplink:v1', JSON.stringify(payload));
}
} catch {}
}, [params]);
// Render the main Dapp UI without redirect; it will consume the deep-link
return <DappPage />;
}