diff --git a/.gitignore b/.gitignore
index a76ecf9..7fe360e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
+dev-dist
*.local
.tevm
diff --git a/apps/lite/index.html b/apps/lite/index.html
index 780a124..fa51317 100644
--- a/apps/lite/index.html
+++ b/apps/lite/index.html
@@ -5,6 +5,8 @@
+
+
%VITE_APP_TITLE%
diff --git a/apps/lite/package.json b/apps/lite/package.json
index 8f7939c..6bb1fce 100644
--- a/apps/lite/package.json
+++ b/apps/lite/package.json
@@ -33,6 +33,7 @@
"lucide-react": "^0.475.0",
"ohash": "^1.1.4",
"react": "^19.0.0",
+ "react-device-detect": "^2.2.3",
"react-dom": "^19.0.0",
"react-router": "^7.4.0",
"recharts": "^2.15.1",
@@ -51,6 +52,7 @@
"terser": "^5.39.0",
"typescript": "~5.7.2",
"vite": "^6.2.0",
+ "vite-plugin-pwa": "^0.21.1",
"vite-plugin-svgr": "^4.3.0"
}
}
diff --git a/apps/lite/src/App.tsx b/apps/lite/src/App.tsx
index 9e2a33c..e11ea9f 100644
--- a/apps/lite/src/App.tsx
+++ b/apps/lite/src/App.tsx
@@ -11,6 +11,7 @@ import { ReactNode } from "react";
import { Client as UrqlClient, Provider as UrqlProvider, fetchExchange } from "urql";
import { type Config, deserialize, serialize, WagmiProvider } from "wagmi";
+import { PWAProvider } from "@/hooks/use-pwa";
import { TERMS_OF_USE } from "@/lib/constants";
import { createConfig } from "@/lib/wagmi-config";
@@ -59,13 +60,15 @@ function App({ children, wagmiConfig = defaultWagmiConfig }: { children: ReactNo
}}
>
-
-
- {children}
-
-
-
-
+
+
+
+ {children}
+
+
+
+
+
diff --git a/apps/lite/src/app/dashboard/borrow-subpage.tsx b/apps/lite/src/app/dashboard/borrow-subpage.tsx
index e7c7798..6e04c9a 100644
--- a/apps/lite/src/app/dashboard/borrow-subpage.tsx
+++ b/apps/lite/src/app/dashboard/borrow-subpage.tsx
@@ -2,6 +2,7 @@ import { AccrualPosition } from "@morpho-org/blue-sdk";
import { restructure } from "@morpho-org/blue-sdk-viem";
import { metaMorphoFactoryAbi } from "@morpho-org/uikit/assets/abis/meta-morpho-factory";
import { morphoAbi } from "@morpho-org/uikit/assets/abis/morpho";
+import { oracleAbi } from "@morpho-org/uikit/assets/abis/oracle";
import useContractEvents from "@morpho-org/uikit/hooks/use-contract-events/use-contract-events";
import {
marketHasDeadDeposit,
@@ -10,9 +11,9 @@ import {
} from "@morpho-org/uikit/lens/read-vaults";
import { CORE_DEPLOYMENTS, getContractDeploymentInfo } from "@morpho-org/uikit/lib/deployments";
import { Token } from "@morpho-org/uikit/lib/utils";
-import { useMemo } from "react";
+import { useEffect, useMemo } from "react";
import { useOutletContext } from "react-router";
-import { type Address, erc20Abi, type Chain, zeroAddress, type Hex } from "viem";
+import { encodeFunctionData, type Address, erc20Abi, type Chain, zeroAddress, type Hex, multicall3Abi } from "viem";
import { useAccount, useReadContract, useReadContracts } from "wagmi";
import { BorrowPositionTable, BorrowTable } from "@/components/borrow-table";
@@ -21,9 +22,11 @@ import { useMarkets } from "@/hooks/use-markets";
import * as Merkl from "@/hooks/use-merkl-campaigns";
import { useMerklOpportunities } from "@/hooks/use-merkl-opportunities";
import { useTopNCurators } from "@/hooks/use-top-n-curators";
+import { useUserNotifications } from "@/hooks/use-user-notifications";
import { type DisplayableCurators, getDisplayableCurators } from "@/lib/curators";
import { CREATE_METAMORPHO_EVENT_OVERRIDES, getDeploylessMode, getShouldEnforceDeadDeposit } from "@/lib/overrides";
import { getTokenURI } from "@/lib/tokens";
+import type { HealthFactor } from "@/user-notifications/types";
const STALE_TIME = 5 * 60 * 1000;
@@ -37,6 +40,10 @@ export function BorrowSubPage() {
const { chain } = useOutletContext() as { chain?: Chain };
const chainId = chain?.id;
+ const { monitorHealthFactor } = useUserNotifications();
+
+ const DEFAULT_HEALTH_FACTOR_THRESHOLD = 3;
+
const shouldOverrideCreateMetaMorphoEvents = chainId !== undefined && chainId in CREATE_METAMORPHO_EVENT_OVERRIDES;
const shouldUseDeploylessReads = getDeploylessMode(chainId) === "deployless";
const shouldEnforceDeadDeposit = getShouldEnforceDeadDeposit(chainId);
@@ -234,6 +241,76 @@ export function BorrowSubPage() {
return map;
}, [marketsArr, erc20Symbols, erc20Decimals, chainId]);
+ // Enqueue borrows for monitoring when positions change
+ useEffect(() => {
+ if (positions && userAddress && chainId && morpho && marketsArr.length > 0) {
+ const healthFactorJobs: HealthFactor[] = [];
+
+ positions.forEach((position, marketId) => {
+ const accruedPosition = position.accrueInterest(BigInt(Math.floor(Date.now() / 1000)));
+ if (accruedPosition.borrowAssets > 0n) {
+ const multicall3Address = chain?.contracts?.multicall3?.address;
+ if (!multicall3Address) return;
+
+ // Find the market to get oracle address
+ const market = marketsArr.find((m) => m.id === marketId);
+ if (!market) return;
+
+ // Encode all required calls for health factor calculation
+ const positionCalldata = encodeFunctionData({
+ abi: morphoAbi,
+ functionName: "position",
+ args: [marketId, userAddress],
+ });
+
+ const marketParamsCalldata = encodeFunctionData({
+ abi: morphoAbi,
+ functionName: "idToMarketParams",
+ args: [marketId],
+ });
+
+ const marketCalldata = encodeFunctionData({
+ abi: morphoAbi,
+ functionName: "market",
+ args: [marketId],
+ });
+
+ const oraclePriceCalldata = encodeFunctionData({
+ abi: oracleAbi,
+ functionName: "price",
+ args: [],
+ });
+
+ // Encode multicall aggregate3 with all calls (viem uses aggregate3 function)
+ const multicallData = encodeFunctionData({
+ abi: multicall3Abi,
+ functionName: "aggregate3",
+ args: [
+ [
+ { target: morpho.address, callData: positionCalldata, allowFailure: false },
+ { target: morpho.address, callData: marketParamsCalldata, allowFailure: false },
+ { target: morpho.address, callData: marketCalldata, allowFailure: false },
+ { target: market.params.oracle, callData: oraclePriceCalldata, allowFailure: false },
+ ],
+ ],
+ });
+
+ healthFactorJobs.push({
+ chainId: chainId,
+ userAddress: userAddress,
+ to: multicall3Address,
+ data: multicallData,
+ threshold: DEFAULT_HEALTH_FACTOR_THRESHOLD,
+ });
+ }
+ });
+
+ if (healthFactorJobs.length > 0) {
+ void monitorHealthFactor(healthFactorJobs);
+ }
+ }
+ }, [positions, userAddress, chainId, monitorHealthFactor, morpho, marketsArr, chain?.contracts?.multicall3?.address]);
+
if (status === "reconnecting") return undefined;
const userMarkets = marketsArr.filter((market) => positions?.get(market.id)?.collateral ?? 0n > 0n);
diff --git a/apps/lite/src/components/header.tsx b/apps/lite/src/components/header.tsx
index a846f54..a167d85 100644
--- a/apps/lite/src/components/header.tsx
+++ b/apps/lite/src/components/header.tsx
@@ -2,6 +2,7 @@ import { useKeyedState } from "@morpho-org/uikit/hooks/use-keyed-state";
import { cn } from "@morpho-org/uikit/lib/utils";
import { XIcon } from "lucide-react";
+import { InstallBanner } from "@/components/install-banner";
import { BANNERS } from "@/lib/constants";
function Banner(chainId: number | undefined) {
@@ -34,6 +35,7 @@ export function Header({ className, children, chainId, ...props }: React.Compone
<>
{placeholder}