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 [presetKey, setPresetKey] = useState<string>('cbBTC-USDC');
const [manualWallet, setManualWallet] = useState<string>(''); const [manualWallet, setManualWallet] = useState<string>('');
const [manualTokenId, setManualTokenId] = useState(''); const [manualTokenId, setManualTokenId] = useState('');
const [deeplinkTokenId, setDeeplinkTokenId] = useState('');
const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]); const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]);
const [scanBusy, setScanBusy] = useState(false); const [scanBusy, setScanBusy] = useState(false);
const [scanComplete, setScanComplete] = useState(false); const [scanComplete, setScanComplete] = useState(false);
@@ -86,6 +87,8 @@ export default function DappPage() {
schedyBaseUrl: ENV_SCHEDY, schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY, schedyApiKey: ENV_SCHEDY_API_KEY,
email: '', email: '',
backupEmail: '',
backupDelayDays: 1,
daysBefore: 10, daysBefore: 10,
}); });
useEffect(() => { useEffect(() => {
@@ -143,6 +146,32 @@ export default function DappPage() {
complete?: boolean; // when true, stop further ownerOf scans (gap limit reached) 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 // Utility: sleep for backoff
@@ -397,9 +426,12 @@ export default function DappPage() {
// Resolve tokenIds: either detected or manual entry // Resolve tokenIds: either detected or manual entry
const tokenIds = useMemo(() => { const tokenIds = useMemo(() => {
if (deeplinkTokenId.trim()) {
try { return [BigInt(deeplinkTokenId.trim())]; } catch { return []; }
}
const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : []; const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : [];
return [...detectedTokenIds, ...manual]; return [...detectedTokenIds, ...manual];
}, [detectedTokenIds, manualTokenId]); }, [detectedTokenIds, manualTokenId, deeplinkTokenId]);
// Build reads for debt contract // Build reads for debt contract
const debtReads = useMemo(() => { const debtReads = useMemo(() => {
@@ -622,14 +654,15 @@ export default function DappPage() {
}; };
// Helpers to construct provider webhook URL/body for cronhost // 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; const provider = notifSettings.provider;
if (!provider) throw new Error('Notification provider not configured'); if (!provider) throw new Error('Notification provider not configured');
if (provider === 'ntfy') { if (provider === 'ntfy') {
const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, ''); const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, '');
const topic = notifSettings.ntfyTopic || ''; const topic = notifSettings.ntfyTopic || '';
const headers: Record<string, string> = { 'Content-Type': 'text/plain' }; 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 { return {
url: `${baseUrl}/${encodeURIComponent(topic)}`, url: `${baseUrl}/${encodeURIComponent(topic)}`,
method: 'POST' as const, method: 'POST' as const,
@@ -685,7 +718,11 @@ export default function DappPage() {
const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined; const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined;
const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined; const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined;
const debtRemaining = (row as any)?.debtAtThisSize 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 humanLeft = human(offset);
const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2); 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 collateralStr = fmt((row as any)?.coinSize as bigint | undefined, Number(coinDecimals ?? 8), 8);
const collateralSym = String(coinSymbol ?? ''); 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); const liqAt = nowSec + Math.max(0, seconds);
if (runAt <= nowSec) throw new Error('Computed run time is in the past'); const runAt1 = liqAt - offset;
const req = buildNotificationRequest(row, msg); if (runAt1 <= nowSec) throw new Error('Computed run time is in the past');
const res = await scheduleJob(notifSettings, { runAtEpoch: runAt, method: req.method, url: req.url, body: req.body, headers: req.headers }); // Primary job (to main email)
return { jobId: res.jobId, runAt }; 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) => { const cancelNotification = async (p: PositionNotification) => {
@@ -800,13 +862,15 @@ export default function DappPage() {
if (!Number.isFinite(seconds) || seconds <= 0) continue; if (!Number.isFinite(seconds) || seconds <= 0) continue;
const lead = Math.max(0, Number(notifSettings.daysBefore ?? 0) * 86400); 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 targetRunAt = Math.floor(Date.now() / 1000) + Math.max(0, seconds - (lead > 0 ? lead : 86400));
const drift = Math.abs((p.scheduledAt || 0) - targetRunAt); const scheduledAtLead = (p.jobs?.find(j => j.label === 'lead')?.at) || (p.scheduledAt || 0);
if (!p.jobId || drift > 60) { const drift = Math.abs(scheduledAtLead - targetRunAt);
// reschedule single const hasAnyJob = (p.jobs && p.jobs.length > 0) || Boolean(p.jobId);
if (!hasAnyJob || drift > 60) {
// reschedule
if (p) try { await cancelNotification(p); } catch {} if (p) try { await cancelNotification(p); } catch {}
try { try {
const { jobId, runAt } = await scheduleNotification(row); const { jobs } = await scheduleNotification(row);
setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt } })); setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs } }));
} catch (e) { } catch (e) {
console.warn('[notif] reschedule failed', 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="rounded border p-4 bg-gray-800 border-gray-700">
<div className="font-medium text-gray-100">About</div> <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-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>
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3"> <div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
@@ -1168,10 +1232,10 @@ export default function DappPage() {
return; return;
} }
try { try {
const { jobId, runAt } = await scheduleNotification(row); const { jobs } = await scheduleNotification(row);
setPositionsNotif((prev) => ({ setPositionsNotif((prev) => ({
...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) { } catch (e) {
console.warn('[notif] schedule failed', e); console.warn('[notif] schedule failed', e);
@@ -1243,9 +1307,7 @@ export default function DappPage() {
})} })}
</div> </div>
<div className="text-xs text-gray-500"> {/* Footer note removed per requirements */}
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>
{/* Settings Modal */} {/* Settings Modal */}
<SettingsModal <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 />;
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications"; import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications";
import { scheduleTestNotification } from "@/utils/scheduler"; import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler";
export interface SettingsModalProps { export interface SettingsModalProps {
open: boolean; open: boolean;
@@ -28,6 +28,8 @@ const defaultSettings: NotificationSettings = {
schedyBaseUrl: ENV_SCHEDY, schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY, schedyApiKey: ENV_SCHEDY_API_KEY,
email: "", email: "",
backupEmail: "",
backupDelayDays: 1,
daysBefore: 10, daysBefore: 10,
}; };
@@ -37,13 +39,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const base: any = initial || defaultSettings; // Merge defaults to ensure newly added fields (e.g., backupDelayDays) are populated
const next: any = { ...base }; const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings;
if (next.scheduler !== 'schedy') next.scheduler = 'schedy'; // Re-apply env defaults if user hasn't set values
for (const k of ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']) { if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY;
if (k in next) delete next[k]; if (!merged.schedyBaseUrl) merged.schedyBaseUrl = ENV_SCHEDY;
} setForm(merged);
setForm(next);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial]); }, [open, initial]);
@@ -55,6 +56,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
if (!form.email) return true; if (!form.email) return true;
const db = form.daysBefore ?? 10; const db = form.daysBefore ?? 10;
if (Number(db) < 0) return true; if (Number(db) < 0) return true;
// if backupEmail provided, enforce min backupDelayDays >= 1 (treat empty/NaN as invalid)
const hasBackup = Boolean((form.backupEmail || '').trim());
if (hasBackup) {
const bdd = Number(form.backupDelayDays);
if (!Number.isFinite(bdd) || bdd < 1) return true;
}
if (!provider) return true; if (!provider) return true;
if (!scheduler) return true; if (!scheduler) return true;
if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic); if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic);
@@ -80,6 +87,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
} }
}; };
const onTestBackup = async () => {
setTestStatus('Scheduling backup test…');
try {
const { jobId } = await scheduleTestBackupNotification(form);
setTestStatus(`Backup test scheduled (job ${jobId}). You should receive a backup email shortly.`);
} catch (e: any) {
setTestStatus(`Backup test failed: ${e?.message || String(e)}`);
}
};
if (!open) return null; if (!open) return null;
return ( return (
@@ -146,6 +163,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
/> />
</label> </label>
<label>
<span className="text-gray-300">Backup Email (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="backup@example.com"
value={form.backupEmail || ''}
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
/>
</label>
<label> <label>
<span className="text-gray-300">Days before liquidation</span> <span className="text-gray-300">Days before liquidation</span>
<input <input
@@ -156,7 +183,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))} onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
/> />
</label> </label>
<div /> <label>
<span className="text-gray-300">Backup delay (days after first email)</span>
<input
type="number"
min={1}
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
value={form.backupDelayDays ?? 1}
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
/>
</label>
{provider === 'ntfy' && ( {provider === 'ntfy' && (
<> <>
@@ -260,6 +296,14 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
> >
Send test alert Send test alert
</button> </button>
<button
className="rounded bg-teal-600 px-3 py-1.5 disabled:opacity-50"
disabled={!canTest || !(form.backupEmail || '').trim()}
onClick={onTestBackup}
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
>
Send backup test
</button>
<button <button
className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50" className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50"
disabled={saveDisabled} disabled={saveDisabled}

View File

@@ -20,6 +20,10 @@ export interface NotificationSettings {
schedyApiKey?: string; schedyApiKey?: string;
// Contact details // Contact details
email?: string; email?: string;
// Backup contact email and delay after first email
backupEmail?: string;
// Days after the first email to send a backup notification
backupDelayDays?: number;
// Lead time before liquidation in days // Lead time before liquidation in days
daysBefore?: number; // default 0 daysBefore?: number; // default 0
} }

View File

@@ -8,6 +8,39 @@ export interface ScheduleArgs {
headers?: Record<string, string>; headers?: Record<string, string>;
} }
// Schedule a backup-email specific test (uses backupEmail target)
export async function scheduleTestBackupNotification(settings: NotificationSettings): Promise<ScheduleResult> {
const scheduler = settings.scheduler || 'schedy';
if (scheduler !== 'schedy') throw new Error('Only Schedy is supported for tests');
const backup = (settings.backupEmail || '').trim();
if (!backup) throw new Error('Please set a backup email');
if (settings.provider !== 'ntfy') {
throw new Error('Backup test currently supports ntfy provider only');
}
const topic = (settings.ntfyTopic || '').trim();
const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
if (!base || !topic) throw new Error('ntfy server or topic missing');
const relUrl = `${base}/${encodeURIComponent(topic)}`;
const now = Math.floor(Date.now() / 1000);
const runAtEpoch = now + 120; // 2 minutes
const primary = (settings.email || '').trim();
const prefix = `You are the backup contact for this position. Please contact the primary contact${primary ? ' ' + primary : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`;
const body = `${prefix}\n\n(MortgageFi backup email test at ${new Date().toISOString()})`;
return schedySchedule((settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''), settings.schedyApiKey || '', {
runAtEpoch,
method: 'POST',
url: relUrl,
body,
headers: {
'Content-Type': 'text/plain',
'X-Email': backup,
},
});
}
export interface ScheduleResult { jobId: string; scheduledRunAtUtc: number } export interface ScheduleResult { jobId: string; scheduledRunAtUtc: number }
// Resolve an absolute callback URL for schedulers that execute webhooks server-side // Resolve an absolute callback URL for schedulers that execute webhooks server-side