Skip to content

Commit 0376f46

Browse files
r4topunkclaude
andcommitted
Merge main into perf/stake-cache-standard
Resolves two conflicts against #224 and #225, both in the /stake feature: - `services/stake-graph.ts`: #224 added a `claimReceiver` check so the orbit only counts official /stake deposits. Kept, composed with this branch's `priceStEth()` — their receiver filter runs first, then the stETH row is priced through the guard that throws instead of silently valuing it at $0. Also took their hoisted `user` const. - `hooks/use-morpheus-stake.ts`: #225 added the `claimLockEnd` power-factor argument. Kept verbatim; this branch's only change to that block was prettier formatting, plus the `requestRevalidation` calls, which are on untouched lines. Both sides' intent verified present after the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents ee3be3e + d1ca3b0 commit 0376f46

6 files changed

Lines changed: 270 additions & 10 deletions

File tree

messages/en/stake.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,22 @@
139139
"lineStable": "Steady wins. {you} {asset} a year back to you — I'll take my cut in grip tape.",
140140
"lineVar": "~{rate}% APR is a big air. Could land clean, could slam. You in?"
141141
},
142+
"lock": {
143+
"step": "Step 2 · boost (optional)",
144+
"title": "Lock your MOR claim to earn more",
145+
"explain": "Your stETH stays liquid — withdraw after the 7-day window, whenever you want. You only defer WHEN you can claim the MOR you earn; in exchange your reward multiplier goes up.",
146+
"continue": "Next: lock →",
147+
"none": "No lock",
148+
"noneNote": "Claim your MOR anytime",
149+
"years": "{n, plural, one {# year} other {# years}}",
150+
"mult": "{mult}× more MOR",
151+
"oneway": "A lock can only be extended later, never shortened.",
152+
"back": "Back",
153+
"summaryTitle": "What you're choosing",
154+
"summaryNone": "No lock — base rate, and you can claim your MOR whenever you like.",
155+
"summaryLocked": "Locking {label} → {mult}× the MOR share, in exchange for not claiming it until then.",
156+
"stethNote": "Either way your stETH principal is never locked by this — only the 7-day withdraw rule applies."
157+
},
142158
"lootbox": {
143159
"title": "Rewards ready",
144160
"claim": "Claim",

messages/pt-br/stake.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,22 @@
139139
"lineStable": "Devagar e sempre. {you} {asset} por ano de volta pra você — meu corte eu pego em fita.",
140140
"lineVar": "~{rate}% APR é um baita air. Pode cair limpo ou levar tombo. Tá dentro?"
141141
},
142+
"lock": {
143+
"step": "Passo 2 · turbinar (opcional)",
144+
"title": "Trave o claim do MOR pra render mais",
145+
"explain": "Seu stETH continua líquido — saca depois dos 7 dias, quando quiser. Você só adia QUANDO pode sacar o MOR que acumula; em troca, seu multiplicador de recompensa sobe.",
146+
"continue": "Próximo: lock →",
147+
"none": "Sem lock",
148+
"noneNote": "Saca seu MOR quando quiser",
149+
"years": "{n, plural, one {# ano} other {# anos}}",
150+
"mult": "{mult}× mais MOR",
151+
"oneway": "O lock só pode ser estendido depois, nunca encurtado.",
152+
"back": "Voltar",
153+
"summaryTitle": "O que você está escolhendo",
154+
"summaryNone": "Sem lock — taxa base, e você saca seu MOR quando quiser.",
155+
"summaryLocked": "Travar {label} → {mult}× a fatia de MOR, em troca de não poder sacar até lá.",
156+
"stethNote": "De qualquer forma seu stETH nunca fica preso por isso — vale só a regra de saque de 7 dias."
157+
},
142158
"lootbox": {
143159
"title": "Recompensas prontas",
144160
"claim": "Sacar",

src/components/stake/StakeDialog.tsx

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useEnsNameAndAvatar } from "@/hooks/use-ens";
1414
import type { StakeYields } from "@/services/yields";
1515
import { REWARD_SPLIT } from "./CharacterSelector";
1616
import { riderCustomLine } from "@/lib/rider-lines";
17+
import { LOCK_OPTIONS, multiplierForYears, claimLockEndFor } from "@/lib/lock-multiplier";
1718

