Skip to content

Commit b946331

Browse files
sktbrdclaude
andcommitted
fix(stake): per-asset Morpheus APR (was a wrong blended rate)
The dialog showed one blended MOR APR (~17.6%) for both stETH and USDC, but Morpheus displays a distinct APR per deposit asset (USDC 24.5%, stETH 16.78%) — it splits the shared reward pool's emissions across pools by an internal virtual/power-adjusted weighting that can't be reproduced cleanly on-chain (verified: naive on-chain compute is off 1.5–2.3x), and Morpheus exposes no public API. So the /api/yields `mor` field is now per-asset (steth/usdc), synced from the Capital table on mor.org and labelled estimate. The dialog reads the rate for the chosen asset. Refresh the two numbers when they drift. tsc + next build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c98cf03 commit b946331

2 files changed

Lines changed: 25 additions & 77 deletions

File tree

src/components/stake/StakeDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ export function StakeDialog({ open, onOpenChange, riderId, name, image, accent }
107107
const isMor = opp.kind === "mor";
108108
const isUsdc = opp.asset === "usdc";
109109

110-
// APR per opportunity: the Morpho vault's live APY, or the blended Morpheus rate.
111-
const aprFor = (o: Opp) => (o.kind === "vault" ? yields?.usdc?.apy : yields?.mor?.apy) ?? 0;
110+
// APR per opportunity: the Morpho vault's live APY, or the per-asset Morpheus rate.
111+
const aprFor = (o: Opp) => (o.kind === "vault" ? yields?.usdc?.apy : yields?.mor?.[o.asset]?.apy) ?? 0;
112112
const rate = aprFor(opp);
113113
const source = isMor ? "Morpheus" : yields?.usdc?.source ?? "Morpho";
114114

src/services/yields.ts

Lines changed: 23 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
// Live staking/lending yields shown on the /stake page.
22
// - Lido: stETH staking APR (Ethereum L1)
33
// - Morpho: largest listed USDC vault net APY on Base
4-
// - Morpheus: blended MOR-reward APR on the shared Capital pool (Ethereum L1)
5-
6-
import { createPublicClient, http } from "viem";
7-
import { mainnet } from "viem/chains";
8-
import { MORPHEUS_POOLS, MORPHEUS_DISTRIBUTOR, MOR_TOKEN } from "@/lib/morpheus";
4+
// - Morpheus: MOR-reward APR PER ASSET (see MORPHEUS_APR note)
95

106
export interface StakeYield {
117
/** Annual rate as a percentage, e.g. 4.35 */
@@ -18,11 +14,16 @@ export interface StakeYield {
1814
estimate?: boolean;
1915
}
2016

17+
/** Morpheus shows a distinct APR per deposit asset, not one blended rate. */
18+
export interface MorpheusYields {
19+
steth: StakeYield | null;
20+
usdc: StakeYield | null;
21+
}
22+
2123
export interface StakeYields {
2224
eth: StakeYield | null;
2325
usdc: StakeYield | null;
24-
/** Blended Morpheus (MOR) reward APR — same rate for the stETH and USDC pools. */
25-
mor: StakeYield | null;
26+
mor: MorpheusYields;
2627
updatedAt: number;
2728
}
2829

@@ -31,72 +32,19 @@ const MORPHO_GRAPHQL_URL = "https://blue-api.morpho.org/graphql";
3132
const BASE_CHAIN_ID = 8453;
3233

3334
// ---- Morpheus (MOR) capital-pool APR ----------------------------------------
34-
// Emission for the shared reward pool (index 0) is minted MOR/day; it's split
35-
// across the stETH + USDC deposit pools by their oracle-valued deposits, so the
36-
// blended APR is: dailyMor * morUsd * 365 / totalTvlUsd. Verified live on-chain
37-
// (getPeriodRewards / totalDepositedInPublicPools) before wiring.
38-
const MOR_REWARD_POOL_INDEX = BigInt(0);
39-
const morMainnet = createPublicClient({
40-
chain: mainnet,
41-
transport: http(process.env.ETH_RPC_URL || "https://ethereum.publicnode.com"),
42-
});
43-
const distributorAbi = [
44-
{ type: "function", name: "rewardPool", stateMutability: "view", inputs: [], outputs: [{ type: "address" }] },
45-
] as const;
46-
const rewardPoolAbi = [
47-
{ type: "function", name: "getPeriodRewards", stateMutability: "view",
48-
inputs: [{ type: "uint256" }, { type: "uint128" }, { type: "uint128" }], outputs: [{ type: "uint256" }] },
49-
] as const;
50-
const depositPoolAbi = [
51-
{ type: "function", name: "totalDepositedInPublicPools", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] },
52-
] as const;
53-
54-
async function coingeckoUsd(url: string, pick: (j: unknown) => number | undefined): Promise<number | null> {
55-
try {
56-
const res = await fetch(url, { next: { revalidate: 300 } });
57-
if (!res.ok) return null;
58-
const v = pick(await res.json());
59-
return typeof v === "number" && v > 0 ? v : null;
60-
} catch {
61-
return null;
62-
}
63-
}
64-
65-
async function getMorpheusMorApr(): Promise<StakeYield | null> {
66-
try {
67-
const rewardPool = await morMainnet.readContract({
68-
address: MORPHEUS_DISTRIBUTOR, abi: distributorAbi, functionName: "rewardPool",
69-
});
70-
const now = Math.floor(Date.now() / 1000);
71-
const [daily, stDep, usdcDep, morUsd, ethUsd] = await Promise.all([
72-
morMainnet.readContract({
73-
address: rewardPool, abi: rewardPoolAbi, functionName: "getPeriodRewards",
74-
args: [MOR_REWARD_POOL_INDEX, BigInt(now), BigInt(now + 86400)],
75-
}),
76-
morMainnet.readContract({ address: MORPHEUS_POOLS.stEth.pool, abi: depositPoolAbi, functionName: "totalDepositedInPublicPools" }),
77-
morMainnet.readContract({ address: MORPHEUS_POOLS.usdc.pool, abi: depositPoolAbi, functionName: "totalDepositedInPublicPools" }),
78-
coingeckoUsd(
79-
`https://api.coingecko.com/api/v3/simple/token_price/arbitrum-one?contract_addresses=${MOR_TOKEN}&vs_currencies=usd`,
80-
(j) => (j as Record<string, { usd?: number }>)?.[MOR_TOKEN.toLowerCase()]?.usd,
81-
),
82-
coingeckoUsd(
83-
"https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd",
84-
(j) => (j as { ethereum?: { usd?: number } })?.ethereum?.usd,
85-
),
86-
]);
87-
if (!morUsd || !ethUsd) return null;
88-
89-
const dailyMor = Number(daily) / 1e18;
90-
const tvlUsd = (Number(stDep) / 1e18) * ethUsd + Number(usdcDep) / 1e6; // stETH ≈ ETH, USDC = $1
91-
if (tvlUsd <= 0) return null;
92-
93-
const apr = (dailyMor * morUsd * 365) / tvlUsd * 100;
94-
if (!isFinite(apr) || apr <= 0) return null;
95-
return { apy: apr, source: "Morpheus", detail: "MOR", estimate: true };
96-
} catch (error) {
97-
console.error("[yields] Morpheus fetch failed:", error);
98-
return null;
99-
}
35+
// Morpheus splits its shared reward pool's emissions across deposit pools by an
36+
// internal virtual/power-adjusted weighting, so each asset shows a distinct APR
37+
// that can't be reproduced cleanly on-chain — and Morpheus exposes no public API
38+
// for it (their dashboard computes client-side). These are synced from the
39+
// Capital table on mor.org; refresh when they drift. Marked estimate=true since
40+
// MOR emissions and price move.
41+
const MORPHEUS_APR: Record<"steth" | "usdc", number> = {
42+
usdc: 24.5,
43+
steth: 16.78,
44+
};
45+
function morpheusYields(): MorpheusYields {
46+
const mk = (apy: number): StakeYield => ({ apy, source: "Morpheus", detail: "MOR", estimate: true });
47+
return { usdc: mk(MORPHEUS_APR.usdc), steth: mk(MORPHEUS_APR.steth) };
10048
}
10149

10250
async function getLidoEthApr(): Promise<StakeYield | null> {
@@ -141,6 +89,6 @@ async function getMorphoUsdcApy(): Promise<StakeYield | null> {
14189
}
14290

14391
export async function getStakeYields(): Promise<StakeYields> {
144-
const [eth, usdc, mor] = await Promise.all([getLidoEthApr(), getMorphoUsdcApy(), getMorpheusMorApr()]);
145-
return { eth, usdc, mor, updatedAt: Date.now() };
92+
const [eth, usdc] = await Promise.all([getLidoEthApr(), getMorphoUsdcApy()]);
93+
return { eth, usdc, mor: morpheusYields(), updatedAt: Date.now() };
14694
}

0 commit comments

Comments
 (0)