From 6c4a8dfe8355bff16e6e7b256333ca6cc9e3e720 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Tue, 26 Aug 2025 16:15:20 +0400 Subject: [PATCH] created a new branch with alert functionality and added the compose files etc .. --- .gitmodules | 2 +- app/dapp/page.tsx | 339 ++++++++++++++++++++++++++++++++++- components/ConnectButton.tsx | 19 +- components/Navbar.tsx | 1 + components/SettingsModal.tsx | 277 ++++++++++++++++++++++++++++ config/web3.ts | 30 +--- docker-compose.yml | 81 +++++++++ providers/Web3Provider.tsx | 27 +-- submodules/schedy | 2 +- types/notifications.ts | 42 +++++ utils/cronhost.ts | 38 ++++ utils/scheduler.ts | 138 ++++++++++++++ utils/useLocalStorage.ts | 42 +++++ 13 files changed, 980 insertions(+), 58 deletions(-) create mode 100644 components/SettingsModal.tsx create mode 100644 docker-compose.yml create mode 100644 types/notifications.ts create mode 100644 utils/cronhost.ts create mode 100644 utils/scheduler.ts create mode 100644 utils/useLocalStorage.ts diff --git a/.gitmodules b/.gitmodules index cdc2f82..073aa47 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "submodules/schedy"] path = submodules/schedy - url = https://github.com/ksamirdev/schedy + url = ssh://git@git.manko.yoga:222/manawenuz/schedy.git diff --git a/app/dapp/page.tsx b/app/dapp/page.tsx index cfeccc2..176d87e 100644 --- a/app/dapp/page.tsx +++ b/app/dapp/page.tsx @@ -6,6 +6,11 @@ import { base, arbitrum } from 'wagmi/chains'; import { Abi, parseUnits } from 'viem'; import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import SettingsModal from '@/components/SettingsModal'; +import { useLocalStorage } from '@/utils/useLocalStorage'; +import type { NotificationSettings, PositionNotification } from '@/types/notifications'; +import { scheduleJob, cancelScheduledJob, purgeNtfyTopicSchedules } from '@/utils/scheduler'; // Minimal ERC-721 ABI for balance/owner/enumeration const erc721Abi = [ @@ -42,6 +47,7 @@ export default function DappPage() { const { address, isConnected } = useAccount(); const chainId = useChainId(); const { switchChain } = useSwitchChain(); + const searchParams = useSearchParams(); const [selectedChainId, setSelectedChainId] = useState(base.id); const [nftAddress, setNftAddress] = useState(DEFAULTS[base.id].nft); @@ -57,6 +63,77 @@ export default function DappPage() { const { writeContractAsync, isPending: writePending } = useWriteContract(); const { sendTransactionAsync, isPending: txPending } = useSendTransaction(); + // Notification settings (global) and per-position preferences + const [settingsOpen, setSettingsOpen] = useState(false); + + useEffect(() => { + if (searchParams?.get('settings') === '1') setSettingsOpen(true); + }, [searchParams]); + const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, ''); + const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, ''); + const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim(); + const [notifSettings, setNotifSettings] = useLocalStorage('notif:settings', { + provider: '', + scheduler: 'schedy', + ntfyServer: ENV_NTFY, + ntfyTopic: '', + gotifyServer: '', + gotifyToken: '', + snsRegion: '', + snsTopicArn: '', + snsAccessKeyId: '', + snsSecretAccessKey: '', + schedyBaseUrl: ENV_SCHEDY, + schedyApiKey: ENV_SCHEDY_API_KEY, + email: '', + daysBefore: 10, + }); + useEffect(() => { + console.log('[Settings] Current', notifSettings); + }, [notifSettings]); + + // One-time migration: minutesBefore -> daysBefore + useEffect(() => { + const anySettings: any = notifSettings as any; + if (anySettings && anySettings.minutesBefore !== undefined && anySettings.daysBefore === undefined) { + const mins = Number(anySettings.minutesBefore) || 0; + const days = Math.max(0, Math.round(mins / 1440)); + const next = { ...notifSettings, daysBefore: days } as NotificationSettings; + delete (next as any).minutesBefore; + console.log('[Settings] Migrating minutesBefore -> daysBefore', { mins, days }); + setNotifSettings(next); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // One-time migration: legacy scheduler providers -> schedy; remove legacy fields + useEffect(() => { + const s: any = notifSettings as any; + if (!s) return; + let changed = false; + const next: any = { ...notifSettings }; + // Normalize scheduler + if (!s.scheduler || s.scheduler !== 'schedy') { + next.scheduler = 'schedy'; + changed = true; + } + // Remove legacy keys if present + const legacyKeys = ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']; + for (const k of legacyKeys) { + if (k in next) { + delete next[k]; + changed = true; + } + } + if (changed) { + console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields'); + setNotifSettings(next as NotificationSettings); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const positionsStoreKey = useMemo(() => `notif:positions:v1:${selectedChainId}:${debtAddress?.toLowerCase()}`, [selectedChainId, debtAddress]); + const [positionsNotif, setPositionsNotif] = useLocalStorage>(positionsStoreKey, {}); + // Cache helpers type WalletCache = { lastScannedIndex: number; @@ -544,6 +621,135 @@ export default function DappPage() { } }; + // Helpers to construct provider webhook URL/body for cronhost + const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string) => { + const provider = notifSettings.provider; + if (!provider) throw new Error('Notification provider not configured'); + if (provider === 'ntfy') { + const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, ''); + const topic = notifSettings.ntfyTopic || ''; + const headers: Record = { 'Content-Type': 'text/plain' }; + if ((notifSettings.email || '').trim()) headers['X-Email'] = String(notifSettings.email); + return { + url: `${baseUrl}/${encodeURIComponent(topic)}`, + method: 'POST' as const, + body: message, + headers, + }; + } + if (provider === 'gotify') { + const baseUrl = (notifSettings.gotifyServer || '').replace(/\/$/, ''); + const token = notifSettings.gotifyToken || ''; + return { + url: `${baseUrl}/message?token=${encodeURIComponent(token)}`, + method: 'POST' as const, + body: { title: 'MortgageFi Alert', message, priority: 5 }, + headers: { 'Content-Type': 'application/json' }, + }; + } + if (provider === 'sns') { + // Note: Direct SNS publish requires AWS SigV4; recommend server-side relay. Here we simply target a placeholder you control. + // User should set up a relay endpoint that publishes to SNS. + const relay = '/api/sns-relay'; + return { + url: relay, + method: 'POST' as const, + body: { topicArn: notifSettings.snsTopicArn, region: notifSettings.snsRegion, message, email: notifSettings.email }, + headers: { 'Content-Type': 'application/json' }, + }; + } + throw new Error('Unsupported provider'); + }; + + // Single schedule at configured lead time (or 1 day by default) + const scheduleNotification = async (row: { tokenId: bigint; secondsTillLiq?: bigint }) => { + const seconds = Number(row.secondsTillLiq ?? 0); + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Invalid liquidation timer'); + const nowSec = Math.floor(Date.now() / 1000); + const leadSecsCfg = Math.max(0, Math.floor(Number(notifSettings.daysBefore ?? 0) * 86400)); + const offset = leadSecsCfg > 0 ? leadSecsCfg : 86400; // default to 1 day + + // Helper: humanize a duration in seconds + const human = (rem: number) => { + if (rem <= 0) return '0m'; + const days = Math.floor(rem / 86400); + const hours = Math.floor((rem % 86400) / 3600); + const mins = Math.floor((rem % 3600) / 60); + if (days >= 2) return `${days}d ${hours}h`; + if (days === 1) return `1d ${hours}h`; + if (hours >= 1) return `${hours}h ${mins}m`; + return `${mins}m`; + }; + + // Amount suggestion: prefer total debt remaining; fallback to 1.5x monthly payment; else current payment pending + const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined; + const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined; + const debtRemaining = (row as any)?.debtAtThisSize as bigint | undefined; + const dappUrl = (typeof window !== 'undefined' && window.location?.origin) ? `${window.location.origin}/dapp` : 'https://markets.mortgagefi.app/dashboard'; + const humanLeft = human(offset); + + const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2); + const formatToken = (v: bigint | undefined) => { + if (v === undefined) return null; + const dec = Number(stableDecimals ?? 6); + const factor = BigInt(10) ** BigInt(dec); + const int = v / factor; + const frac = (v % factor).toString().padStart(dec, '0').slice(0, 6); + return `${int.toString()}.${frac}`; + }; + + const payAmt15 = monthly !== undefined ? mul15(monthly) : undefined; + const payAmtStr = formatToken(debtRemaining ?? payAmt15 ?? paymentPending ?? BigInt(0)); + const payClause = payAmtStr ? `Pay at least ${payAmtStr} USDC to avoid liquidation.` : `Make a payment to avoid liquidation.`; + // Collateral at risk: use same formatting as UI Loan Collateral + const collateralStr = fmt((row as any)?.coinSize as bigint | undefined, Number(coinDecimals ?? 8), 8); + const collateralSym = String(coinSymbol ?? ''); + + const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. 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 cancelNotification = async (p: PositionNotification) => { + // Cancel new multi-jobs first + if (p.jobs && p.jobs.length) { + for (const j of p.jobs) { + try { await cancelScheduledJob(notifSettings, j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); } + } + } + // Back-compat: single jobId + if (p.jobId) { + try { await cancelScheduledJob(notifSettings, p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); } + } + }; + + // Scheduled notifications helpers + const scheduledList = useMemo(() => { + const entries = Object.entries(positionsNotif || {}); + return entries + .filter(([, v]) => v && v.enabled && ((v.jobs && v.jobs.length) || v.jobId || v.scheduledAt)) + .map(([k, v]) => { + const earliest = v.jobs && v.jobs.length ? Math.min(...v.jobs.map(j => j.at)) : (v.scheduledAt || 0); + return { key: k, ...v, scheduledAt: earliest }; + }) + .sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0)); + }, [positionsNotif]); + + const deleteSchedule = async (key: string) => { + const p = positionsNotif[key]; + if (!p) return; + try { await cancelNotification(p); } catch {} + setPositionsNotif((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + const parsed = useMemo(() => { if (!debtResults || !tokenIds.length) return [] as Array<{ tokenId: bigint; @@ -583,6 +789,33 @@ export default function DappPage() { return out; }, [debtResults, tokenIds]); + // Auto-reschedule when secondsTillLiq changes for enabled positions + useEffect(() => { + (async () => { + for (const row of parsed) { + const key = row.tokenId.toString(); + const p = positionsNotif[key]; + if (!p || !p.enabled) continue; + const seconds = Number(row.secondsTillLiq ?? 0); + if (!Number.isFinite(seconds) || seconds <= 0) continue; + const lead = Math.max(0, Number(notifSettings.daysBefore ?? 0) * 86400); + const targetRunAt = Math.floor(Date.now() / 1000) + Math.max(0, seconds - (lead > 0 ? lead : 86400)); + const drift = Math.abs((p.scheduledAt || 0) - targetRunAt); + if (!p.jobId || drift > 60) { + // reschedule single + 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 } })); + } catch (e) { + console.warn('[notif] reschedule failed', e); + } + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parsed.map(r => String(r.tokenId)+':'+String(r.secondsTillLiq)).join(','), notifSettings.daysBefore, positionsStoreKey]); + // Map tokenId -> max payable (Amount to reset timer) const payMaxByTokenId = useMemo(() => { const m: Record = {}; @@ -752,6 +985,50 @@ export default function DappPage() {

Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.