1819
// Adapted from the Claude-designed "Stake Dialog v2" — arcade-gold, three
1920
// columns (yield source / amount / your share) over a rewards-flow hero with the
@@ -86,10 +87,19 @@ export function StakeDialog({ open, onOpenChange, riderId, name, image, overall,
8687
const [refresh, setRefresh] = useState(0);
8788
const [oppId, setOppId] = useState<OppId>("vault-usdc");
8889
const [amount, setAmount] = useState(OPPS[0].default);
90+
// MOR stakes get a 2nd step to tune the optional power-factor lock, so the
91+
// first screen stays lean for newcomers. `nowSec` is captured once (lazy init)
92+
// to keep the multiplier math out of render-time Date.now().
93+
const [step, setStep] = useState<"config" | "lock">("config");
94+
const [lockYears, setLockYears] = useState(0);
95+
const [nowSec] = useState(() => Math.floor(Date.now() / 1000));
8996

9097
const { data: yields } = useQuery({ queryKey: ["stake-yields"], queryFn: fetchYields, staleTime: 60_000 });
9198
const { data: ethUsd = 0 } = useQuery({ queryKey: ["eth-price"], queryFn: fetchEthPrice, staleTime: 60_000 });
9299

100+
// Reopen always lands on the lean first step with no lock preselected.
101+
useEffect(() => { if (open) { setStep("config"); setLockYears(0); } }, [open]);
102+
93103
const opp = OPPS.find((o) => o.id === oppId)!;
94104
const isMor = opp.kind === "mor";
95105
const isUsdc = opp.asset === "usdc";
@@ -100,14 +110,18 @@ export function StakeDialog({ open, onOpenChange, riderId, name, image, overall,
100110

101111
const assetUsd = opp.asset === "steth" ? ethUsd : 1;
102112
const amountNum = Math.max(0, parseFloat(amount) || 0);
103-
const totalAsset = (amountNum * rate) / 100; // yield in the deposit asset
113+
// The power-factor lock scales the MOR projection (1× when no lock / vault).
114+
const lockMult = isMor ? multiplierForYears(lockYears, nowSec) : 1;
115+
const totalAsset = ((amountNum * rate) / 100) * lockMult; // yield in the deposit asset
104116
const totalUsd = totalAsset * assetUsd;
105117
const busy = isMor ? morpheus.isBusy : isStaking;
106118

107119
const switchOpp = (next: OppId) => {
108120
const o = OPPS.find((x) => x.id === next)!;
109121
setOppId(next);
110122
setAmount(o.default);
123+
setStep("config");
124+
setLockYears(0);
111125
};
112126

113127
// Shares of the yield — the 3-way split (mirrors the vault and the MOR split).
@@ -146,7 +160,8 @@ export function StakeDialog({ open, onOpenChange, riderId, name, image, overall,
146160
const handleConfirm = async () => {
147161
if (isMor) {
148162
if (!rider?.wallet) return;
149-
const ok = await morpheus.stake(opp.asset === "steth" ? "stEth" : "usdc", amount, rider.wallet);
163+
const lockEnd = claimLockEndFor(lockYears);
164+
const ok = await morpheus.stake(opp.asset === "steth" ? "stEth" : "usdc", amount, rider.wallet, lockEnd);
150165
if (ok) { toast.success(t("opp.stakedMorTitle", { name }), { description: t("opp.stakedMorDesc") }); setRefresh((n) => n + 1); onOpenChange(false); }
151166
else toast.error(t("dlg.failTitle"), { description: morpheus.error ?? undefined });
152167
return;
@@ -271,7 +286,9 @@ export function StakeDialog({ open, onOpenChange, riderId, name, image, overall,
271286
</div>
272287
</div>
273288

274-
{/* Three columns */}
289+
{/* Step 1 (config): source · amount · your share. Step 2 (lock, MOR only)
290+
tunes the optional power-factor multiplier — kept off the first screen. */}
291+
{step === "config" ? (
275292
<div className="grid items-start gap-5 sm:grid-cols-3">
276293
{/* Yield source */}
277294
<div className="min-w-0">
@@ -376,15 +393,28 @@ export function StakeDialog({ open, onOpenChange, riderId, name, image, overall,
376393
))}
377394
<button
378395
type="button"
379-
onClick={handleConfirm}
396+
onClick={isMor ? () => setStep("lock") : handleConfirm}
380397
disabled={busy}
381398
className="mt-auto cursor-pointer rounded-[13px] px-5 py-3.5 text-center text-[14.5px] font-extrabold disabled:opacity-70"
382399
style={{ color: "#1a1205", background: "linear-gradient(90deg,#f7c948,#f5851f)", boxShadow: "0 8px 24px rgba(245,133,31,.28)" }}
383400
>
384-
{confirmLabel}
401+
{isMor ? t("lock.continue") : confirmLabel}
385402
</button>
386403
</div>
387404
</div>
405+
) : (
406+
<LockStep
407+
t={t}
408+
rate={rate}
409+
nowSec={nowSec}
410+
lockYears={lockYears}
411+
setLockYears={setLockYears}
412+
onBack={() => setStep("config")}
413+
onStake={handleConfirm}
414+
stakeLabel={confirmLabel}
415+
busy={busy}
416+
/>
417+
)}
388418

389419
{/* Vault position management — only when a live position exists */}
390420
{!isMor && position && position.shares > BigInt(0) && (
@@ -485,3 +515,91 @@ function RewardFlow({
485515
</div>
486516
);
487517
}
518+
519+
/** Step 2 (MOR only): pick the optional power-factor lock. The multiplier is
520+
* exact (on-chain LockMultiplierMath replica); the APR it scales is the same
521+
* live estimate shown on step 1, so the RELATIVE boost is honest. */
522+
function LockStep({
523+
t, rate, nowSec, lockYears, setLockYears, onBack, onStake, stakeLabel, busy,
524+
}: {
525+
t: ReturnType<typeof useTranslations>;
526+
rate: number; nowSec: number; lockYears: number;
527+
setLockYears: (y: number) => void; onBack: () => void; onStake: () => void; stakeLabel: string; busy: boolean;
528+
}) {
529+
const muted = "#8a857e";
530+
const optLabel = (y: number) => (y === 0 ? t("lock.none") : t("lock.years", { n: y }));
531+
return (
532+
<div className="grid items-start gap-5 sm:grid-cols-[minmax(0,1fr)_300px]">
533+
{/* Explainer + lock options */}
534+
<div className="min-w-0">
535+
<div className="mb-1 text-[11px] font-bold uppercase tracking-[0.24em]" style={{ color: muted }}>{t("lock.step")}</div>
536+
<h3 className="m-0 text-lg font-black">{t("lock.title")}</h3>
537+
<p className="mt-1.5 text-[13px] leading-relaxed" style={{ color: muted }}>{t("lock.explain")}</p>
538+
<div className="mt-4 grid gap-2.5">
539+
{LOCK_OPTIONS.map(({ years }) => {
540+
const m = multiplierForYears(years, nowSec);
541+
const active = years === lockYears;
542+
return (
543+
<button
544+
key={years}
545+
type="button"
546+
onClick={() => setLockYears(years)}
547+
aria-pressed={active}
548+
className="flex cursor-pointer items-center gap-3 rounded-[13px] px-4 py-3 text-left transition"
549+
style={{
550+
border: active ? `2px solid ${GOLD}` : "1px solid rgba(255,255,255,.09)",
551+
background: active ? "rgba(245,166,35,.09)" : "rgba(255,255,255,.035)",
552+
}}
553+
>
554+
<div className="min-w-0 flex-1">
555+
<div className="text-sm font-bold">{optLabel(years)}</div>
556+
<div className="mt-0.5 text-[11.5px] font-semibold" style={{ color: muted }}>
557+
{years === 0 ? t("lock.noneNote") : t("lock.mult", { mult: m.toFixed(2) })}
558+
</div>
559+
</div>
560+
<div className="flex-none text-right">
561+
<div className="text-[17px] font-black" style={{ color: GREEN }}>~{(rate * m).toFixed(0)}%</div>
562+
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ color: muted }}>APR</div>
563+
</div>
564+
</button>
565+
);
566+
})}
567+
</div>
568+
<div className="mt-3 flex items-start gap-2 text-[11.5px] font-semibold" style={{ color: "#b8741a" }}>
569+
<span className="flex-none"></span><span>{t("lock.oneway")}</span>
570+
</div>
571+
</div>
572+
573+
{/* Summary + actions */}
574+
<div className="flex min-h-[240px] flex-col gap-3 rounded-[18px] border border-white/[0.07] p-[18px]" style={{ background: "rgba(255,255,255,.035)" }}>
575+
<div className="text-[11px] font-bold uppercase tracking-[0.22em]" style={{ color: muted }}>{t("lock.summaryTitle")}</div>
576+
<p className="text-[13px] leading-relaxed" style={{ color: "#c9c6c2" }}>
577+
{lockYears === 0
578+
? t("lock.summaryNone")
579+
: t("lock.summaryLocked", { label: optLabel(lockYears), mult: multiplierForYears(lockYears, nowSec).toFixed(2) })}
580+
</p>
581+
<p className="text-[12px] leading-relaxed" style={{ color: muted }}>{t("lock.stethNote")}</p>
582+
<div className="mt-auto flex flex-col gap-2">
583+
<button
584+
type="button"
585+
onClick={onStake}
586+
disabled={busy}
587+
className="cursor-pointer rounded-[13px] px-5 py-3.5 text-center text-[14.5px] font-extrabold disabled:opacity-70"
588+
style={{ color: "#1a1205", background: "linear-gradient(90deg,#f7c948,#f5851f)", boxShadow: "0 8px 24px rgba(245,133,31,.28)" }}
589+
>
590+
{stakeLabel}
591+
</button>
592+
<button
593+
type="button"
594+
onClick={onBack}
595+
disabled={busy}
596+
className="cursor-pointer rounded-[13px] border border-white/15 px-5 py-2.5 text-center text-[13px] font-bold disabled:opacity-60"
597+
style={{ color: "#c9c6c2" }}
598+
>
599+
{t("lock.back")}
600+
</button>
601+
</div>
602+
</div>
603+
</div>
604+
);
605+
}

src/hooks/use-morpheus-stake.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,17 @@ export function useMorpheusStake() {
153153
const [error, setError] = useState<string | null>(null);
154154
const pending = useRef(false);
155155

156-
/** Stake `amount` of the asset, crediting the athlete as referrer. */
156+
/** Stake `amount` of the asset, crediting the athlete as referrer. `claimLockEnd`
157+
* (unix seconds, 0 = none) is the optional power-factor lock: it defers when the
158+
* MOR can be CLAIMED, boosting the reward multiplier — it does NOT lock the
159+
* deposit (that follows the 7-day withdraw rule). */
157160
const stake = useCallback(
158-
async (asset: MorpheusAsset, amount: string, athlete: Address): Promise<boolean> => {
161+
async (
162+
asset: MorpheusAsset,
163+
amount: string,
164+
athlete: Address,
165+
claimLockEnd = 0,
166+
): Promise<boolean> => {
159167
if (pending.current) return false;
160168
const client = getThirdwebClient();
161169
if (!client) {
@@ -235,11 +243,17 @@ export function useMorpheusStake() {
235243
}
236244

237245
setPhase("stake");
238-
// claimLockEnd = 0 → no extra lock beyond the protocol's 7-day default.
246+
// claimLockEnd > 0 → defer MOR claims until then for a bigger reward
247+
// multiplier (power factor); 0 keeps only the protocol's 7-day default.
239248
const stakeData = encodeFunctionData({
240249
abi: depositPoolAbi,
241250
functionName: "stake",
242-
args: [MOR_REWARD_POOL_INDEX, assets, BigInt(0), athlete],
251+
args: [
252+
MOR_REWARD_POOL_INDEX,
253+
assets,
254+
BigInt(Math.max(0, Math.floor(claimLockEnd))),
255+
athlete,
256+
],
243257
});
244258
const sendStake = async () => {
245259
const tx = prepareTransaction({ client, chain: ethereum, to: pool, data: stakeData });

src/lib/lock-multiplier.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Morpheus power-factor lock multiplier — a faithful float replica of the
2+
// on-chain LockMultiplierMath.getLockPeriodMultiplier (contracts/libs). The
3+
// curve is tanh-based, capped at 10.7×, and anchored to ABSOLUTE dates (the MOR
4+
// distribution window, ending Jan 2040) — so the same lock *duration* is worth
5+
// slightly less the later you lock.
6+
//
7+
// What locking actually does (verified against DepositPool.sol): it commits you
8+
// to not CLAIMING your accrued MOR until the end date, in exchange for a bigger
9+
// share of emissions. It does NOT lock your deposit — the stETH/USDC principal
10+
// follows the protocol's 7-day withdraw rule regardless. The lock is one-way:
11+
// _stake requires the new claimLockEnd ≥ the current one (extend only).
12+
13+
const POWER_MAX = 16.61327546;
14+
const MAX_MULT = 10.7;
15+
const MIN_MULT = 1;
16+
const PERIOD_START = 1721908800; // Thu, 25 Jul 2024 12:00:00 UTC
17+
const PERIOD_END = 2211192000; // Thu, 26 Jan 2040 12:00:00 UTC
18+
const DISTRIBUTION = PERIOD_END - PERIOD_START;
19+
const YEAR = 365.25 * 86400;
20+
21+
/** Reward multiplier for a claim lock spanning [startSec, endSec] (both unix seconds). */
22+
export function lockMultiplier(startSec: number, endSec: number): number {
23+
const end = Math.min(endSec, PERIOD_END);
24+
const start = Math.max(startSec, PERIOD_START);
25+
if (start >= end) return 1;
26+
const endP = Math.tanh(2 * ((end - PERIOD_START) / DISTRIBUTION));
27+
const startP = Math.tanh(2 * ((start - PERIOD_START) / DISTRIBUTION));
28+
const m = POWER_MAX * (endP - startP);
29+
return Math.min(Math.max(m, MIN_MULT), MAX_MULT);
30+
}
31+
32+
/** The `claimLockEnd` timestamp (unix seconds) for locking `years` from `nowSec`; 0 = no lock. */
33+
export function claimLockEndFor(years: number, nowSec: number = Math.floor(Date.now() / 1000)): number {
34+
return years <= 0 ? 0 : Math.round(nowSec + years * YEAR);
35+
}
36+
37+
/** The multiplier you'd get by locking `years` from `nowSec` (1 = no boost). */
38+
export function multiplierForYears(years: number, nowSec: number = Math.floor(Date.now() / 1000)): number {
39+
if (years <= 0) return 1;
40+
return lockMultiplier(nowSec, nowSec + years * YEAR);
41+
}
42+
43+
export interface LockOption {
44+
/** Lock length in years; 0 = no lock. */
45+
years: number;
46+
}
47+
// Sub-1-year locks barely move the tanh curve (still ~1×), so we skip them: the
48+
// meaningful choices start at 1 year. Kept short so first-timers aren't drowned.
49+
export const LOCK_OPTIONS: LockOption[] = [
50+
{ years: 0 },
51+
{ years: 1 },
52+
{ years: 2 },
53+
{ years: 3 },
54+
{ years: 5 },
55+
];

src/services/stake-graph.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,40 @@ async function etherscanReferred(
282282
return [];
283283
}
284284

285+
// The MOR claim receiver a staker set for a pool (0x0 if never set). The /stake
286+
// flow wires this to the rider's 3-way split, so a NON-zero receiver marks an
287+
// "official" sponsorship stake (its MOR routes to Gnars + the athlete). A zero
288+
// receiver is a raw Morpheus deposit that pays 100% back to the staker — not a
289+
// sponsorship, so the orbit must not count it. Read via Etherscan proxy eth_call
290+
// (datacenter-safe, same key as the log scan).
291+
const CLAIM_RECEIVER_SIG = keccak256(toHex("claimReceiver(uint256,address)")).slice(0, 10);
292+
async function etherscanClaimReceiver(
293+
pool: Address,
294+
user: Address,
295+
key: string,
296+
): Promise<Address | null> {
297+
const data = `${CLAIM_RECEIVER_SIG}${"0".repeat(64)}${pad32(user).slice(2)}`;
298+
const url =
299+
`https://api.etherscan.io/v2/api?chainid=1&module=proxy&action=eth_call` +
300+
`&to=${pool}&data=${data}&tag=latest&apikey=${key}`;
301+
for (let i = 0; i < 4; i++) {
302+
try {
303+
const res = await fetch(url, { cache: "no-store" });
304+
const j = (await res.json()) as { result?: unknown; message?: string };
305+
if (typeof j.result === "string" && j.result.length >= 66)
306+
return getAddress(`0x${j.result.slice(-40)}`);
307+
if (/rate limit/i.test(String(j.result)) || /rate limit/i.test(String(j.message))) {
308+
await sleep(600);
309+
continue;
310+
}
311+
return null;
312+
} catch {
313+
await sleep(300);
314+
}
315+
}
316+
return null;
317+
}
318+
285319
async function morBackersByRider(ethUsd: number): Promise<Record<string, OrbitBacker[]>> {
286320
const walletToId = new Map<string, RiderId>();
287321
for (const r of RIDER_LIST) if (r.wallet) walletToId.set(r.wallet.toLowerCase(), r.id);
@@ -319,12 +353,19 @@ async function morBackersByRider(ethUsd: number): Promise<Record<string, OrbitBa
319353
);
320354
const out: Array<{ id: RiderId; backer: OrbitBacker }> = [];
321355
for (const [userLc, amt] of byUser) {
356+
const user = getAddress(userLc);
357+
// Only OFFICIAL /stake deposits belong in the sponsorship orbit: the
358+
// site wires the MOR claim receiver to the rider's 3-way split. A zero
359+
// receiver is a raw Morpheus stake whose MOR pays 100% to the staker
360+
// (no Gnars/athlete cut) — exclude it from the orbit AND the total.
361+
const receiver = await etherscanClaimReceiver(pool, user, ETHERSCAN_KEY as string);
362+
if (!receiver || receiver.toLowerCase() === ZERO) continue;
322363
const tokens = Number(formatUnits(amt, decimals));
323364
const usd = asset === "steth" ? priceStEth(tokens, ethUsd) : tokens;
324365
if (usd > 0)
325366
out.push({
326367
id,
327-
backer: { address: getAddress(userLc), amount: usd, kind: "mor", asset },
368+
backer: { address: user, amount: usd, kind: "mor", asset },
328369
});
329370
}
330371
return out;

0 commit comments

Comments
 (0)