Skip to content
Merged
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
92 changes: 92 additions & 0 deletions lib/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,95 @@ export async function getTokenTVL(
return null;
}
}

// ---------------------------------------------------------------------------
// Batched multicall for multiple tokens (home page)
// ---------------------------------------------------------------------------

export interface BatchTokenEntry {
price: TokenPriceInfo | null;
tvl: { tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null;
}

/**
* Fetch price + TVL for multiple tokens in a single multicall RPC request.
* Returns a Map keyed by lowercase token address.
*
* Each token produces 3 calls: priceForNextMint, totalSupply, tokenBond.
*/
export async function getBatchTokenData(
tokenAddresses: Address[],
client?: typeof publicClient,
): Promise<Map<string, BatchTokenEntry>> {
const rpcClient = client ?? publicClient;
const result = new Map<string, BatchTokenEntry>();
if (tokenAddresses.length === 0) return result;

const calls = tokenAddresses.flatMap((token) => [
{
address: MCV2_BOND as Address,
abi: [priceForNextMintFunction],
functionName: "priceForNextMint" as const,
args: [token] as const,
},
{
address: token,
abi: erc20Abi,
functionName: "totalSupply" as const,
},
{
address: MCV2_BOND as Address,
abi: [tokenBondFunction],
functionName: "tokenBond" as const,
args: [token] as const,
},
]);

try {
const multicallResults = await rpcClient.multicall({
contracts: calls,
allowFailure: true,
});

// Parse results in groups of 3 per token
for (let i = 0; i < tokenAddresses.length; i++) {
const addr = tokenAddresses[i].toLowerCase();
const base = i * 3;
const priceResult = multicallResults[base];
const supplyResult = multicallResults[base + 1];
const bondResult = multicallResults[base + 2];

let price: TokenPriceInfo | null = null;
if (priceResult.status === "success" && supplyResult.status === "success") {
const priceRaw = priceResult.result as bigint;
const totalSupplyRaw = supplyResult.result as bigint;
price = {
pricePerToken: formatUnits(priceRaw, 18),
priceRaw,
totalSupply: formatUnits(totalSupplyRaw, 18),
totalSupplyRaw,
};
}

let tvl: BatchTokenEntry["tvl"] = null;
if (bondResult.status === "success") {
const bondData = bondResult.result as readonly unknown[];
const reserveToken = bondData[4] as Address;
const reserveBalance = bondData[5] as bigint;
// Default to 18 decimals (PL_TEST/WETH) — avoids extra RPC call
tvl = {
tvl: formatUnits(reserveBalance, 18),
tvlRaw: reserveBalance,
reserveToken,
decimals: 18,
};
}

result.set(addr, { price, tvl });
}
} catch {
// If multicall fails entirely, return empty map (callers fall back)
}

return result;
}
10 changes: 3 additions & 7 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createServerClient, type Storyline } from "../../lib/supabase";
import { STORY_FACTORY } from "../../lib/contracts/constants";
import { getTrendingStorylines } from "../../lib/ranking";
import { StoryCard } from "../components/StoryCard";
import { StoryGrid } from "../components/StoryGrid";
import { FilterBar, type WriterFilterValue } from "../components/FilterBar";
import { GENRES, LANGUAGES } from "../../lib/genres";
import Link from "next/link";
Expand Down Expand Up @@ -55,12 +55,8 @@ export default async function Home({
{/* Filter bar */}
<FilterBar writer={writer} genre={genre} lang={lang} tab={tab} />

{/* Story grid */}
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-3">
{storylines.map((s) => (
<StoryCard key={s.id} storyline={s} />
))}
</div>
{/* Story grid — batched multicall for price/TVL */}
<StoryGrid storylines={storylines} />

{/* Pagination */}
{(page > 1 || storylines.length === PAGE_SIZE) && (
Expand Down
41 changes: 41 additions & 0 deletions src/components/BatchTokenDataProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { createContext, useContext, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { type Address } from "viem";
import { getBatchTokenData, type BatchTokenEntry } from "../../lib/price";
import { browserClient } from "../../lib/rpc";

type BatchTokenDataMap = Map<string, BatchTokenEntry>;

const BatchTokenDataContext = createContext<BatchTokenDataMap>(new Map());

export function useBatchTokenData(tokenAddress: string): BatchTokenEntry | undefined {
const map = useContext(BatchTokenDataContext);
return map.get(tokenAddress.toLowerCase());
}

/**
* Fetches price + TVL for all provided token addresses in a single
* multicall RPC request and provides the data via context.
*/
export function BatchTokenDataProvider({
tokenAddresses,
children,
}: {
tokenAddresses: Address[];
children: ReactNode;
}) {
const { data } = useQuery({
queryKey: ["batch-token-data", tokenAddresses.join(",")],
queryFn: () => getBatchTokenData(tokenAddresses, browserClient),
staleTime: 60000,
enabled: tokenAddresses.length > 0,
});

return (
<BatchTokenDataContext.Provider value={data ?? new Map()}>
{children}
</BatchTokenDataContext.Provider>
);
}
10 changes: 8 additions & 2 deletions src/components/StoryCardStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { type Address } from "viem";
import { getTokenTVL, getTokenPrice } from "../../lib/price";
import { RESERVE_LABEL } from "../../lib/contracts/constants";
import { useBatchTokenData } from "./BatchTokenDataProvider";

function formatCompact(value: string): string {
const num = parseFloat(value);
Expand Down Expand Up @@ -45,16 +46,21 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) {
);
}

/** TVL-only display for home page book cards */
/** TVL-only display for home page book cards.
* Uses batch context when available (home page), falls back to individual fetch. */
export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) {
const batchEntry = useBatchTokenData(tokenAddress);
const addr = tokenAddress as Address;

const { data: tvlData } = useQuery({
// Fall back to individual fetch only if batch data not available
const { data: individualTvl } = useQuery({
queryKey: ["card-tvl", tokenAddress],
queryFn: () => getTokenTVL(addr),
staleTime: 60000,
enabled: !batchEntry,
});

const tvlData = batchEntry?.tvl ?? individualTvl;
const tvl = tvlData ? formatCompact(tvlData.tvl) : "—";

return (
Expand Down
27 changes: 27 additions & 0 deletions src/components/StoryGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { type Address } from "viem";
import { type Storyline } from "../../lib/supabase";
import { BatchTokenDataProvider } from "./BatchTokenDataProvider";
import { StoryCard } from "./StoryCard";

/**
* Story card grid wrapped in BatchTokenDataProvider.
* Fetches price + TVL for all visible stories in a single multicall
* instead of 4 individual RPC calls per card.
*/
export function StoryGrid({ storylines }: { storylines: Storyline[] }) {
const tokenAddresses = storylines
.map((s) => s.token_address)
.filter((addr): addr is string => !!addr) as Address[];

return (
<BatchTokenDataProvider tokenAddresses={tokenAddresses}>
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-3">
{storylines.map((s) => (
<StoryCard key={s.id} storyline={s} />
))}
</div>
</BatchTokenDataProvider>
);
}
Loading