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
58 changes: 58 additions & 0 deletions lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Singleton in-memory cache with TTL and in-flight request deduplication.
* Ported from mintpad's singleton-memory-cache pattern.
*/

interface CacheEntry {
value: unknown;
expires: number;
}

class MemoryCache {
private cache = new Map<string, CacheEntry>();
private inFlight = new Map<string, Promise<unknown>>();

async get<T>(key: string, fetcher: () => Promise<T>, ttlSeconds = 60): Promise<T> {
const normalizedKey = key.toLowerCase();

// Return cached if fresh
const cached = this.cache.get(normalizedKey);
if (cached && Date.now() < cached.expires) {
return cached.value as T;
}

// Dedup: if same key is already being fetched, await that promise
const pending = this.inFlight.get(normalizedKey);
if (pending) return pending as Promise<T>;

// Fetch, cache, and clean up
const promise = fetcher()
.then((value) => {
this.cache.set(normalizedKey, {
value,
expires: Date.now() + ttlSeconds * 1000,
});
this.inFlight.delete(normalizedKey);
return value;
})
.catch((err) => {
this.inFlight.delete(normalizedKey);
throw err;
});

this.inFlight.set(normalizedKey, promise);
return promise;
}

delete(key: string): void {
const normalizedKey = key.toLowerCase();
this.cache.delete(normalizedKey);
this.inFlight.delete(normalizedKey);
}

clear(): void {
this.cache.clear();
}
}

export const priceCache = new MemoryCache();
84 changes: 51 additions & 33 deletions lib/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
priceForNextMintFunction,
tokenBondFunction,
} from "./contracts/abi";
import { priceCache } from "./cache";

/**
* Minimal ABIs for price display.
Expand Down Expand Up @@ -146,7 +147,7 @@ export async function getTokenPrice(
client?: typeof publicClient,
): Promise<TokenPriceInfo | null> {
const rpc = client ?? publicClient;
try {
const fetcher = async () => {
const [priceRaw, totalSupplyRaw] = await Promise.all([
rpc.readContract({
address: MCV2_BOND,
Expand All @@ -166,7 +167,12 @@ export async function getTokenPrice(
priceRaw: BigInt(priceRaw),
totalSupply: formatUnits(totalSupplyRaw, 18),
totalSupplyRaw,
};
} satisfies TokenPriceInfo;
};

try {
if (client) return await fetcher();
return await priceCache.get(`price:${tokenAddress.toLowerCase()}`, fetcher, 60);
} catch {
return null;
}
Expand All @@ -186,7 +192,7 @@ export async function get24hPriceChange(
client?: typeof publicClient,
): Promise<{ changePercent: number; currentPrice: bigint; previousPrice: bigint } | null> {
const rpc = client ?? publicClient;
try {
const fetcher = async () => {
const currentBlock = await rpc.getBlockNumber();
const pastBlock = currentBlock - BLOCKS_PER_24H;

Expand Down Expand Up @@ -217,6 +223,11 @@ export async function get24hPriceChange(
Number(((current - previous) * BigInt(10000)) / previous) / 100;

return { changePercent, currentPrice: current, previousPrice: previous };
};

try {
if (client) return await fetcher();
return await priceCache.get(`24h:${tokenAddress.toLowerCase()}`, fetcher, 60);
} catch {
return null;
}
Expand All @@ -243,7 +254,7 @@ export async function getTokenTVL(
client?: typeof publicClient,
): Promise<{ tvl: string; tvlRaw: bigint; reserveToken: Address; decimals: number } | null> {
const rpc = client ?? publicClient;
try {
const fetcher = async () => {
const result = await rpc.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
Expand All @@ -266,6 +277,11 @@ export async function getTokenTVL(
reserveToken: reserveAddr,
decimals,
};
};

try {
if (client) return await fetcher();
return await priceCache.get(`tvl:${tokenAddress.toLowerCase()}`, fetcher, 60);
} catch {
return null;
}
Expand All @@ -290,37 +306,37 @@ export async function getBatchTokenData(
tokenAddresses: Address[],
client?: typeof publicClient,
): Promise<Map<string, BatchTokenEntry>> {
if (tokenAddresses.length === 0) return new Map();

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,
},
]);
const fetcher = async () => {
const result = new Map<string, BatchTokenEntry>();

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;
Expand All @@ -345,7 +361,6 @@ export async function getBatchTokenData(
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,
Expand All @@ -356,9 +371,12 @@ export async function getBatchTokenData(

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

return result;
return result;
};

if (client) return fetcher().catch(() => new Map());

const cacheKey = `batch:${tokenAddresses.map((a) => a.toLowerCase()).sort().join(",")}`;
return priceCache.get(cacheKey, fetcher, 60).catch(() => new Map());
}
29 changes: 23 additions & 6 deletions lib/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ const PUBLIC_RPC_ENDPOINTS = [
"https://base.drpc.org",
"https://base.llamarpc.com",
"https://base.meowrpc.com",
"https://developer-access-mainnet.base.org",
"https://base-mainnet.public.blastapi.io",
"https://1rpc.io/base",
"https://base.gateway.tenderly.co",
"https://rpc.notadegen.com/base",
"https://base.blockpi.network/v1/rpc/public",
"https://developer-access-mainnet.base.org",
"https://base.api.onfinality.io/public",
];

export const RPC_ENDPOINTS = CUSTOM_RPC_URL
Expand All @@ -29,11 +33,18 @@ export const RPC_ENDPOINTS = CUSTOM_RPC_URL

/** Client-side CORS-enabled RPC endpoints for wagmi/browser. */
const PUBLIC_CORS_ENDPOINTS = [
"https://mainnet.base.org",
"https://base-rpc.publicnode.com",
"https://mainnet.base.org",
"https://base.drpc.org",
"https://base.llamarpc.com",
"https://base.meowrpc.com",
"https://base-mainnet.public.blastapi.io",
"https://1rpc.io/base",
"https://base.gateway.tenderly.co",
"https://rpc.notadegen.com/base",
"https://base.blockpi.network/v1/rpc/public",
"https://developer-access-mainnet.base.org",
"https://base.api.onfinality.io/public",
];

