From ba0d62835e27ebb7f2a2368e8f8ac526d9316b31 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Sat, 25 Jul 2026 16:12:43 -0300 Subject: [PATCH] perf(stake): cache + multicall the orbit graph; fix stale dialog copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orbit did dozens of sequential client RPC round-trips on every mount (per-backer balanceOf + convertToAssets in a loop, per-pool log scans), so it painted slowly. Move it server-side and cache it: - New /api/stake-graph (revalidate 60s + stale-while-revalidate 300s), backed by src/services/stake-graph.ts. Shared across users → warm loads are instant. - Multicall3: each vault's totalAssets/totalSupply/every balance goes in ONE aggregated call; shares→assets is computed locally, dropping all per-backer convertToAssets calls. MOR log scans + usersData reads are parallelized and multicalled too. - useStakeGraph is now a thin react-query hook over the cached route (kept warm between navigations); same public API and types. Also fixes wrong dialog copy: the disclaimer no longer claims staking "isn't wired to a contract yet" (it is), and the intro no longer says "Stake ETH". Localized en + pt-br. Multicall verified against a live vault; tsc + next build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/en/stake.json | 4 +- messages/pt-br/stake.json | 4 +- src/app/api/stake-graph/route.ts | 16 ++ src/hooks/use-stake-graph.ts | 260 +++---------------------------- src/services/stake-graph.ts | 251 +++++++++++++++++++++++++++++ 5 files changed, 289 insertions(+), 246 deletions(-) create mode 100644 src/app/api/stake-graph/route.ts create mode 100644 src/services/stake-graph.ts diff --git a/messages/en/stake.json b/messages/en/stake.json index 228d299a..d586fbbd 100644 --- a/messages/en/stake.json +++ b/messages/en/stake.json @@ -7,7 +7,7 @@ "selectedRider": "{name} is ready to drop in.", "stakeCta": "Stake with {name}", "stakeToast": "Staked with {name}", - "dialogIntro": "Stake ETH as {name}. Your ETH keeps earning staking yield — the rewards stream to you, your rider, and the Gnars treasury.", + "dialogIntro": "Back {name}. Your deposit stays yours and keeps earning — only the yield is shared with your rider and the Gnars treasury.", "amountLabel": "Amount to stake", "aprNote": "at {rate}% {rateType} via {source}", "flowTitle": "How rewards flow", @@ -18,7 +18,7 @@ "treasuryLabel": "Gnars Treasury", "confirm": "Confirm stake", "cancel": "Cancel", - "disclaimer": "Preview only — staking isn't wired to a contract yet, and this isn't financial advice.", + "disclaimer": "Your deposit stays yours and is withdrawable anytime. Rates vary; this isn't financial advice.", "prev": "Previous rider", "next": "Next rider", "statusTitle": "Status", diff --git a/messages/pt-br/stake.json b/messages/pt-br/stake.json index 37130465..54bd4652 100644 --- a/messages/pt-br/stake.json +++ b/messages/pt-br/stake.json @@ -7,7 +7,7 @@ "selectedRider": "{name} está pronto para descer.", "stakeCta": "Fazer stake com {name}", "stakeToast": "Stake feito com {name}", - "dialogIntro": "Faça stake de ETH como {name}. Seu ETH continua rendendo staking — as recompensas fluem para você, seu rider e o tesouro da Gnars.", + "dialogIntro": "Apoie {name}. Seu depósito continua seu e rendendo — só o rendimento é dividido com o rider e o tesouro da Gnars.", "amountLabel": "Valor para stake", "aprNote": "a {rate}% {rateType} via {source}", "flowTitle": "Como as recompensas fluem", @@ -18,7 +18,7 @@ "treasuryLabel": "Tesouro Gnars", "confirm": "Confirmar stake", "cancel": "Cancelar", - "disclaimer": "Apenas prévia — o stake ainda não está ligado a um contrato, e isto não é aconselhamento financeiro.", + "disclaimer": "Seu depósito continua seu e pode ser sacado quando quiser. As taxas variam; isto não é aconselhamento financeiro.", "prev": "Rider anterior", "next": "Próximo rider", "statusTitle": "Status", diff --git a/src/app/api/stake-graph/route.ts b/src/app/api/stake-graph/route.ts new file mode 100644 index 00000000..8c9e8b7f --- /dev/null +++ b/src/app/api/stake-graph/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getStakeGraph } from "@/services/stake-graph"; + +// Recompute at most once a minute; serve stale for 5 more while revalidating. +export const revalidate = 60; + +export async function GET() { + try { + const graph = await getStakeGraph(); + return NextResponse.json(graph, { + headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" }, + }); + } catch { + return NextResponse.json({ error: "stake_graph_failed" }, { status: 500 }); + } +} diff --git a/src/hooks/use-stake-graph.ts b/src/hooks/use-stake-graph.ts index ad272307..f90f971f 100644 --- a/src/hooks/use-stake-graph.ts +++ b/src/hooks/use-stake-graph.ts @@ -1,251 +1,27 @@ "use client"; -// The whole sponsorship graph in one read: every rider vault's total, and who -// backs it for how much. Feeds the orbital view, where the per-rider supporters -// list can't show a backer's positions across riders at once. -// -// Depositors are found from each vault's share-token transfers (a deposit mints -// shares, from 0x0), then every balance is read from the contract — the index -// only says WHO to ask about, never how much. +// Thin client hook over the cached /api/stake-graph route. The heavy on-chain +// work (multicalled, parallelized) now happens server-side and is cached + +// shared across users, so the orbit paints from JSON instead of doing dozens of +// RPC round-trips on every mount. react-query keeps it warm between navigations. -import { useEffect, useState } from "react"; -import { createPublicClient, http, fallback, formatUnits, getAddress, type Address } from "viem"; -import { base, mainnet } from "viem/chains"; -import { RIDER_LIST, type RiderId } from "@/lib/gnars-vaults"; -import { MORPHEUS_POOLS, MOR_REWARD_POOL_INDEX, depositPoolAbi } from "@/lib/morpheus"; +import { useQuery } from "@tanstack/react-query"; +import type { StakeGraph } from "@/services/stake-graph"; -const client = createPublicClient({ - chain: base, - transport: fallback([ - http("https://mainnet.base.org"), - http("https://base-rpc.publicnode.com"), - http("https://base.drpc.org"), - ]), -}); +export type { OrbitBacker, OrbitAthlete, StakeGraph } from "@/services/stake-graph"; -// Morpheus deposits live on Ethereum mainnet; a separate client reads them. -const ethClient = createPublicClient({ - chain: mainnet, - transport: fallback([ - http("https://ethereum.publicnode.com"), - http("https://eth.llamarpc.com"), - http("https://rpc.ankr.com/eth"), - ]), -}); - -// The referrer is indexed, so we can pull exactly the Morpheus stakes that named -// one of our riders — cheaply, without scanning every staker on the pool. -const userReferredEvent = { - type: "event", name: "UserReferred", - inputs: [ - { name: "rewardPoolIndex", type: "uint256", indexed: true }, - { name: "user", type: "address", indexed: true }, - { name: "referrer", type: "address", indexed: true }, - { name: "amount", type: "uint256", indexed: false }, - ], -} as const; - -const abi = [ - { type: "function", name: "totalAssets", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, - { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, - { type: "function", name: "convertToAssets", stateMutability: "view", inputs: [{ type: "uint256" }], outputs: [{ type: "uint256" }] }, -] as const; - -export type OrbitBacker = { - address: Address; - amount: number; - /** "vault" = Morpho USDC sponsorship vault (Base); "mor" = Morpheus stake (mainnet). */ - kind: "vault" | "mor"; - /** For MOR backers, which asset they staked. */ - asset?: "steth" | "usdc"; -}; -export type OrbitAthlete = { - id: RiderId; - handle: string; - vault: Address; - split?: Address; - total: number; - /** Fee accrued to the split for this vault (the shares minted to it), in USDC. */ - feeAccrued: number; - backers: OrbitBacker[]; -}; -export type StakeGraph = { - athletes: OrbitAthlete[]; - total: number; - /** Distinct backer addresses across all riders. */ - backerCount: number; - /** The Gnars treasury's earned share so far = half of the accrued fee. */ - gnarsAccrued: number; -}; - -const ZERO = "0x0000000000000000000000000000000000000000"; - -async function backerAddresses(vault: Address, feeRecipient?: Address): Promise> { - const out = new Set
(); - try { - const res = await fetch(`https://base.blockscout.com/api/v2/tokens/${vault}/transfers`, { - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(9000), - }); - if (res.ok) { - const json = (await res.json()) as { items?: { to?: { hash?: string }; from?: { hash?: string } }[] }; - for (const t of json.items ?? []) { - for (const raw of [t.to?.hash, t.from?.hash]) { - if (raw && raw.toLowerCase() !== ZERO) { - try { out.add(getAddress(raw)); } catch { /* skip */ } - } - } - } - } - } catch { /* fall through */ } - // The fee recipient (the split) holds shares too — it's not a backer, drop it. - if (feeRecipient) out.delete(getAddress(feeRecipient)); - return out; -} - -async function fetchEthUsd(): Promise { - try { - const res = await fetch("/api/eth-price"); - if (!res.ok) return 0; - const json = (await res.json()) as { usd?: number }; - return json.usd ?? 0; - } catch { - return 0; - } -} - -/** - * Morpheus (MOR) backers per rider — the mainnet stakes that named a rider as - * referrer. Kept separate from the vault backers, so a wallet that staked on - * BOTH shows up twice (a green MOR stream and a Morpho one). Best-effort: on any - * RPC hiccup the orbit just falls back to the vault backers. - */ -async function morBackersByRider(ethUsd: number): Promise> { - const walletToId = new Map(); - for (const r of RIDER_LIST) if (r.wallet) walletToId.set(r.wallet.toLowerCase(), r.id); - const referrers = [...walletToId.keys()].map((a) => getAddress(a)); - const out: Record = {}; - if (referrers.length === 0) return out; - - let latest: bigint; - try { latest = await ethClient.getBlockNumber(); } catch { return out; } - // Gnars referrals only started recently; a ~2-week window (chunked to stay - // under public-RPC getLogs limits) covers them without a full-history scan. - const WINDOW = BigInt(90_000); - const CHUNK = BigInt(45_000); - const from0 = latest > WINDOW ? latest - WINDOW : BigInt(0); - - const pools: Array<{ asset: "steth" | "usdc"; pool: Address; decimals: number }> = [ - { asset: "steth", pool: MORPHEUS_POOLS.stEth.pool, decimals: 18 }, - { asset: "usdc", pool: MORPHEUS_POOLS.usdc.pool, decimals: 6 }, - ]; - - for (const { asset, pool, decimals } of pools) { - const userToRef = new Map(); // user (lc) -> referrer (lc) - for (let start = from0; start <= latest; start += CHUNK + BigInt(1)) { - const end = start + CHUNK > latest ? latest : start + CHUNK; - try { - const logs = await ethClient.getLogs({ - address: pool, event: userReferredEvent, - args: { rewardPoolIndex: MOR_REWARD_POOL_INDEX, referrer: referrers }, - fromBlock: start, toBlock: end, - }); - for (const l of logs) { - const user = l.args.user as Address | undefined; - const ref = l.args.referrer as Address | undefined; - if (user && ref) userToRef.set(user.toLowerCase(), ref.toLowerCase()); - } - } catch { /* skip this chunk */ } - } - - for (const [userLc, refLc] of userToRef) { - const id = walletToId.get(refLc); - if (!id) continue; - try { - const ud = (await ethClient.readContract({ - address: pool, abi: depositPoolAbi, functionName: "usersData", - args: [getAddress(userLc), MOR_REWARD_POOL_INDEX], - })) as unknown as readonly bigint[]; - const deposited = ud[1] ?? BigInt(0); // struct field: deposited - if (deposited <= BigInt(0)) continue; - const tokens = Number(formatUnits(deposited, decimals)); - const amount = asset === "steth" ? tokens * ethUsd : tokens; // stETH≈ETH, USDC=$1 - if (amount <= 0) continue; - (out[id] ||= []).push({ address: getAddress(userLc), amount, kind: "mor", asset }); - } catch { /* skip this user */ } - } - } - return out; +async function fetchStakeGraph(): Promise { + const res = await fetch("/api/stake-graph"); + if (!res.ok) throw new Error("stake-graph"); + return res.json(); } export function useStakeGraph(nonce = 0): StakeGraph | null { - const [graph, setGraph] = useState(null); - - useEffect(() => { - const live = RIDER_LIST.filter((r) => r.vault); - if (live.length === 0) { setGraph({ athletes: [], total: 0, backerCount: 0, gnarsAccrued: 0 }); return; } - let cancelled = false; - - (async () => { - try { - const ethUsd = await fetchEthUsd(); - const morPromise = morBackersByRider(ethUsd); - const athletes = await Promise.all( - live.map(async (r): Promise => { - const vault = r.vault as Address; - const totalRaw = await client.readContract({ address: vault, abi, functionName: "totalAssets" }); - const candidates = await backerAddresses(vault, r.split); - const backers: OrbitBacker[] = []; - for (const address of candidates) { - const shares = await client.readContract({ address: vault, abi, functionName: "balanceOf", args: [address] }); - if (shares <= BigInt(0)) continue; - const assets = await client.readContract({ address: vault, abi, functionName: "convertToAssets", args: [shares] }); - backers.push({ address, amount: Number(formatUnits(assets, 6)), kind: "vault" }); - } - backers.sort((a, b) => b.amount - a.amount); - - // The performance fee is minted to the split as vault shares — its - // position is the fee accrued so far. - let feeAccrued = 0; - if (r.split) { - const sShares = await client.readContract({ address: vault, abi, functionName: "balanceOf", args: [r.split as Address] }); - if (sShares > BigInt(0)) { - const sAssets = await client.readContract({ address: vault, abi, functionName: "convertToAssets", args: [sShares] }); - feeAccrued = Number(formatUnits(sAssets, 6)); - } - } - return { - id: r.id, handle: r.handle, vault, split: r.split, - total: Number(formatUnits(totalRaw, 6)), feeAccrued, backers, - }; - }), - ); - const mor = await morPromise; - if (cancelled) return; - // Attach MOR backers (kept distinct from vault backers) and re-rank. - for (const a of athletes) { - const m = mor[a.id]; - if (m && m.length) { - a.backers.push(...m); - a.backers.sort((x, y) => y.amount - x.amount); - } - } - const distinct = new Set(); - athletes.forEach((a) => a.backers.forEach((b) => distinct.add(b.address.toLowerCase()))); - setGraph({ - athletes, - total: athletes.reduce((s, a) => s + a.total, 0), - backerCount: distinct.size, - // Split is Gnars 50 / athlete 50, so the treasury's share is half. - gnarsAccrued: athletes.reduce((s, a) => s + a.feeAccrued, 0) / 2, - }); - } catch { - if (!cancelled) setGraph(null); - } - })(); - - return () => { cancelled = true; }; - }, [nonce]); - - return graph; + const { data } = useQuery({ + queryKey: ["stake-graph", nonce], + queryFn: fetchStakeGraph, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + return data ?? null; } diff --git a/src/services/stake-graph.ts b/src/services/stake-graph.ts new file mode 100644 index 00000000..9477b38a --- /dev/null +++ b/src/services/stake-graph.ts @@ -0,0 +1,251 @@ +// The whole sponsorship graph — every rider vault's total and who backs it — +// computed SERVER-SIDE and cached, so the orbit loads instantly (shared across +// users) instead of doing dozens of client RPC round-trips on every mount. +// +// Smart tricks vs. the old client hook: +// - Multicall3: all of a vault's reads (totalAssets/totalSupply/every balance) +// go in ONE aggregated call instead of 2 round-trips per backer. +// - Shares → assets is computed locally from totalAssets/totalSupply, killing +// the per-backer convertToAssets calls entirely. +// - MOR log scans and usersData reads are parallelized + multicalled. +// - The API route wraps this with s-maxage + stale-while-revalidate. + +import { createPublicClient, http, fallback, formatUnits, getAddress, type Address } from "viem"; +import { base, mainnet } from "viem/chains"; +import { RIDER_LIST, type RiderId } from "@/lib/gnars-vaults"; +import { MORPHEUS_POOLS, MOR_REWARD_POOL_INDEX, depositPoolAbi } from "@/lib/morpheus"; + +const baseClient = createPublicClient({ + chain: base, + batch: { multicall: true }, + transport: fallback([ + http("https://mainnet.base.org"), + http("https://base-rpc.publicnode.com"), + http("https://base.drpc.org"), + ]), +}); +const ethClient = createPublicClient({ + chain: mainnet, + batch: { multicall: true }, + transport: fallback([ + http("https://ethereum.publicnode.com"), + http("https://eth.llamarpc.com"), + http("https://rpc.ankr.com/eth"), + ]), +}); + +const userReferredEvent = { + type: "event", name: "UserReferred", + inputs: [ + { name: "rewardPoolIndex", type: "uint256", indexed: true }, + { name: "user", type: "address", indexed: true }, + { name: "referrer", type: "address", indexed: true }, + { name: "amount", type: "uint256", indexed: false }, + ], +} as const; + +const vaultAbi = [ + { type: "function", name: "totalAssets", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "totalSupply", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, +] as const; + +export type OrbitBacker = { + address: Address; + amount: number; + /** "vault" = Morpho USDC sponsorship vault (Base); "mor" = Morpheus stake (mainnet). */ + kind: "vault" | "mor"; + asset?: "steth" | "usdc"; +}; +export type OrbitAthlete = { + id: RiderId; + handle: string; + vault: Address; + split?: Address; + total: number; + feeAccrued: number; + backers: OrbitBacker[]; +}; +export type StakeGraph = { + athletes: OrbitAthlete[]; + total: number; + backerCount: number; + gnarsAccrued: number; +}; + +const ZERO = "0x0000000000000000000000000000000000000000"; + +async function backerAddresses(vault: Address, feeRecipient?: Address): Promise { + const out = new Set
(); + try { + const res = await fetch(`https://base.blockscout.com/api/v2/tokens/${vault}/transfers`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(9000), + }); + if (res.ok) { + const json = (await res.json()) as { items?: { to?: { hash?: string }; from?: { hash?: string } }[] }; + for (const t of json.items ?? []) { + for (const raw of [t.to?.hash, t.from?.hash]) { + if (raw && raw.toLowerCase() !== ZERO) { + try { out.add(getAddress(raw)); } catch { /* skip */ } + } + } + } + } + } catch { /* fall through */ } + if (feeRecipient) out.delete(getAddress(feeRecipient)); + return [...out]; +} + +async function getEthUsd(): Promise { + try { + const res = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd", { + next: { revalidate: 300 }, + }); + if (!res.ok) return 0; + const j = (await res.json()) as { ethereum?: { usd?: number } }; + return j.ethereum?.usd ?? 0; + } catch { + return 0; + } +} + +async function morBackersByRider(ethUsd: number): Promise> { + const walletToId = new Map(); + for (const r of RIDER_LIST) if (r.wallet) walletToId.set(r.wallet.toLowerCase(), r.id); + const referrers = [...walletToId.keys()].map((a) => getAddress(a)); + const byRider: Record = {}; + if (referrers.length === 0) return byRider; + + let latest: bigint; + try { latest = await ethClient.getBlockNumber(); } catch { return byRider; } + const WINDOW = BigInt(90_000); + const CHUNK = BigInt(45_000); + const from0 = latest > WINDOW ? latest - WINDOW : BigInt(0); + + const pools: Array<{ asset: "steth" | "usdc"; pool: Address; decimals: number }> = [ + { asset: "steth", pool: MORPHEUS_POOLS.stEth.pool, decimals: 18 }, + { asset: "usdc", pool: MORPHEUS_POOLS.usdc.pool, decimals: 6 }, + ]; + + const perPool = await Promise.all( + pools.map(async ({ asset, pool, decimals }) => { + // Parallelize the chunked log scan. + const ranges: Array<[bigint, bigint]> = []; + for (let s = from0; s <= latest; s += CHUNK + BigInt(1)) { + ranges.push([s, s + CHUNK > latest ? latest : s + CHUNK]); + } + const logsArr = await Promise.all( + ranges.map(([fromBlock, toBlock]) => + ethClient.getLogs({ + address: pool, event: userReferredEvent, + args: { rewardPoolIndex: MOR_REWARD_POOL_INDEX, referrer: referrers }, + fromBlock, toBlock, + }).catch(() => []), + ), + ); + const userToRef = new Map(); + for (const l of logsArr.flat()) { + const user = l.args.user as Address | undefined; + const ref = l.args.referrer as Address | undefined; + if (user && ref) userToRef.set(user.toLowerCase(), ref.toLowerCase()); + } + const users = [...userToRef.keys()]; + if (users.length === 0) return [] as Array<{ id: RiderId; backer: OrbitBacker }>; + + // One multicall for every referred user's position. + const uds = await ethClient.multicall({ + allowFailure: true, + contracts: users.map((u) => ({ + address: pool, abi: depositPoolAbi, functionName: "usersData", + args: [getAddress(u), MOR_REWARD_POOL_INDEX], + })), + }); + + const rows: Array<{ id: RiderId; backer: OrbitBacker }> = []; + users.forEach((u, i) => { + const id = walletToId.get(userToRef.get(u)!); + if (!id) return; + const ud = uds[i].result as unknown as readonly bigint[] | undefined; + const deposited = ud?.[1] ?? BigInt(0); + if (deposited <= BigInt(0)) return; + const tokens = Number(formatUnits(deposited, decimals)); + const amount = asset === "steth" ? tokens * ethUsd : tokens; + if (amount <= 0) return; + rows.push({ id, backer: { address: getAddress(u), amount, kind: "mor", asset } }); + }); + return rows; + }), + ); + + for (const { id, backer } of perPool.flat()) (byRider[id] ||= []).push(backer); + return byRider; +} + +export async function getStakeGraph(): Promise { + const live = RIDER_LIST.filter((r) => r.vault); + if (live.length === 0) return { athletes: [], total: 0, backerCount: 0, gnarsAccrued: 0 }; + + const ethUsd = await getEthUsd(); + const [athletes, mor] = await Promise.all([ + Promise.all( + live.map(async (r): Promise => { + const vault = r.vault as Address; + const candidates = await backerAddresses(vault, r.split); + + // Everything this vault needs in ONE aggregated call. + const contracts = [ + { address: vault, abi: vaultAbi, functionName: "totalAssets", args: [] }, + { address: vault, abi: vaultAbi, functionName: "totalSupply", args: [] }, + ...candidates.map((a) => ({ address: vault, abi: vaultAbi, functionName: "balanceOf", args: [a] })), + ...(r.split ? [{ address: vault, abi: vaultAbi, functionName: "balanceOf", args: [r.split as Address] }] : []), + ]; + const res = await baseClient.multicall({ + allowFailure: true, + contracts: contracts as Parameters[0]["contracts"], + }); + + const totalAssets = (res[0].result as bigint | undefined) ?? BigInt(0); + const totalSupply = (res[1].result as bigint | undefined) ?? BigInt(0); + const toAssets = (shares: bigint) => (totalSupply > BigInt(0) ? (shares * totalAssets) / totalSupply : BigInt(0)); + + const backers: OrbitBacker[] = []; + candidates.forEach((addr, i) => { + const shares = (res[2 + i].result as bigint | undefined) ?? BigInt(0); + if (shares <= BigInt(0)) return; + backers.push({ address: addr, amount: Number(formatUnits(toAssets(shares), 6)), kind: "vault" }); + }); + backers.sort((a, b) => b.amount - a.amount); + + let feeAccrued = 0; + if (r.split) { + const sShares = (res[2 + candidates.length].result as bigint | undefined) ?? BigInt(0); + feeAccrued = Number(formatUnits(toAssets(sShares), 6)); + } + + return { + id: r.id, handle: r.handle, vault, split: r.split, + total: Number(formatUnits(totalAssets, 6)), feeAccrued, backers, + }; + }), + ), + morBackersByRider(ethUsd), + ]); + + for (const a of athletes) { + const m = mor[a.id]; + if (m && m.length) { + a.backers.push(...m); + a.backers.sort((x, y) => y.amount - x.amount); + } + } + + const distinct = new Set(); + athletes.forEach((a) => a.backers.forEach((b) => distinct.add(b.address.toLowerCase()))); + return { + athletes, + total: athletes.reduce((s, a) => s + a.total, 0), + backerCount: distinct.size, + gnarsAccrued: athletes.reduce((s, a) => s + a.feeAccrued, 0) / 2, + }; +}