Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local

.tevm
Expand Down
2 changes: 2 additions & 0 deletions apps/lite/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link href="/src/index.css" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Morpho Lite App" />
<title>%VITE_APP_TITLE%</title>
</head>
<body>
Expand Down
2 changes: 2 additions & 0 deletions apps/lite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
17 changes: 10 additions & 7 deletions apps/lite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -59,13 +60,15 @@ function App({ children, wagmiConfig = defaultWagmiConfig }: { children: ReactNo
}}
>
<UrqlProvider value={urqlClient}>
<AddressScreeningProvider>
<SafeLinksProvider>
{children}
<SafeLinkModal />
</SafeLinksProvider>
<AddressScreeningModal />
</AddressScreeningProvider>
<PWAProvider>
<AddressScreeningProvider>
<SafeLinksProvider>
{children}
<SafeLinkModal />
</SafeLinksProvider>
<AddressScreeningModal />
</AddressScreeningProvider>
</PWAProvider>
</UrqlProvider>
</ConnectKitProvider>
</PersistQueryClientProvider>
Expand Down
81 changes: 79 additions & 2 deletions apps/lite/src/app/dashboard/borrow-subpage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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;

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/lite/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -34,6 +35,7 @@ export function Header({ className, children, chainId, ...props }: React.Compone
<>
{placeholder}
<div className="pointer-events-none fixed top-0 z-50 flex h-screen w-screen flex-col">
<InstallBanner />
{banner}
<header className={cn("bg-primary pointer-events-auto h-16", className)} {...props}>
{children}
Expand Down
69 changes: 69 additions & 0 deletions apps/lite/src/components/install-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Button } from "@morpho-org/uikit/components/shadcn/button";
import { useKeyedState } from "@morpho-org/uikit/hooks/use-keyed-state";
import { cn } from "@morpho-org/uikit/lib/utils";
import { Download, XIcon } from "lucide-react";
import { isIOS } from "react-device-detect";

import { usePWA } from "@/hooks/use-pwa";

export function InstallBanner() {
const { isInstallable, install } = usePWA();
const [shouldShowBanner, setShouldShowBanner] = useKeyedState(true, "pwa-install-banner", { persist: true });

if (!isInstallable || !shouldShowBanner) {
return null;
}

const handleInstall = async () => {
if (isIOS) {
// On iOS, just close the banner - user needs to use Share menu
setShouldShowBanner(false);
return;
}

const installed = await install();
if (installed) {
setShouldShowBanner(false);
}
};

return (
<aside
className={cn(
"pointer-events-auto flex h-10 min-h-min items-center justify-between px-1 text-sm font-light italic",
"text-primary-foreground bg-[var(--morpho-banner)]",
)}
>
<div className="flex items-center gap-2 px-2">
<Download className="h-4 w-4" />
<span>
{isIOS ? (
<>
Install Morpho Lite for a better experience
<br />
Tap Share → Add to Home Screen
</>
) : (
"Install Morpho Lite for a better experience"
)}
</span>
</div>
<div className="flex items-center gap-2">
{!isIOS && (
<Button
variant="secondary"
size="sm"
className="rounded-sm px-2 py-1 text-xs font-medium"
onClick={handleInstall}
>
Install
</Button>
)}
<XIcon
className="hover:bg-accent mx-2 h-6 w-6 cursor-pointer rounded-sm p-1"
onClick={() => setShouldShowBanner(false)}
/>
</div>
</aside>
);
}
69 changes: 69 additions & 0 deletions apps/lite/src/hooks/use-pwa-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { isIOS } from "react-device-detect";

export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstallable, setIsInstallable] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);

useEffect(() => {
// Check if app is already installed
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}

// On iOS, show banner even without beforeinstallprompt event
if (isIOS) {
setIsInstallable(true);
return;
}

// Listen for beforeinstallprompt event (Android/Chrome desktop)
const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
setDeferredPrompt(e);
setIsInstallable(true);
};

window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);

// Check if app was just installed
window.addEventListener("appinstalled", () => {
setIsInstalled(true);
setIsInstallable(false);
setDeferredPrompt(null);
});

return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
};
}, []);

const install = async (): Promise<boolean> => {
if (!deferredPrompt) {
return false;
}

try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;

if (outcome === "accepted") {
setIsInstallable(false);
setDeferredPrompt(null);
return true;
}
return false;
} catch (error) {
console.error("Error installing PWA:", error);
return false;
}
};

return {
isInstallable,
isInstalled,
install,
};
}
Loading