export const CORS_RPC_ENDPOINTS = CUSTOM_RPC_URL
Expand All @@ -49,7 +60,7 @@ function buildServerTransport() {
return CUSTOM_RPC_URL ? fallback([http(CUSTOM_RPC_URL), http()]) : http();
}
return fallback(
RPC_ENDPOINTS.map((url) => http(url, { timeout: 10_000, retryCount: 1 })),
RPC_ENDPOINTS.map((url) => http(url, { timeout: 2_000, retryCount: 0, batch: true })),
{ rank: false },
);
}
Expand Down Expand Up @@ -77,8 +88,9 @@ export const browserClient = createPublicClient({
? fallback(
CORS_RPC_ENDPOINTS.map((url) =>
http(url, {
timeout: 5_000,
timeout: 2_000,
retryCount: 0,
batch: true,
fetchOptions: { mode: "cors", credentials: "omit" },
}),
),
Expand All @@ -104,8 +116,9 @@ export function createFallbackTransport() {
return fallback(
CORS_RPC_ENDPOINTS.map((url) =>
http(url, {
timeout: 5_000,
timeout: 2_000,
retryCount: 0,
batch: true,
fetchOptions: { mode: "cors", credentials: "omit" },
}),
),
Expand All @@ -126,7 +139,11 @@ function getRpcDisplayName(url: string): string {
if (url.includes("meowrpc.com")) return "MeowRPC";
if (url.includes("1rpc.io")) return "1RPC";
if (url.includes("blastapi.io")) return "BlastAPI";
if (url.includes("tenderly.co")) return "Tenderly";
if (url.includes("notadegen.com")) return "NotADegen";
if (url.includes("blockpi.network")) return "BlockPI";
if (url.includes("developer-access")) return "Base Dev";
if (url.includes("onfinality.io")) return "OnFinality";
return "RPC";
}

Expand All @@ -149,7 +166,7 @@ export async function withServerRpcFallback<T>(
try {
const client = createPublicClient({
chain,
transport: http(url, { timeout: 10_000, retryCount: 0 }),
transport: http(url, { timeout: 2_000, retryCount: 0 }),
}) as PublicClient;
const result = await operation(client);
if (i > 0) console.log(`${prefix} Success with ${name} (attempt ${i + 1})`);
Expand Down
Loading