Base network is required. The app will attempt to detect your token IDs via on-chain queries.

+ +
+
+
Scheduled notifications
+ +
+ {scheduledList.length === 0 ? ( +
No schedules yet.
+ ) : ( +
    + {scheduledList.map((it) => ( +
  • +
    +
    Token #{it.tokenId}
    +
    + {it.scheduledAt ? `Runs at ${new Date(it.scheduledAt * 1000).toLocaleString()}` : 'Scheduled'} + {it.jobId ? ` · Job ${it.jobId}` : ''} +
    +
    + +
  • + ))} +
+ )} +
@@ -860,7 +1137,55 @@ export default function DappPage() {
{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}
Seconds Till Liquidation
-
{fmtDuration(row.secondsTillLiq)}
+
+ {fmtDuration(row.secondsTillLiq)} + {!isClosed && ( + (() => { + const key = row.tokenId.toString(); + const p = positionsNotif[key]; + const checked = Boolean(p?.enabled); + const disabled = row.secondsTillLiq === undefined || Number(row.secondsTillLiq) <= 0; + const onToggle = async (next: boolean) => { + // Ensure settings + const haveProvider = (() => { + if (!notifSettings.provider) return false; + if (notifSettings.provider === 'ntfy') return Boolean(notifSettings.ntfyServer && notifSettings.ntfyTopic); + if (notifSettings.provider === 'gotify') return Boolean(notifSettings.gotifyServer && notifSettings.gotifyToken); + if (notifSettings.provider === 'sns') return Boolean(notifSettings.snsRegion && notifSettings.snsTopicArn && notifSettings.snsAccessKeyId && notifSettings.snsSecretAccessKey); + return false; + })(); + const haveScheduler = (() => { + const s = notifSettings.scheduler || 'schedy'; + if (s !== 'schedy') return false; + return Boolean(notifSettings.schedyBaseUrl && notifSettings.schedyApiKey); + })(); + const haveSettings = Boolean(haveProvider && haveScheduler && notifSettings.email); + if (next && !haveSettings) { setSettingsOpen(true); return; } + if (!next) { + // disable and cancel + if (p) await cancelNotification(p); + setPositionsNotif((prev) => ({ ...prev, [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: false } })); + return; + } + try { + const { jobId, runAt } = await scheduleNotification(row); + setPositionsNotif((prev) => ({ + ...prev, + [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt }, + })); + } catch (e) { + console.warn('[notif] schedule failed', e); + } + }; + return ( + + ); + })() + )} +
{/* Pay controls (hidden for Closed/Defaulted loans) */} @@ -921,6 +1246,18 @@ export default function DappPage() {
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.
+ + {/* Settings Modal */} + setSettingsOpen(false)} + onSave={(s) => { + console.log('[Settings] Save requested', s); + setNotifSettings(s); + setSettingsOpen(false); + }} + /> ); } diff --git a/components/ConnectButton.tsx b/components/ConnectButton.tsx index b41686a..43e8d72 100644 --- a/components/ConnectButton.tsx +++ b/components/ConnectButton.tsx @@ -1,16 +1,18 @@ 'use client'; -import { useWeb3Modal } from '@web3modal/wagmi/react'; -import { useAccount, useDisconnect } from 'wagmi'; +import { useAccount } from 'wagmi'; import { formatAddress } from '@/utils/format'; export function ConnectButton() { - const { open } = useWeb3Modal(); const { address, isConnected } = useAccount(); - const { disconnect } = useDisconnect(); const handleConnect = () => { - open(); + try { + // Minimal connect without WalletConnect modal + if (typeof window !== 'undefined' && (window as any).ethereum?.request) { + (window as any).ethereum.request({ method: 'eth_requestAccounts' }); + } + } catch {} }; if (isConnected && address) { @@ -19,12 +21,7 @@ export function ConnectButton() { {formatAddress(address)} - + {/* Disconnect can be done from the wallet UI; no WalletConnect here */} ); } diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 43db104..78df949 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -7,6 +7,7 @@ import { ConnectButton } from '@/components/ConnectButton'; const navigation = [ { name: 'DApp', href: '/dapp', current: false }, + { name: 'Settings', href: '/dapp?settings=1', current: false }, { name: 'README', href: 'https://git.manko.yoga/manawenuz/mortgagefi-helper', current: false, external: true }, ]; diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx new file mode 100644 index 0000000..a16feaa --- /dev/null +++ b/components/SettingsModal.tsx @@ -0,0 +1,277 @@ +"use client"; +import { useEffect, useMemo, useState } from "react"; +import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications"; +import { scheduleTestNotification } from "@/utils/scheduler"; + +export interface SettingsModalProps { + open: boolean; + initial?: NotificationSettings; + onClose: () => void; + onSave: (settings: NotificationSettings) => void; +} + +const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, ''); +const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, ''); +const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim(); + +const defaultSettings: NotificationSettings = { + provider: "", + scheduler: 'schedy', + ntfyServer: ENV_NTFY, + ntfyTopic: "", + gotifyServer: "", + gotifyToken: "", + snsRegion: "", + snsTopicArn: "", + snsAccessKeyId: "", + snsSecretAccessKey: "", + schedyBaseUrl: ENV_SCHEDY, + schedyApiKey: ENV_SCHEDY_API_KEY, + email: "", + daysBefore: 10, +}; + +export default function SettingsModal({ open, initial, onClose, onSave }: SettingsModalProps) { + const [form, setForm] = useState(initial || defaultSettings); + const [testStatus, setTestStatus] = useState(""); + + useEffect(() => { + if (open) { + const base: any = initial || defaultSettings; + const next: any = { ...base }; + if (next.scheduler !== 'schedy') next.scheduler = 'schedy'; + for (const k of ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']) { + if (k in next) delete next[k]; + } + setForm(next); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initial]); + + const provider: NotificationProvider | '' = form.provider || ''; + const scheduler: SchedulerProvider | '' = form.scheduler || ''; + + const saveDisabled = useMemo(() => { + if (!form.email) return true; + const db = form.daysBefore ?? 10; + if (Number(db) < 0) return true; + if (!provider) return true; + if (!scheduler) return true; + if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic); + if (provider === 'gotify') return !(form.gotifyServer && form.gotifyToken); + if (provider === 'sns') return !(form.snsRegion && form.snsTopicArn && form.snsAccessKeyId && form.snsSecretAccessKey); + if (scheduler === 'schedy') return !(form.schedyBaseUrl && form.schedyApiKey); + return true; + }, [form, provider, scheduler]); + + const canTest = useMemo(() => { + // Basic same checks as save + provider must be ntfy + if (saveDisabled) return false; + return provider === 'ntfy' && scheduler === 'schedy'; + }, [saveDisabled, provider, scheduler]); + + const onTest = async () => { + setTestStatus('Scheduling test…'); + try { + const { jobId } = await scheduleTestNotification(form); + setTestStatus(`Test scheduled (job ${jobId}). You should receive a notification shortly.`); + } catch (e: any) { + setTestStatus(`Test failed: ${e?.message || String(e)}`); + } + }; + + if (!open) return null; + + return ( +
+
+
+
Notification Settings
+

Configure your notification provider and scheduler credentials.

+
+ + + + + {scheduler === 'schedy' && ( + <> + + + + )} + + + +
+ + {provider === 'ntfy' && ( + <> + + + + )} + + {provider === 'gotify' && ( + <> + + + + )} + + {provider === 'sns' && ( + <> + + + + +
+ Storing AWS credentials in the browser is insecure. Prefer a server-side relay. +
+ + )} +
+ +
+ + + +
+ {testStatus && ( +
{testStatus}
+ )} +
+
+ ); +} diff --git a/config/web3.ts b/config/web3.ts index f8f57c7..fcf739e 100644 --- a/config/web3.ts +++ b/config/web3.ts @@ -1,15 +1,7 @@ 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 +// Create wagmiConfig const metadata = { name: 'MortgageFi', description: 'Decentralized Mortgage Lending Platform', @@ -17,26 +9,18 @@ const metadata = { icons: ['https://mortgagefi.app/logo.png'] }; +// Prefer custom RPCs to avoid public-provider rate limits (429) +const baseRpc = process.env.NEXT_PUBLIC_RPC_BASE; +const arbitrumRpc = process.env.NEXT_PUBLIC_RPC_ARBITRUM; + export const config = createConfig({ chains: [base, arbitrum, mainnet, sepolia], transports: { - [base.id]: http(), - [arbitrum.id]: http(), + [base.id]: baseRpc ? http(baseRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(undefined, { batch: true, retryCount: 2, retryDelay: 250 }), + [arbitrum.id]: arbitrumRpc ? http(arbitrumRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(undefined, { batch: true, retryCount: 2, retryDelay: 250 }), [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', - }, -}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1124b51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +services: + ntfy: + image: binwiederhier/ntfy + container_name: ntfy + platform: linux/amd64 + command: ["serve"] + env_file: .env.local + environment: + - TZ=Europe/Zurich + # ntfy email via SMTP (Gmail App Password or your SMTP) + - NTFY_BASE_URL=${NTFY_BASE_URL:-https://web.windsurf-project.orb.local} + - NTFY_SMTP_SENDER_ADDR=${NTFY_SMTP_SENDER_ADDR:-smtp.gmail.com:587} + - NTFY_SMTP_SENDER_USER=${NTFY_SMTP_SENDER_USER:-your.name@gmail.com} + - NTFY_SMTP_SENDER_PASS=${NTFY_SMTP_SENDER_PASS:-app-password-xxxx} + - NTFY_SMTP_SENDER_FROM=${NTFY_SMTP_SENDER_FROM:-your.name@gmail.com} + - NTFY_LOG_LEVEL=${NTFY_LOG_LEVEL:-info} + user: "4242:4242" + volumes: + - ../data/ntfy/cache:/var/cache/ntfy + - ../data/ntfy/etc/ntfy:/etc/ntfy + ports: + - "8081:80" + restart: unless-stopped + + schedy: + build: ./submodules/schedy + container_name: schedy + environment: + - SCHEDY_API_KEY=${SCHEDY_API_KEY} + - TZ=UTC + ports: + - "8080:8080" + restart: unless-stopped + + frontend: + image: node:20-alpine + platform: linux/amd64 + container_name: mortgagefi-frontend + working_dir: /app + env_file: .env.local + environment: + - HOST=0.0.0.0 + - PORT=3000 + - NEXT_PUBLIC_NTFY_URL=${NEXT_PUBLIC_NTFY_URL:-/ntfy} + - NEXT_PUBLIC_SCHEDY_URL=${NEXT_PUBLIC_SCHEDY_URL:-/schedy} + volumes: + - ./:/app + - /app/node_modules + command: >- + sh -c "apk add --no-cache python3 make g++ git libc6-compat + && export PYTHON=/usr/bin/python3 + && npm ci --legacy-peer-deps --no-audit --no-fund + && mkdir -p .next/static/development + && npm run dev -- --hostname 0.0.0.0 --port 3000" + ports: + - "3000:3000" + restart: unless-stopped + depends_on: + - ntfy + - schedy + + web: + image: nginx:alpine + platform: linux/amd64 + container_name: mortgagefi-proxy + ports: + - "80:80" + volumes: + - ../nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - frontend + - ntfy + - schedy + +# Usage: +# 1) Optionally, set in .env.local: +# NEXT_PUBLIC_NTFY_URL=/ntfy +# NEXT_PUBLIC_SCHEDY_URL=/schedy +# NEXT_PUBLIC_SCHEDY_API_KEY=your-schedy-secret +# 2) docker compose up -d +# 3) App: http://localhost ; ntfy (proxied): http://localhost/ntfy ; schedy (proxied): http://localhost/schedy diff --git a/providers/Web3Provider.tsx b/providers/Web3Provider.tsx index a12e922..b2ee7ec 100644 --- a/providers/Web3Provider.tsx +++ b/providers/Web3Provider.tsx @@ -1,17 +1,10 @@ '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', @@ -19,29 +12,21 @@ const metadata = { icons: ['https://mortgagefi.app/logo.png'] }; +// Prefer custom RPCs to avoid public-provider rate limits (429) +const baseRpc = process.env.NEXT_PUBLIC_RPC_BASE; +const arbitrumRpc = process.env.NEXT_PUBLIC_RPC_ARBITRUM; + const config = createConfig({ chains: [base, arbitrum, mainnet, sepolia], transports: { - [base.id]: http(), - [arbitrum.id]: http(), + [base.id]: baseRpc ? http(baseRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(undefined, { batch: true, retryCount: 2, retryDelay: 250 }), + [arbitrum.id]: arbitrumRpc ? http(arbitrumRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(undefined, { batch: true, retryCount: 2, retryDelay: 250 }), [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) { diff --git a/submodules/schedy b/submodules/schedy index 1001015..595a923 160000 --- a/submodules/schedy +++ b/submodules/schedy @@ -1 +1 @@ -Subproject commit 1001015f048863c6e6a7f86436035f7453362f03 +Subproject commit 595a9232ccfcf80ca8ad0d552f8fd222ca1b649f diff --git a/types/notifications.ts b/types/notifications.ts new file mode 100644 index 0000000..8751be3 --- /dev/null +++ b/types/notifications.ts @@ -0,0 +1,42 @@ +export type NotificationProvider = 'ntfy' | 'gotify' | 'sns'; +export type SchedulerProvider = 'schedy'; + +export interface NotificationSettings { + provider: NotificationProvider | ''; + scheduler?: SchedulerProvider | ''; + // Ntfy + ntfyServer?: string; // e.g., https://ntfy.sh or self-hosted + ntfyTopic?: string; + // Gotify + gotifyServer?: string; // base URL + gotifyToken?: string; + // Amazon SNS + snsRegion?: string; + snsTopicArn?: string; + snsAccessKeyId?: string; + snsSecretAccessKey?: string; + // Schedy + schedyBaseUrl?: string; // e.g., http://localhost:8080 + schedyApiKey?: string; + // Contact details + email?: string; + // Lead time before liquidation in days + daysBefore?: number; // default 0 +} + +export interface PositionNotification { + chainId: number; + debtAddress: string; + nftAddress: string; + tokenId: string; // decimal string + // Legacy single-job fields (backward compat with already-stored values) + jobId?: string; // scheduler job id (from schedy) + scheduledAt?: number; // epoch seconds + // New multi-job support + jobs?: Array<{ + id: string; + at: number; // epoch seconds + label: 'lead' | 'half' | 'last'; + }>; + enabled: boolean; +} diff --git a/utils/cronhost.ts b/utils/cronhost.ts new file mode 100644 index 0000000..3dbf5b3 --- /dev/null +++ b/utils/cronhost.ts @@ -0,0 +1,38 @@ +// NOTE: Real cronhost API integration pending official REST docs. +// These stubs simulate scheduling/canceling and should be replaced with actual fetch calls. + +export interface ScheduleParams { + apiKey: string; + runAt: number; // epoch seconds + method: 'POST' | 'GET'; + url: string; + body?: any; + headers?: Record; +} + +export interface ScheduleResult { + jobId: string; + scheduledRunAtUtc: number; +} + +export async function scheduleOneTimeJob(params: ScheduleParams): Promise { + const { apiKey, runAt, method, url } = params; + if (!apiKey) throw new Error('Missing cronhost API key'); + if (!url) throw new Error('Missing target URL'); + if (!runAt || runAt < Math.floor(Date.now() / 1000)) throw new Error('runAt must be in the future'); + + console.warn('[cronhost] scheduleOneTimeJob is a stub. Replace with real API call.'); + // Example real call outline: + // await fetch('https://api.cronho.st/schedules', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({...}) }) + + return { + jobId: `stub_${Math.random().toString(36).slice(2)}`, + scheduledRunAtUtc: runAt, + }; +} + +export async function cancelJob(apiKey: string, jobId: string): Promise { + if (!apiKey || !jobId) return; + console.warn('[cronhost] cancelJob is a stub. Replace with real API call.', { jobId }); + // Example: await fetch(`https://api.cronho.st/jobs/${jobId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` } }) +} diff --git a/utils/scheduler.ts b/utils/scheduler.ts new file mode 100644 index 0000000..c671b2e --- /dev/null +++ b/utils/scheduler.ts @@ -0,0 +1,138 @@ +import { NotificationSettings } from '@/types/notifications'; + +export interface ScheduleArgs { + runAtEpoch: number; // seconds + method: 'POST' | 'GET'; + url: string; + body?: any; + headers?: Record; +} + +export interface ScheduleResult { jobId: string; scheduledRunAtUtc: number } + +// Resolve an absolute callback URL for schedulers that execute webhooks server-side +function toAbsoluteUrl(url: string): string { + // Already absolute + if (/^https?:\/\//i.test(url)) return url; + // If relative, prefer an explicit callback origin if provided + const explicit = (process.env.NEXT_PUBLIC_CALLBACK_ORIGIN || '').replace(/\/$/, ''); + if (explicit) return `${explicit}${url.startsWith('/') ? '' : '/'}${url}`; + // Fallback to browser origin (client-side only). If unavailable, return as-is. + try { + if (typeof window !== 'undefined' && window.location?.origin) { + return `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`; + } + } catch {} + return url; +} + +// Schedy implementation +async function schedySchedule(baseUrl: string, apiKey: string, args: ScheduleArgs): Promise { + const executeAt = new Date(args.runAtEpoch * 1000).toISOString(); // RFC3339 UTC + const targetUrl = toAbsoluteUrl(args.url); + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ + execute_at: executeAt, + url: targetUrl, + headers: args.headers || {}, + payload: args.body ?? undefined, + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Schedy schedule failed: ${res.status} ${text}`); + } + const json = await res.json().catch(() => ({} as any)); + const id = json?.id || json?.task_id || `schedy_${Math.random().toString(36).slice(2)}`; + return { jobId: String(id), scheduledRunAtUtc: args.runAtEpoch }; +} + +async function schedyCancel(baseUrl: string, apiKey: string, jobId: string): Promise { + await fetch(`${baseUrl.replace(/\/$/, '')}/tasks/${encodeURIComponent(jobId)}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey } }); +} + +// --- Schedy management helpers --- +interface SchedyTask { id: string; url: string; execute_at?: string; headers?: Record; payload?: any } + +async function schedyList(baseUrl: string, apiKey: string): Promise { + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/tasks`, { headers: { 'X-API-Key': apiKey } }); + if (!res.ok) throw new Error(`Schedy list failed: ${res.status}`); + const json = await res.json().catch(() => []); + return Array.isArray(json) ? json as SchedyTask[] : []; +} + +async function schedyDelete(baseUrl: string, apiKey: string, id: string): Promise { + await fetch(`${baseUrl.replace(/\/$/, '')}/tasks/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey } }); +} + +// Compute absolute ntfy topic URL for comparison (matches what schedule uses) +function absoluteNtfyUrl(settings: NotificationSettings): string | null { + const topic = (settings.ntfyTopic || '').trim(); + if (!topic) return null; + const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, ''); + const rel = `${base}/${encodeURIComponent(topic)}`; + return toAbsoluteUrl(rel); +} + +export async function purgeNtfyTopicSchedules(settings: NotificationSettings): Promise { + const scheduler = settings.scheduler || 'cronhost'; + if (scheduler !== 'schedy') return 0; // only implemented for schedy + const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''); + const key = settings.schedyApiKey || ''; + if (!base || !key) throw new Error('Schedy base URL or API key missing'); + const targetUrl = absoluteNtfyUrl(settings); + if (!targetUrl) return 0; + const tasks = await schedyList(base, key); + const victims = tasks.filter(t => t.url === targetUrl); + await Promise.allSettled(victims.map(v => schedyDelete(base, key, v.id))); + return victims.length; +} + +const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, ''); + +export async function scheduleJob(settings: NotificationSettings, args: ScheduleArgs): Promise { + const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''); + const key = settings.schedyApiKey || ''; + if (!base || !key) throw new Error('Schedy base URL or API key missing'); + return schedySchedule(base, key, args); +} + +export async function cancelScheduledJob(settings: NotificationSettings, jobId: string): Promise { + const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''); + const key = settings.schedyApiKey || ''; + if (!base || !key || !jobId) return; + return schedyCancel(base, key, jobId); +} + +// Schedule a quick test notification to validate configuration end-to-end. +export async function scheduleTestNotification(settings: NotificationSettings): Promise { + const scheduler = settings.scheduler || 'schedy'; + if (scheduler !== 'schedy') throw new Error('Only Schedy is supported for tests'); + if (!settings.email) throw new Error('Please set an email (used to validate requirements)'); + + // Currently support ntfy provider for E2E test + if (settings.provider !== 'ntfy') { + throw new Error('Test alert 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)}`; // can be relative; toAbsoluteUrl will fix + const now = Math.floor(Date.now() / 1000); + const runAtEpoch = now + 120; // 2 minutes from now + const body = `MortgageFi test alert at ${new Date().toISOString()}`; + return schedySchedule((settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''), settings.schedyApiKey || '', { + runAtEpoch, + method: 'POST', + url: relUrl, + body, + headers: { + 'Content-Type': 'text/plain', + ...(settings.email ? { 'X-Email': settings.email } : {}), + }, + }); +} diff --git a/utils/useLocalStorage.ts b/utils/useLocalStorage.ts new file mode 100644 index 0000000..5acbab0 --- /dev/null +++ b/utils/useLocalStorage.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +export function useLocalStorage(key: string, initial: T) { + const [value, setValue] = useState(() => { + try { + if (typeof window !== 'undefined') { + const raw = localStorage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + console.log('[useLocalStorage] Init from storage', { key, value: parsed }); + return parsed as T; + } + } + } catch {} + return initial; + }); + + useEffect(() => { + try { + const raw = typeof window !== 'undefined' ? localStorage.getItem(key) : null; + if (raw) { + const parsed = JSON.parse(raw); + console.log('[useLocalStorage] Loaded', { key, value: parsed }); + setValue(parsed); + } else { + console.log('[useLocalStorage] No existing value, using initial', { key, initial }); + } + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + useEffect(() => { + try { + if (typeof window !== 'undefined') { + localStorage.setItem(key, JSON.stringify(value)); + console.log('[useLocalStorage] Saved', { key, value }); + } + } catch {} + }, [key, value]); + + return [value, setValue] as const; +}