diff --git a/AGENTS.md b/AGENTS.md index dcb1a49..2b977e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,34 @@ Keep this file for knowledge useful to almost every future agent session in this Do not repeat what the codebase already shows; point to the authoritative file or command instead. Prefer rewriting or pruning existing entries over appending new ones. When updating this file, preserve this bar for all agents and keep entries concise. + +## Deploy + verification conventions + +- After every prod merge, fast-forward the testnet deployment branch: + `git push origin origin/main:testnet`. Both Vercel prods build from their branch. +- `NEXT_PUBLIC_*` env vars bake at build time — changing one on Vercel does nothing + until a redeploy (bit us in the 2026-08-19 Helius key outage). +- Contract-touching changes can be verified without a funded wallet or foundry: + viem `simulateBlocks` (eth_simulateV1) against `https://sepolia.base.org`, using a + balance stateOverride + TestUSDC `faucet()` → `approve` → create. Put heavy calls + in separate simulated blocks — several multi-million-gas calls in one block hit the + shared block-gas ceiling and revert positionally, which looks like a contract limit + but isn't. +- Sablier Lockup Tranched facts (probed on-chain, not docs): tranche cap is exactly + 500; tranche amounts must sum exactly to the post-broker-fee net deposit or the + create reverts — always go through `computeTranches`/`sablierNetDeposit` in + `packages/app/src/lib/schedule.ts`. + +## RPC transports + vault discovery (EVM app) + +- Never let a chain fall back to viem's built-in default RPC. Ethereum's + (`eth.merkle.io`) rejects CORS preflight, and RainbowKit runs mainnet ENS + lookups on every page whenever chain 1 is configured — that was the console + CORS storm on ripguard.xyz. Each chain's RPC list lives in + `packages/app/src/config/chains.ts` (`rpcUrls`, each checked for a permissive + preflight); `NEXT_PUBLIC_RPC_URLS` prepends a keyed provider per chain. +- Never rediscover streams by scanning Transfer logs. Public RPCs cap + `eth_getLogs` at 1k–10k blocks (BSC: 1,000), so the scan is tens of thousands + of calls. Discovery is the Sablier Envio indexer — one query for all + deployment chains, since it allows 250 req/min per IP — with a fallback to + stream IDs the device already knows (`packages/app/src/lib/vaults.ts`). diff --git a/README.md b/README.md index 1b8cda7..1758bbe 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Key env vars in `packages/app/.env.local`: - `NEXT_PUBLIC_USDC_ADDRESS` — override for testnet address - `NEXT_PUBLIC_TREASURY_ADDRESS` — broker fee recipient. Set to zero address to disable the fee (useful on testnet). - `NEXT_PUBLIC_WC_PROJECT_ID` — WalletConnect project ID +- `NEXT_PUBLIC_RPC_URLS` — optional JSON object of keyed RPC URLs by chainId (e.g. `{"8453":"https://..."}`). Prepended to the vetted public RPC list in `packages/app/src/config/chains.ts`. ```bash pnpm --filter app test # vitest diff --git a/packages/app/.env.example b/packages/app/.env.example index 9f397ad..c110230 100644 --- a/packages/app/.env.example +++ b/packages/app/.env.example @@ -7,6 +7,11 @@ NEXT_PUBLIC_WC_PROJECT_ID=317afde7ae23278927448b7ab4213a17 # RipGuard Audit Fund treasury address (broker fee recipient) NEXT_PUBLIC_TREASURY_ADDRESS=0x847F640bE052b0700C31F72Dce622F4C6286934E +# Optional keyed RPC per chain (Alchemy, dRPC, ...) — JSON object keyed by chainId. +# Prepended to the vetted public list in src/config/chains.ts, which stays as fallback. +# Bakes at build time: changing it on Vercel needs a redeploy. +# NEXT_PUBLIC_RPC_URLS={"8453":"https://base-mainnet.g.alchemy.com/v2/KEY","1":"https://eth-mainnet.g.alchemy.com/v2/KEY"} + # --- Mainnet (production — ripguard.xyz) --- # These are the defaults if not set: # NEXT_PUBLIC_CHAIN=base diff --git a/packages/app/src/app/vaults/page.tsx b/packages/app/src/app/vaults/page.tsx index a3fa17e..1e22620 100644 --- a/packages/app/src/app/vaults/page.tsx +++ b/packages/app/src/app/vaults/page.tsx @@ -4,7 +4,7 @@ import { ConnectButton } from "@rainbow-me/rainbowkit"; import Link from "next/link"; import { Header } from "@/components/Header"; import { useState, useEffect, useCallback } from "react"; -import { parseAbiItem, type Address, type PublicClient } from "viem"; +import { type Address } from "viem"; import { useAccount, useChainId, @@ -14,7 +14,7 @@ import { useWriteContract, useWaitForTransactionReceipt, } from "wagmi"; -import { CHAINS, getChainConfig, isSupportedDeploymentChain, DEFAULT_CHAIN_ID, type ChainConfig } from "@/config/chains"; +import { DEPLOYMENT_CHAINS, getChainConfig, isSupportedDeploymentChain, DEFAULT_CHAIN_ID } from "@/config/chains"; import { IS_TESTNET } from "@/config/contracts"; import { sablierLockupAbi } from "@/config/abis"; import { WrongChainPanel } from "@/components/WrongChainPanel"; @@ -24,19 +24,15 @@ import { useToast } from "@/components/Toast"; import { trackClaim, trackContractError } from "@/lib/analytics"; import { formatTokenAmount } from "@/lib/format"; import { isUserRejection, extractErrorReason } from "@/lib/errors"; - -// Sablier Envio indexer — single endpoint, no API key, supports all chains. -// Per-chain selection happens via the `chainId` filter in the GraphQL query. -const SABLIER_SUBGRAPH = "https://indexer.hyperindex.xyz/53b7e25/v1/graphql"; - -type SubgraphStream = { - tokenId: string; - depositAmount: string; - withdrawnAmount: string; - startTime: string; - endTime: string; - cliffTime: string | null; -}; +import { + fetchIndexedStreamsWithRetry, + getScheduleType, + knownStreamIds, + readStreamsOnChain, + rememberStreamIds, + splitByChain, + type StreamRecord, +} from "@/lib/vaults"; type Tranche = { amount: bigint; timestamp: number }; @@ -67,17 +63,6 @@ function formatCountdown(seconds: number): string { return `${sec}s`; } -function getScheduleType( - cliffSeconds: number, - totalSeconds: number, - isTranched = false, -): string { - if (isTranched) return "Strict Payouts"; - if (cliffSeconds === totalSeconds && cliffSeconds > 0) return "One Drop"; - if (cliffSeconds > 0) return "Wait, then reloads"; - return "Steady reloads"; -} - // Prefer the preset label the user picked on /create (stored at lock time). // Falls back to the generic schedule-type label when no preset is remembered. // chainId is part of the key because Sablier stream IDs reset per chain — @@ -351,105 +336,6 @@ function VaultSkeleton() { ); } -// ERC-721 Transfer event for stream discovery fallback -const transferEvent = parseAbiItem( - "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)" -); - -async function fetchFromSubgraph( - address: Address, - chainId: number, - sablierAddress: Address -): Promise { - const query = `{ - LockupStream( - where: { - recipient: { _eq: "${address.toLowerCase()}" } - chainId: { _eq: "${chainId.toString()}" } - contract: { _eq: "${sablierAddress.toLowerCase()}" } - } - order_by: { startTime: desc } - ) { - tokenId - depositAmount - withdrawnAmount - startTime - endTime - cliffTime - } - }`; - - const res = await fetch(SABLIER_SUBGRAPH, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - }); - - if (!res.ok) throw new Error(`Subgraph returned ${res.status}`); - const json = await res.json(); - if (json.errors) throw new Error(json.errors[0]?.message || "Subgraph query failed"); - return json.data?.LockupStream ?? []; -} - -async function fetchFromChain( - publicClient: PublicClient, - address: Address, - chainConfig: ChainConfig, -): Promise { - const { sablierLockup, streamStartBlock, logChunkSize } = chainConfig; - const toBlock = await publicClient.getBlockNumber(); - const allTokenIds: bigint[] = []; - let from = streamStartBlock; - - while (from <= toBlock) { - const to = from + logChunkSize > toBlock ? toBlock : from + logChunkSize; - const chunk = await publicClient.getLogs({ - address: sablierLockup, - event: transferEvent, - args: { from: "0x0000000000000000000000000000000000000000", to: address }, - fromBlock: from, - toBlock: to, - }); - for (const log of chunk) { - if (log.args.tokenId != null) allTokenIds.push(log.args.tokenId); - } - from = to + BigInt(1); - } - - if (allTokenIds.length === 0) return []; - - const ids = allTokenIds; - - // Multicall: 5 reads per stream (startTime, endTime, cliffTime, deposited, withdrawn) - const calls = ids.flatMap((id) => [ - { address: sablierLockup, abi: sablierLockupAbi, functionName: "getStartTime" as const, args: [id] as const }, - { address: sablierLockup, abi: sablierLockupAbi, functionName: "getEndTime" as const, args: [id] as const }, - { address: sablierLockup, abi: sablierLockupAbi, functionName: "getCliffTime" as const, args: [id] as const }, - { address: sablierLockup, abi: sablierLockupAbi, functionName: "getDepositedAmount" as const, args: [id] as const }, - { address: sablierLockup, abi: sablierLockupAbi, functionName: "getWithdrawnAmount" as const, args: [id] as const }, - ]); - - const results = await publicClient.multicall({ contracts: calls }); - - return ids.map((id, i) => { - const base = i * 5; - const startTime = results[base].result as number; - const endTime = results[base + 1].result as number; - const cliffTime = results[base + 2].result as number; - const deposited = results[base + 3].result as bigint; - const withdrawn = results[base + 4].result as bigint; - - return { - tokenId: id.toString(), - depositAmount: deposited.toString(), - withdrawnAmount: withdrawn.toString(), - startTime: startTime.toString(), - endTime: endTime.toString(), - cliffTime: cliffTime > 0 ? cliffTime.toString() : null, - }; - }); -} - function VaultDashboard() { const { address, isConnected } = useAccount(); const chainId = useChainId(); @@ -464,21 +350,25 @@ function VaultDashboard() { const publicClient = usePublicClient(); const { toast } = useToast(); - const [subgraphStreams, setSubgraphStreams] = useState([]); + const [streams, setStreams] = useState([]); const [isLoadingEvents, setIsLoadingEvents] = useState(false); const [fetchError, setFetchError] = useState(null); const [claimingId, setClaimingId] = useState(null); const [fetchKey, setFetchKey] = useState(0); - const [fetchSource, setFetchSource] = useState<"subgraph" | "onchain" | null>(null); - // Other-chain probe — when the current chain returns 0 streams, fan - // out to the rest of the deployment's chains so we can tell users - // exactly where their vaults actually live. The "vault vanished" - // support hits we've seen are almost all wallet-on-wrong-chain. + const [fetchSource, setFetchSource] = useState<"indexer" | "onchain" | null>(null); + // Vault counts on the deployment's other chains, from the same indexer + // response. The "vault vanished" support hits we've seen are almost all + // wallet-on-wrong-chain, so when the current chain is empty we can say + // exactly where the locks live. const [otherChainStreams, setOtherChainStreams] = useState< Array<{ chainId: number; chainName: string; count: number }> >([]); - // Fetch streams: subgraph first, on-chain fallback + // Discovery: one indexer query covering every deployment chain (the + // indexer rate-limits per IP, so a per-chain fan-out is the wrong shape). + // If it is down or throttled past our retries, fall back to the stream IDs + // this device already knows and read their state straight from the chain. + // See lib/vaults.ts for why scanning Transfer logs is not the fallback. useEffect(() => { if (!isConnected || !address) return; @@ -486,33 +376,41 @@ function VaultDashboard() { setIsLoadingEvents(true); setFetchError(null); setFetchSource(null); + const storage = typeof window !== "undefined" ? window.localStorage : null; (async () => { try { - const streams = await fetchFromSubgraph(address, chainId, sablierLockup); - if (!cancelled) { - setSubgraphStreams(streams); - setFetchSource("subgraph"); - } - } catch (subgraphErr) { - // Subgraph unavailable — fall back to on-chain - trackContractError({ action: "fetchVaults", error: String(subgraphErr), contract: "SablierLockup" }); - - if (!publicClient) { - if (!cancelled) setFetchError("Wallet not connected. Please reconnect and try again."); + const all = await fetchIndexedStreamsWithRetry(address, DEPLOYMENT_CHAINS); + const { current, elsewhere } = splitByChain(all, chainId); + rememberStreamIds(storage, chainId, address, current.map((s) => s.tokenId)); + if (cancelled) return; + setStreams(current); + setOtherChainStreams( + elsewhere.map((c) => ({ ...c, chainName: getChainConfig(c.chainId).name })), + ); + setFetchSource("indexer"); + } catch (indexerErr) { + trackContractError({ action: "fetchVaults", error: String(indexerErr), contract: "SablierLockup" }); + + const ids = knownStreamIds(storage, chainId, address); + if (!publicClient || ids.length === 0) { + if (!cancelled) { + setFetchError("The vault indexer is busy right now. Try again in a moment."); + } return; } try { - const streams = await fetchFromChain(publicClient, address, chainConfig); + const known = await readStreamsOnChain(publicClient, chainConfig, address, ids); if (!cancelled) { - setSubgraphStreams(streams); + setStreams(known); + setOtherChainStreams([]); setFetchSource("onchain"); } } catch (chainErr) { trackContractError({ action: "fetchVaults:onchain", error: String(chainErr), contract: "SablierLockup" }); if (!cancelled) { - setFetchError("Failed to load vaults. Try again in a moment."); + setFetchError("Couldn't read your vaults from the chain. Try again in a moment."); } } } finally { @@ -521,64 +419,14 @@ function VaultDashboard() { })(); return () => { cancelled = true; }; - }, [address, isConnected, publicClient, fetchKey, chainId, sablierLockup, chainConfig]); + }, [address, isConnected, publicClient, fetchKey, chainId, chainConfig]); const retryFetch = useCallback(() => setFetchKey((k) => k + 1), []); - // Cross-chain probe: when the current chain comes back empty, hit - // every other deployment chain's subgraph in parallel and surface - // the counts. Cheap (one subgraph query per chain), and tells users - // exactly where their vaults live when the wallet is on the wrong - // chain. Subgraph-only by design — chain-scan fallback would be - // catastrophic to fan out across 6 chains on mobile. - useEffect(() => { - if ( - !isConnected || - !address || - isLoadingEvents || - fetchError || - subgraphStreams.length > 0 - ) { - setOtherChainStreams([]); - return; - } - let cancelled = false; - const otherChains = Object.values(CHAINS).filter( - (c) => c.isTestnet === IS_TESTNET && c.chainId !== chainId, - ); - Promise.all( - otherChains.map(async (c) => { - try { - const streams = await fetchFromSubgraph( - address, - c.chainId, - c.sablierLockup, - ); - return { chainId: c.chainId, chainName: c.name, count: streams.length }; - } catch { - return { chainId: c.chainId, chainName: c.name, count: 0 }; - } - }), - ).then((results) => { - if (cancelled) return; - setOtherChainStreams(results.filter((r) => r.count > 0)); - }); - return () => { - cancelled = true; - }; - }, [ - address, - chainId, - fetchError, - isConnected, - isLoadingEvents, - subgraphStreams.length, - ]); - // Clear vault state when wallet disconnects useEffect(() => { if (!isConnected) { - setSubgraphStreams([]); + setStreams([]); setFetchError(null); setClaimingId(null); setOtherChainStreams([]); @@ -586,7 +434,7 @@ function VaultDashboard() { }, [isConnected]); // Only need on-chain call for live claimable amount (1 call per stream) - const streamIds = subgraphStreams.map((s) => BigInt(s.tokenId)); + const streamIds = streams.map((s) => s.tokenId); const claimableContracts = streamIds.map((id) => ({ address: sablierLockup, abi: sablierLockupAbi, @@ -623,13 +471,9 @@ function VaultDashboard() { // Build vault data from subgraph + on-chain claimable let failedStreamCount = 0; - const vaults: VaultData[] = subgraphStreams + const vaults: VaultData[] = streams .map((stream, i) => { - const startTime = Number(stream.startTime); - const endTime = Number(stream.endTime); - const cliffTime = stream.cliffTime ? Number(stream.cliffTime) : 0; - const deposited = BigInt(stream.depositAmount); - const withdrawn = BigInt(stream.withdrawnAmount); + const { startTime, endTime, cliffTime, deposited, withdrawn } = stream; const claimableResult = claimableResults?.[i]; const claimable = claimableResult?.status === "success" @@ -652,7 +496,7 @@ function VaultDashboard() { const cliffSeconds = cliffTime > 0 ? cliffTime - startTime : 0; return { - streamId: BigInt(stream.tokenId), + streamId: stream.tokenId, totalAmount: deposited, cliffSeconds, totalSeconds, @@ -771,7 +615,7 @@ function VaultDashboard() {
Connection error

- Couldn't reach the chain. + Couldn't load your vaults.

{fetchError} Your vaults are unaffected. They live in Sablier. @@ -871,15 +715,19 @@ function VaultDashboard() { ); } - // Aggregate stats (only compute when we have multiple vaults) + // Aggregate stats (only when there are multiple vaults). "Still locked" is + // what has not unlocked yet — deposits minus claimed minus claimable — so it + // never repeats a single card's "Total locked" figure or counts a fully + // claimed test lock as money still in a vault. Both misreads came up in + // support as "it merged my vaults" / "the balance is off". const totals = vaults.length >= 2 ? vaults.reduce( (acc, v) => ({ - locked: acc.locked + v.deposited, + stillLocked: acc.stillLocked + (v.deposited - v.withdrawn - v.claimable), claimable: acc.claimable + v.claimable, claimed: acc.claimed + v.withdrawn, }), - { locked: BigInt(0), claimable: BigInt(0), claimed: BigInt(0) } + { stillLocked: BigInt(0), claimable: BigInt(0), claimed: BigInt(0) } ) : null; @@ -891,7 +739,10 @@ function VaultDashboard() {

)} {totals && ( -
+
+
+ Across {vaults.length} vaults on {chainConfig.name} +
    {/* Primary: the actionable number */}
  • @@ -905,9 +756,9 @@ function VaultDashboard() { {/* Context: what's behind it */}
  • - {formatTokenAmount(totals.locked, usdcDecimals)} + {formatTokenAmount(totals.stillLocked, usdcDecimals)} - Total locked + Still locked
  • @@ -921,7 +772,12 @@ function VaultDashboard() { {fetchSource === "onchain" && (
    - Loaded from on-chain (indexer unavailable) + + Indexer unavailable. Showing the locks this device knows about.{" "} + +
    )} {failedStreamCount > 0 && ( diff --git a/packages/app/src/config/abis.ts b/packages/app/src/config/abis.ts index 1103e8d..b6b7c26 100644 --- a/packages/app/src/config/abis.ts +++ b/packages/app/src/config/abis.ts @@ -82,6 +82,16 @@ export const sablierLockupAbi = [ outputs: [{ name: "withdrawableAmount", type: "uint128" }], stateMutability: "view", }, + // ERC-721 owner lookup. The stream NFT's owner is the recipient who can + // withdraw; /vaults uses it to confirm a device-remembered stream ID still + // belongs to the connected wallet before rendering it. + { + type: "function", + name: "ownerOf", + inputs: [{ name: "tokenId", type: "uint256" }], + outputs: [{ name: "owner", type: "address" }], + stateMutability: "view", + }, { type: "function", name: "withdrawMax", diff --git a/packages/app/src/config/chains.test.ts b/packages/app/src/config/chains.test.ts index 414ddd3..b724cbc 100644 --- a/packages/app/src/config/chains.test.ts +++ b/packages/app/src/config/chains.test.ts @@ -1,11 +1,13 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { CHAINS, DEFAULT_CHAIN_ID, + DEPLOYMENT_CHAINS, SUPPORTED_CHAIN_IDS, getChainConfig, isSupportedChain, isSupportedDeploymentChain, + parseRpcOverrides, } from "./chains"; describe("chain registry", () => { @@ -40,8 +42,10 @@ describe("chain registry", () => { expect(chain.usdc, `${chain.name} usdc`).toMatch(/^0x[a-fA-F0-9]{40}$/); expect(chain.treasury, `${chain.name} treasury`).toMatch(/^0x[a-fA-F0-9]{40}$/); expect(chain.usdcDecimals, `${chain.name} usdcDecimals`).toBeGreaterThan(0); - expect(chain.streamStartBlock > BigInt(0), `${chain.name} streamStartBlock`).toBe(true); - expect(chain.logChunkSize > BigInt(0), `${chain.name} logChunkSize`).toBe(true); + expect(chain.rpcUrls.length, `${chain.name} rpcUrls`).toBeGreaterThan(0); + for (const url of chain.rpcUrls) { + expect(url, `${chain.name} rpcUrl`).toMatch(/^https:\/\//); + } expect(chain.explorerUrl, `${chain.name} explorerUrl`).toMatch(/^https:\/\//); } }); @@ -103,3 +107,43 @@ describe("chain registry", () => { } }); }); + +describe("DEPLOYMENT_CHAINS", () => { + it("only contains chains matching the default chain's testnet flag", () => { + const flag = getChainConfig(DEFAULT_CHAIN_ID).isTestnet; + expect(DEPLOYMENT_CHAINS.length).toBeGreaterThan(0); + for (const chain of DEPLOYMENT_CHAINS) { + expect(chain.isTestnet, chain.name).toBe(flag); + } + expect(DEPLOYMENT_CHAINS.some((c) => c.chainId === DEFAULT_CHAIN_ID)).toBe(true); + }); +}); + +describe("parseRpcOverrides", () => { + it("returns no overrides for an unset variable", () => { + expect(parseRpcOverrides(undefined)).toEqual({}); + expect(parseRpcOverrides("")).toEqual({}); + }); + + it("parses a chainId-keyed object of https URLs", () => { + expect( + parseRpcOverrides('{"8453":"https://base.example/v2/key","1":"https://eth.example"}'), + ).toEqual({ 8453: "https://base.example/v2/key", 1: "https://eth.example" }); + }); + + it("degrades to public defaults on malformed input instead of throwing", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(parseRpcOverrides("{not json")).toEqual({}); + expect(parseRpcOverrides('["https://a"]')).toEqual({}); + expect(parseRpcOverrides('"https://a"')).toEqual({}); + // Bad entries are dropped individually; good ones survive. + expect( + parseRpcOverrides('{"base":"https://a","8453":"http://insecure","1":"https://ok"}'), + ).toEqual({ 1: "https://ok" }); + expect(error).toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/packages/app/src/config/chains.ts b/packages/app/src/config/chains.ts index aff8833..386dcc9 100644 --- a/packages/app/src/config/chains.ts +++ b/packages/app/src/config/chains.ts @@ -13,8 +13,13 @@ export type ChainConfig = { // "View on BaseScan" reads stronger than a generic "View on the explorer" // and is correct per chain (Etherscan, Arbiscan, etc.). explorerName: string; - streamStartBlock: bigint; - logChunkSize: bigint; + // Ordered JSON-RPC endpoints for wagmi's public client: first is primary, + // the rest are fallbacks. Never leave a chain on viem's built-in default — + // those are unvetted third parties, and Ethereum's (eth.merkle.io) started + // rejecting CORS preflight, which broke ENS and every mainnet read in the + // browser. Each URL below was checked for a permissive preflight from + // ripguard.xyz; re-check before adding one. + rpcUrls: readonly string[]; isTestnet: boolean; // Optional disclosure shown in /create and /vaults when this chain is // active. Used today to flag that BNB Chain "USDC" is Binance-Peg, not @@ -39,8 +44,11 @@ const ETHEREUM_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://etherscan.io", explorerName: "Etherscan", - streamStartBlock: BigInt(21_717_452), // Sablier Lockup v2.0 deployment block - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://ethereum-rpc.publicnode.com", + "https://cloudflare-eth.com", + "https://eth.drpc.org", + ], isTestnet: false, }; @@ -54,8 +62,11 @@ const BASE_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://basescan.org", explorerName: "BaseScan", - streamStartBlock: BigInt(22_000_000), - logChunkSize: BigInt(50_000), + rpcUrls: [ + "https://mainnet.base.org", + "https://base-rpc.publicnode.com", + "https://base.drpc.org", + ], isTestnet: false, }; @@ -69,8 +80,11 @@ const ARBITRUM_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://arbiscan.io", explorerName: "Arbiscan", - streamStartBlock: BigInt(299_856_278), - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://arb1.arbitrum.io/rpc", + "https://arbitrum-one-rpc.publicnode.com", + "https://arbitrum.drpc.org", + ], isTestnet: false, }; @@ -84,8 +98,11 @@ const OPTIMISM_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://optimistic.etherscan.io", explorerName: "Optimistic Etherscan", - streamStartBlock: BigInt(131_196_856), - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://mainnet.optimism.io", + "https://optimism-rpc.publicnode.com", + "https://optimism.drpc.org", + ], isTestnet: false, }; @@ -99,8 +116,10 @@ const POLYGON_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://polygonscan.com", explorerName: "PolygonScan", - streamStartBlock: BigInt(67_212_728), - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://polygon-bor-rpc.publicnode.com", + "https://polygon.drpc.org", + ], isTestnet: false, }; @@ -114,8 +133,10 @@ const AVALANCHE_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://snowtrace.io", explorerName: "Snowtrace", - streamStartBlock: BigInt(56_433_739), - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://api.avax.network/ext/bc/C/rpc", + "https://avalanche-c-chain-rpc.publicnode.com", + ], isTestnet: false, }; @@ -133,8 +154,11 @@ const BSC_DEFAULT: ChainConfig = { treasury: TREASURY_EOA, explorerUrl: "https://bscscan.com", explorerName: "BscScan", - streamStartBlock: BigInt(46_137_048), - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://bsc-dataseed.bnbchain.org", + "https://bsc-rpc.publicnode.com", + "https://56.rpc.thirdweb.com", + ], isTestnet: false, usdcNote: "On BNB Chain, USDC is Binance-Peg (custodied by Binance, 18 decimals).", }; @@ -149,8 +173,10 @@ const BASE_SEPOLIA_DEFAULT: ChainConfig = { treasury: ZERO_ADDRESS, explorerUrl: "https://sepolia.basescan.org", explorerName: "Base Sepolia BaseScan", - streamStartBlock: BigInt(38_540_000), - logChunkSize: BigInt(10_000), + rpcUrls: [ + "https://sepolia.base.org", + "https://base-sepolia-rpc.publicnode.com", + ], isTestnet: true, }; @@ -159,12 +185,56 @@ export const DEFAULT_CHAIN_ID: number = ? BASE_SEPOLIA_DEFAULT.chainId : BASE_DEFAULT.chainId; -// Env overrides only apply to the current deployment's default chain. -// Kept for testnet staging flexibility; new chains should put addresses in code. +// Optional keyed RPC per chain (Alchemy, dRPC, …) with a referrer allowlist — +// the robust choice for production traffic. JSON object keyed by chainId: +// NEXT_PUBLIC_RPC_URLS='{"8453":"https://base-mainnet.g.alchemy.com/v2/KEY"}' +// The override is prepended to the chain's public list, so the public +// endpoints stay as fallbacks. NEXT_PUBLIC_* bakes at build time — changing +// it on Vercel does nothing until a redeploy. +// +// Bad input degrades to the public defaults with a logged error rather than +// throwing: this runs in every browser session, and a config typo must not +// take the whole app down. +export function parseRpcOverrides(raw: string | undefined): Record { + if (!raw) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error("[RipGuard] NEXT_PUBLIC_RPC_URLS is not valid JSON — using public RPCs"); + return {}; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + console.error("[RipGuard] NEXT_PUBLIC_RPC_URLS must be an object keyed by chainId — using public RPCs"); + return {}; + } + const overrides: Record = {}; + for (const [key, url] of Object.entries(parsed)) { + const chainId = Number(key); + if (!Number.isInteger(chainId) || typeof url !== "string" || !url.startsWith("https://")) { + console.error( + `[RipGuard] NEXT_PUBLIC_RPC_URLS entry "${key}" ignored — key must be a chainId, value an https URL`, + ); + continue; + } + overrides[chainId] = url; + } + return overrides; +} + +const RPC_OVERRIDES = parseRpcOverrides(process.env.NEXT_PUBLIC_RPC_URLS); + +// RPC overrides apply to any chain. Address overrides only apply to the +// current deployment's default chain — kept for testnet staging flexibility; +// new chains should put addresses in code. function withEnvOverrides(cfg: ChainConfig): ChainConfig { - if (cfg.chainId !== DEFAULT_CHAIN_ID) return cfg; + const rpcOverride = RPC_OVERRIDES[cfg.chainId]; + const withRpc: ChainConfig = rpcOverride + ? { ...cfg, rpcUrls: [rpcOverride, ...cfg.rpcUrls] } + : cfg; + if (cfg.chainId !== DEFAULT_CHAIN_ID) return withRpc; return { - ...cfg, + ...withRpc, sablierLockup: (process.env.NEXT_PUBLIC_SABLIER_LOCKUP as Address) || cfg.sablierLockup, usdc: (process.env.NEXT_PUBLIC_USDC_ADDRESS as Address) || cfg.usdc, treasury: (process.env.NEXT_PUBLIC_TREASURY_ADDRESS as Address) || cfg.treasury, @@ -184,6 +254,14 @@ export const CHAINS: Record = { export const SUPPORTED_CHAIN_IDS = Object.keys(CHAINS).map(Number); +// Chains this deployment can actually transact on: mainnets on ripguard.xyz, +// testnets on testnet.ripguard.xyz. The registry holds both, so anything +// user-facing (wallet picker, cross-chain lookups) should iterate this list, +// not CHAINS. +export const DEPLOYMENT_CHAINS: readonly ChainConfig[] = Object.values(CHAINS).filter( + (c) => c.isTestnet === CHAINS[DEFAULT_CHAIN_ID].isTestnet, +); + export function getChainConfig(chainId: number): ChainConfig { const cfg = CHAINS[chainId]; if (!cfg) { diff --git a/packages/app/src/config/wagmi.ts b/packages/app/src/config/wagmi.ts index e5b63b9..7fb389c 100644 --- a/packages/app/src/config/wagmi.ts +++ b/packages/app/src/config/wagmi.ts @@ -2,7 +2,9 @@ import { getDefaultConfig, getDefaultWallets } from "@rainbow-me/rainbowkit"; import { phantomWallet } from "@rainbow-me/rainbowkit/wallets"; import * as wagmiChains from "wagmi/chains"; import { type Chain } from "wagmi/chains"; -import { CHAINS } from "./chains"; +import { fallback, http, type Transport } from "viem"; +import { CHAINS, DEPLOYMENT_CHAINS } from "./chains"; +import { IS_TESTNET } from "./contracts"; // Map a registry chainId to its wagmi chain definition. Add an entry here // when adding a chain to the registry so wagmi can pick it up. @@ -17,23 +19,30 @@ const chainIdToWagmiChain: Record = { [wagmiChains.baseSepolia.id]: wagmiChains.baseSepolia, }; -const isTestnet = process.env.NEXT_PUBLIC_CHAIN === "base-sepolia"; - -// Derive wagmi's chain list from the registry, filtered to chains matching -// the deployment's testnet flag. Registry ⇒ wagmi is one-way: adding a -// chain to chains.ts (with a corresponding wagmi mapping above) auto-expands -// the wallet picker. -const supportedChains = Object.values(CHAINS) - .filter((c) => c.isTestnet === isTestnet) +// Derive wagmi's chain list from the registry. Registry ⇒ wagmi is one-way: +// adding a chain to chains.ts (with a corresponding wagmi mapping above) +// auto-expands the wallet picker. +const supportedChains = DEPLOYMENT_CHAINS .map((c) => chainIdToWagmiChain[c.chainId]) .filter((c): c is Chain => c !== undefined); if (supportedChains.length === 0) { throw new Error( - `No wagmi-mapped chains for ${isTestnet ? "testnet" : "mainnet"} deployment. Check chains.ts and the chainIdToWagmiChain map in wagmi.ts.` + `No wagmi-mapped chains for ${IS_TESTNET ? "testnet" : "mainnet"} deployment. Check chains.ts and the chainIdToWagmiChain map in wagmi.ts.` ); } +// Explicit per-chain transports from the registry's vetted RPC lists. +// Without this wagmi silently uses viem's built-in default RPC for every +// chain — including for the mainnet ENS lookups RainbowKit runs on every +// page whenever chain 1 is configured. `fallback` gives each inner http() +// zero retries and rotates to the next URL on a transport failure, so one +// throttled public endpoint degrades to the next instead of surfacing. +const transports: Record = {}; +for (const chain of supportedChains) { + transports[chain.id] = fallback(CHAINS[chain.id].rpcUrls.map((url) => http(url))); +} + const wcProjectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID; if (!wcProjectId && process.env.NODE_ENV === "development") { console.warn( @@ -51,6 +60,7 @@ export const config = getDefaultConfig({ appName: "RipGuard", projectId: wcProjectId || "YOUR_WC_PROJECT_ID", chains: supportedChains as [Chain, ...Chain[]], + transports, wallets: defaultWallets, ssr: true, }); diff --git a/packages/app/src/lib/retry.ts b/packages/app/src/lib/retry.ts index d39a7f9..ae22eb8 100644 --- a/packages/app/src/lib/retry.ts +++ b/packages/app/src/lib/retry.ts @@ -1,4 +1,4 @@ -/** Retry a transaction-sending function with exponential backoff. +/** Retry an async operation with exponential backoff. * * Useful for RPC rate-limits and transient network errors. * User rejections are never retried. */ @@ -14,9 +14,14 @@ export interface RetryOptions { backoffFactor?: number; /** Maximum delay cap in ms. Default: 10000 */ maxDelay?: number; + /** Decide whether a failure is worth another attempt. Defaults to a + * message sniff for network / rate-limit wording, which is all a + * wallet-surfaced RPC error gives us. Callers with typed errors should + * pass something precise. User rejections are never retried regardless. */ + shouldRetry?: (error: Error) => boolean; } -const DEFAULTS: Required = { +const DEFAULTS: Required> = { maxAttempts: 3, baseDelay: 1000, backoffFactor: 2, @@ -25,8 +30,6 @@ const DEFAULTS: Required = { /** Returns true for errors that are safe to retry (network/RPC issues). */ function isRetryable(error: Error): boolean { - if (isUserRejection(error)) return false; - const msg = error.message?.toLowerCase() ?? ""; return ( msg.includes("network") || @@ -45,6 +48,7 @@ export async function retryWithBackoff( options?: RetryOptions, ): Promise { const opts = { ...DEFAULTS, ...options }; + const shouldRetry = options?.shouldRetry ?? isRetryable; let lastError: Error | null = null; let delay = opts.baseDelay; @@ -54,7 +58,11 @@ export async function retryWithBackoff( } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); - if (!isRetryable(lastError) || attempt === opts.maxAttempts) { + if ( + isUserRejection(lastError) || + !shouldRetry(lastError) || + attempt === opts.maxAttempts + ) { throw lastError; } diff --git a/packages/app/src/lib/vaults.test.ts b/packages/app/src/lib/vaults.test.ts new file mode 100644 index 0000000..bebd575 --- /dev/null +++ b/packages/app/src/lib/vaults.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi } from "vitest"; +import { type Address } from "viem"; +import { type ChainConfig } from "@/config/chains"; +import { + IndexerError, + fetchIndexedStreams, + getScheduleType, + isRetryableIndexerError, + knownStreamIds, + readStreamsOnChain, + rememberStreamIds, + splitByChain, + streamCacheKey, + type StreamRecord, +} from "./vaults"; + +const OWNER: Address = "0xCa7F0d1CCd2A9d9b935D72957E8dFdC56CaF3d71"; +const OTHER: Address = "0x1111111111111111111111111111111111111111"; + +const BSC: ChainConfig = { + chainId: 56, + name: "BNB Chain", + shortName: "BNB", + sablierLockup: "0x6E0baD2c077d699841F1929b45bfb93FAfBEd395", + usdc: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + usdcDecimals: 18, + treasury: "0x847F640bE052b0700C31F72Dce622F4C6286934E", + explorerUrl: "https://bscscan.com", + explorerName: "BscScan", + rpcUrls: ["https://bsc-rpc.publicnode.com"], + isTestnet: false, +}; +const BASE: ChainConfig = { + ...BSC, + chainId: 8453, + name: "Base", + shortName: "Base", + sablierLockup: "0xb5D78DD3276325f5FAF3106Cc4Acc56E28e0Fe3B", + usdcDecimals: 6, +}; + +// Wire shape from the Envio indexer (numerics as strings, cliffTime nullable). +const ROW_1645 = { + chainId: "56", + contract: "0x6e0bad2c077d699841f1929b45bfb93fafbed395", + tokenId: "1645", + depositAmount: "3030000000000000000000", + withdrawnAmount: "0", + startTime: "1788527705", + endTime: "1790323209", + cliffTime: "1790323208", +}; +const ROW_BASE_711 = { + chainId: "8453", + contract: "0xb5d78dd3276325f5faf3106cc4acc56e28e0fe3b", + tokenId: "711", + depositAmount: "5000000", + withdrawnAmount: "1000000", + startTime: "1788000000", + endTime: "1788600000", + cliffTime: null, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchIndexedStreams", () => { + it("asks for every chain in one request with lower-cased addresses", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ data: { LockupStream: [] } })); + await fetchIndexedStreams(OWNER, [BSC, BASE], fetchImpl); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [, init] = fetchImpl.mock.calls[0]; + const body = JSON.parse(init.body); + expect(body.variables).toEqual({ + recipient: OWNER.toLowerCase(), + chainIds: ["56", "8453"], + contracts: [BSC.sablierLockup.toLowerCase(), BASE.sablierLockup.toLowerCase()], + }); + }); + + it("parses wire strings into bigint / seconds and maps null cliff to 0", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ data: { LockupStream: [ROW_1645, ROW_BASE_711] } })); + const streams = await fetchIndexedStreams(OWNER, [BSC, BASE], fetchImpl); + + expect(streams).toEqual([ + { + chainId: 56, + tokenId: BigInt(1645), + deposited: BigInt("3030000000000000000000"), + withdrawn: BigInt(0), + startTime: 1788527705, + endTime: 1790323209, + cliffTime: 1790323208, + }, + { + chainId: 8453, + tokenId: BigInt(711), + deposited: BigInt(5_000_000), + withdrawn: BigInt(1_000_000), + startTime: 1788000000, + endTime: 1788600000, + cliffTime: 0, + }, + ]); + }); + + it("accepts JSON numbers for numeric fields", async () => { + const row = { ...ROW_BASE_711, chainId: 8453, tokenId: 711, startTime: 1788000000 }; + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ data: { LockupStream: [row] } })); + const [stream] = await fetchIndexedStreams(OWNER, [BASE], fetchImpl); + expect(stream.chainId).toBe(8453); + expect(stream.tokenId).toBe(BigInt(711)); + }); + + it("drops streams from a different Sablier contract on the same chain", async () => { + const foreign = { ...ROW_1645, contract: "0x000000000000000000000000000000000000dead" }; + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ data: { LockupStream: [foreign, ROW_1645] } })); + const streams = await fetchIndexedStreams(OWNER, [BSC], fetchImpl); + expect(streams.map((s) => s.tokenId)).toEqual([BigInt(1645)]); + }); + + it("throws a retryable IndexerError on 429", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("", { status: 429 })); + const err = await fetchIndexedStreams(OWNER, [BSC], fetchImpl).catch((e) => e); + expect(err).toBeInstanceOf(IndexerError); + expect(err.status).toBe(429); + expect(isRetryableIndexerError(err)).toBe(true); + }); + + it("throws a retryable IndexerError when the network fails", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + const err = await fetchIndexedStreams(OWNER, [BSC], fetchImpl).catch((e) => e); + expect(err).toBeInstanceOf(IndexerError); + expect(err.status).toBeNull(); + expect(isRetryableIndexerError(err)).toBe(true); + }); + + it("treats GraphQL errors and malformed rows as non-retryable", async () => { + const gqlErr = await fetchIndexedStreams( + OWNER, + [BSC], + vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "field not found" }] })), + ).catch((e) => e); + expect(gqlErr).toBeInstanceOf(IndexerError); + expect(gqlErr.message).toMatch(/field not found/); + expect(isRetryableIndexerError(gqlErr)).toBe(false); + + const badRow = { ...ROW_1645, depositAmount: "3030.5" }; + const rowErr = await fetchIndexedStreams( + OWNER, + [BSC], + vi.fn().mockResolvedValue(jsonResponse({ data: { LockupStream: [badRow] } })), + ).catch((e) => e); + expect(rowErr).toBeInstanceOf(IndexerError); + expect(rowErr.message).toMatch(/depositAmount/); + expect(isRetryableIndexerError(rowErr)).toBe(false); + }); + + it("does not treat arbitrary errors as retryable indexer errors", () => { + expect(isRetryableIndexerError(new Error("Request failed with 429"))).toBe(false); + }); +}); + +describe("splitByChain", () => { + const mk = (chainId: number, tokenId: number): StreamRecord => ({ + chainId, + tokenId: BigInt(tokenId), + deposited: BigInt(1), + withdrawn: BigInt(0), + startTime: 1, + endTime: 2, + cliffTime: 0, + }); + + it("separates the active chain from counts elsewhere", () => { + const { current, elsewhere } = splitByChain( + [mk(56, 1), mk(8453, 2), mk(56, 3), mk(42161, 4), mk(8453, 5)], + 56, + ); + expect(current.map((s) => Number(s.tokenId))).toEqual([1, 3]); + expect(elsewhere).toEqual([ + { chainId: 8453, count: 2 }, + { chainId: 42161, count: 1 }, + ]); + }); + + it("returns empty structures for no streams", () => { + expect(splitByChain([], 56)).toEqual({ current: [], elsewhere: [] }); + }); +}); + +function memoryStorage(seed: Record = {}): Storage { + const map = new Map(Object.entries(seed)); + return { + get length() { + return map.size; + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + clear: () => map.clear(), + }; +} + +describe("device memory of stream IDs", () => { + it("returns nothing without storage", () => { + expect(knownStreamIds(null, 56, OWNER)).toEqual([]); + }); + + it("round-trips the indexer result, keyed by chain and lower-cased wallet", () => { + const storage = memoryStorage(); + rememberStreamIds(storage, 56, OWNER, [BigInt(1645), BigInt(1646)]); + + expect(storage.getItem(streamCacheKey(56, OWNER))).toBe('["1645","1646"]'); + expect(knownStreamIds(storage, 56, OWNER)).toEqual([BigInt(1646), BigInt(1645)]); + expect(knownStreamIds(storage, 56, OTHER)).toEqual([]); + expect(knownStreamIds(storage, 8453, OWNER)).toEqual([]); + }); + + it("unions /create's label keys for the same chain and de-duplicates", () => { + const storage = memoryStorage({ + "ripguard:lock:56:1644": "Lock until Aug 31, 2026", + "ripguard:lock:56:1645": "Lock until Sep 24, 2026", + "ripguard:lock:8453:9": "Steady reloads", + "ripguard:lock:56:not-an-id": "garbage", + }); + rememberStreamIds(storage, 56, OWNER, [BigInt(1645), BigInt(1646)]); + + expect(knownStreamIds(storage, 56, OWNER)).toEqual([ + BigInt(1646), + BigInt(1645), + BigInt(1644), + ]); + }); + + it("survives a corrupt cache entry", () => { + const storage = memoryStorage({ + [streamCacheKey(56, OWNER)]: "{not json", + "ripguard:lock:56:7": "One Drop", + }); + expect(knownStreamIds(storage, 56, OWNER)).toEqual([BigInt(7)]); + }); + + it("swallows storage write failures", () => { + const storage = memoryStorage(); + storage.setItem = () => { + throw new DOMException("quota", "QuotaExceededError"); + }; + expect(() => rememberStreamIds(storage, 56, OWNER, [BigInt(1)])).not.toThrow(); + }); +}); + +describe("readStreamsOnChain", () => { + type Result = { status: "success"; result: unknown } | { status: "failure"; error: Error }; + const ok = (result: unknown): Result => ({ status: "success", result }); + const fail = (msg: string): Result => ({ status: "failure", error: new Error(msg) }); + + // Six reads per id, in STREAM_READS order. + const owned = (owner: Address, start: number, end: number, cliff: number, dep: bigint, wd: bigint) => [ + ok(owner), ok(start), ok(end), ok(cliff), ok(dep), ok(wd), + ]; + + function client(results: Result[]) { + const multicall = vi.fn().mockResolvedValue(results); + return { client: { multicall } as never, multicall }; + } + + it("returns nothing and skips the RPC for no ids", async () => { + const { client: c, multicall } = client([]); + expect(await readStreamsOnChain(c, BSC, OWNER, [])).toEqual([]); + expect(multicall).not.toHaveBeenCalled(); + }); + + it("keeps only streams the wallet owns and sorts newest first", async () => { + const { client: c, multicall } = client([ + ...owned(OWNER, 100, 200, 199, BigInt(5), BigInt(5)), + ...owned(OTHER, 300, 400, 0, BigInt(9), BigInt(0)), + ...owned(OWNER, 150, 250, 249, BigInt(3030), BigInt(0)), + ...[fail("ERC721NonexistentToken"), ok(0), ok(0), ok(0), ok(BigInt(0)), ok(BigInt(0))], + ]); + const streams = await readStreamsOnChain(c, BSC, OWNER, [ + BigInt(1644), BigInt(1), BigInt(1645), BigInt(99999), + ]); + + expect(multicall).toHaveBeenCalledTimes(1); + const { contracts } = multicall.mock.calls[0][0]; + expect(contracts).toHaveLength(4 * 6); + expect(contracts[0]).toMatchObject({ + address: BSC.sablierLockup, + functionName: "ownerOf", + args: [BigInt(1644)], + }); + + expect(streams).toEqual([ + { chainId: 56, tokenId: BigInt(1645), deposited: BigInt(3030), withdrawn: BigInt(0), startTime: 150, endTime: 250, cliffTime: 249 }, + { chainId: 56, tokenId: BigInt(1644), deposited: BigInt(5), withdrawn: BigInt(5), startTime: 100, endTime: 200, cliffTime: 199 }, + ]); + }); + + it("throws instead of rendering a partially-read owned stream", async () => { + const { client: c } = client([ + ok(OWNER), ok(100), ok(200), ok(199), fail("rate limited"), ok(BigInt(0)), + ]); + await expect(readStreamsOnChain(c, BSC, OWNER, [BigInt(1644)])).rejects.toThrow( + /stream #1644.*rate limited/, + ); + }); +}); + +describe("getScheduleType", () => { + it("labels tranched streams regardless of shape", () => { + expect(getScheduleType(0, 86400, true)).toBe("Strict Payouts"); + }); + + it("treats Sablier's required one-second tail after the cliff as a single drop", () => { + // /create writes lock-until and Panic Lock with total = cliff + 1. + expect(getScheduleType(1795503, 1795504)).toBe("One Drop"); + expect(getScheduleType(86400, 86401)).toBe("One Drop"); + expect(getScheduleType(3600, 3600)).toBe("One Drop"); + }); + + it("distinguishes a cliff followed by real reloads", () => { + expect(getScheduleType(86400, 8 * 86400)).toBe("Wait, then reloads"); + }); + + it("labels no-cliff streams as steady reloads", () => { + expect(getScheduleType(0, 86400)).toBe("Steady reloads"); + expect(getScheduleType(0, 1)).toBe("Steady reloads"); + }); +}); diff --git a/packages/app/src/lib/vaults.ts b/packages/app/src/lib/vaults.ts new file mode 100644 index 0000000..1060612 --- /dev/null +++ b/packages/app/src/lib/vaults.ts @@ -0,0 +1,355 @@ +import { type Address, type PublicClient, isAddressEqual } from "viem"; +import { sablierLockupAbi } from "@/config/abis"; +import { type ChainConfig } from "@/config/chains"; +import { retryWithBackoff } from "./retry"; + +// Sablier's Envio indexer — one endpoint for every chain, no API key. It +// rate-limits at 250 requests per 60 s per client IP (see x-ratelimit-* +// response headers), and a mobile user behind carrier NAT shares that budget +// with strangers. Hence one query for every deployment chain, never a fan-out. +export const SABLIER_INDEXER_URL = "https://indexer.hyperindex.xyz/53b7e25/v1/graphql"; + +/** A Sablier stream as /vaults needs it, already parsed from wire strings. */ +export type StreamRecord = { + chainId: number; + tokenId: bigint; + deposited: bigint; + withdrawn: bigint; + startTime: number; + endTime: number; + /** Unix seconds; 0 when the stream has no cliff. */ + cliffTime: number; +}; + +export class IndexerError extends Error { + /** HTTP status, or null when the request never got a response. */ + readonly status: number | null; + + constructor(message: string, status: number | null) { + super(message); + this.name = "IndexerError"; + this.status = status; + } +} + +// Throttling, upstream outages and dropped connections clear on their own. +// A rejected query or a malformed row will not, and retrying those only +// burns the shared rate-limit budget. +export function isRetryableIndexerError(error: Error): boolean { + if (!(error instanceof IndexerError)) return false; + return error.status === null || error.status === 429 || error.status >= 500; +} + +const STREAMS_QUERY = `query RipGuardVaults($recipient: String!, $chainIds: [numeric!]!, $contracts: [String!]!) { + LockupStream( + where: { + recipient: { _eq: $recipient } + chainId: { _in: $chainIds } + contract: { _in: $contracts } + } + order_by: { startTime: desc } + ) { + chainId + contract + tokenId + depositAmount + withdrawnAmount + startTime + endTime + cliffTime + } +}`; + +// Hasura serialises numerics as strings, but be liberal: a config change on +// their side that flips to JSON numbers must not read as "you have no vaults". +function asBigInt(value: unknown, field: string, status: number): bigint { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return BigInt(value); + } + if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); + throw new IndexerError(`Indexer row has malformed ${field}: ${String(value)}`, status); +} + +function asSeconds(value: unknown, field: string, status: number): number { + const n = Number(asBigInt(value, field, status)); + if (!Number.isSafeInteger(n)) { + throw new IndexerError(`Indexer row has out-of-range ${field}: ${String(value)}`, status); + } + return n; +} + +function asLowerHex(value: unknown, field: string, status: number): string { + if (typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value)) return value.toLowerCase(); + throw new IndexerError(`Indexer row has malformed ${field}: ${String(value)}`, status); +} + +type ParsedRow = { contract: string; stream: StreamRecord }; + +function parseRow(row: unknown, status: number): ParsedRow { + if (row === null || typeof row !== "object") { + throw new IndexerError("Indexer row is not an object", status); + } + const r = row as Record; + const chainId = asSeconds(r.chainId, "chainId", status); + return { + contract: asLowerHex(r.contract, "contract", status), + stream: { + chainId, + tokenId: asBigInt(r.tokenId, "tokenId", status), + deposited: asBigInt(r.depositAmount, "depositAmount", status), + withdrawn: asBigInt(r.withdrawnAmount, "withdrawnAmount", status), + startTime: asSeconds(r.startTime, "startTime", status), + endTime: asSeconds(r.endTime, "endTime", status), + cliffTime: + r.cliffTime === null || r.cliffTime === undefined + ? 0 + : asSeconds(r.cliffTime, "cliffTime", status), + }, + }; +} + +/** + * Every stream `recipient` holds on any of `chains`, newest first. + * + * One round-trip for all chains. Throws IndexerError on any failure; a + * malformed row fails the whole call rather than silently dropping a vault, + * because a missing vault is indistinguishable from "lost funds" to a user. + */ +export async function fetchIndexedStreams( + recipient: Address, + chains: readonly ChainConfig[], + fetchImpl: typeof fetch = fetch, +): Promise { + const lockupByChain = new Map(chains.map((c) => [c.chainId, c.sablierLockup.toLowerCase()])); + + let res: Response; + try { + res = await fetchImpl(SABLIER_INDEXER_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: STREAMS_QUERY, + variables: { + recipient: recipient.toLowerCase(), + chainIds: chains.map((c) => String(c.chainId)), + contracts: [...lockupByChain.values()], + }, + }), + }); + } catch (err) { + throw new IndexerError( + `Indexer unreachable: ${err instanceof Error ? err.message : String(err)}`, + null, + ); + } + + if (!res.ok) throw new IndexerError(`Indexer returned ${res.status}`, res.status); + + let json: { data?: { LockupStream?: unknown }; errors?: Array<{ message?: string }> }; + try { + json = await res.json(); + } catch { + throw new IndexerError("Indexer returned a non-JSON body", res.status); + } + if (json.errors?.length) { + throw new IndexerError(json.errors[0]?.message ?? "Indexer query failed", res.status); + } + const rows = json.data?.LockupStream; + if (!Array.isArray(rows)) { + throw new IndexerError("Indexer response is missing LockupStream", res.status); + } + + const streams: StreamRecord[] = []; + for (const row of rows) { + const { contract, stream } = parseRow(row, res.status); + // The query pins contracts and chains as two independent sets, so a + // stream on another Sablier deployment that happens to share a chain + // could slip through — and it would render with the wrong claim target. + if (lockupByChain.get(stream.chainId) !== contract) continue; + streams.push(stream); + } + return streams; +} + +/** `fetchIndexedStreams` with short retries on throttling / outages. */ +export function fetchIndexedStreamsWithRetry( + recipient: Address, + chains: readonly ChainConfig[], + fetchImpl: typeof fetch = fetch, +): Promise { + return retryWithBackoff(() => fetchIndexedStreams(recipient, chains, fetchImpl), { + maxAttempts: 3, + baseDelay: 1500, + shouldRetry: isRetryableIndexerError, + }); +} + +export type ElsewhereCount = { chainId: number; count: number }; + +/** Streams on the active chain, plus how many live on each other chain. */ +export function splitByChain( + streams: readonly StreamRecord[], + chainId: number, +): { current: StreamRecord[]; elsewhere: ElsewhereCount[] } { + const current: StreamRecord[] = []; + const counts = new Map(); + for (const s of streams) { + if (s.chainId === chainId) current.push(s); + else counts.set(s.chainId, (counts.get(s.chainId) ?? 0) + 1); + } + return { + current, + elsewhere: [...counts].map(([id, count]) => ({ chainId: id, count })), + }; +} + +// --------------------------------------------------------------------------- +// Device memory — the fallback when the indexer is down. +// +// /create writes `ripguard:lock::` = preset label at lock +// time; those keys double as a discovery hint. We add our own cache of the +// last indexer result per (chain, wallet). Neither is authoritative: label +// keys are not wallet-scoped, so the on-chain read filters by ownerOf. +// Scanning Transfer logs is not a fallback option — public RPCs cap +// eth_getLogs at 1k–10k blocks, which is tens of thousands of sequential +// calls on BNB Chain. +// --------------------------------------------------------------------------- + +const STREAM_CACHE_PREFIX = "ripguard:streams:"; +const LOCK_LABEL_PREFIX = "ripguard:lock:"; +const DECIMAL_ID = /^\d+$/; + +export function streamCacheKey(chainId: number, owner: Address): string { + return `${STREAM_CACHE_PREFIX}${chainId}:${owner.toLowerCase()}`; +} + +/** Best-effort: private mode / quota errors are swallowed. */ +export function rememberStreamIds( + storage: Storage | null, + chainId: number, + owner: Address, + ids: readonly bigint[], +): void { + if (!storage) return; + try { + storage.setItem(streamCacheKey(chainId, owner), JSON.stringify(ids.map(String))); + } catch { + // Cache is an optimisation for the indexer-down path only. + } +} + +/** Stream IDs this device has seen for (chain, wallet), newest ID first. */ +export function knownStreamIds(storage: Storage | null, chainId: number, owner: Address): bigint[] { + if (!storage) return []; + const ids = new Set(); + + try { + const cached: unknown = JSON.parse(storage.getItem(streamCacheKey(chainId, owner)) ?? "[]"); + if (Array.isArray(cached)) { + for (const v of cached) if (typeof v === "string" && DECIMAL_ID.test(v)) ids.add(v); + } + } catch { + // Corrupt cache entry — the label keys below still count. + } + + try { + const labelPrefix = `${LOCK_LABEL_PREFIX}${chainId}:`; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (!key?.startsWith(labelPrefix)) continue; + const id = key.slice(labelPrefix.length); + if (DECIMAL_ID.test(id)) ids.add(id); + } + } catch { + // Storage unavailable. + } + + return [...ids].map(BigInt).sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); +} + +// ownerOf first so transferred, burned and foreign IDs drop out instead of +// rendering someone else's vault. +const STREAM_READS = [ + "ownerOf", + "getStartTime", + "getEndTime", + "getCliffTime", + "getDepositedAmount", + "getWithdrawnAmount", +] as const; + +/** + * Read `ids` straight from Sablier and keep the ones `owner` still holds. + * + * Throws if any read for an owned stream fails — partial data would show a + * wrong balance, which is worse than the error state. + */ +export async function readStreamsOnChain( + client: PublicClient, + chain: ChainConfig, + owner: Address, + ids: readonly bigint[], +): Promise { + if (ids.length === 0) return []; + + const contracts = ids.flatMap((id) => + STREAM_READS.map((functionName) => ({ + address: chain.sablierLockup, + abi: sablierLockupAbi, + functionName, + args: [id] as const, + })), + ); + const results = await client.multicall({ contracts }); + + const streams: StreamRecord[] = []; + ids.forEach((id, i) => { + const base = i * STREAM_READS.length; + const [ownerRes, start, end, cliff, deposited, withdrawn] = results.slice( + base, + base + STREAM_READS.length, + ); + // ownerOf reverts for burned / never-minted IDs: not ours, skip. + if (ownerRes.status !== "success") return; + if (!isAddressEqual(ownerRes.result as Address, owner)) return; + + for (const r of [start, end, cliff, deposited, withdrawn]) { + if (r.status !== "success") { + throw new Error(`On-chain read failed for stream #${id}: ${r.error?.message ?? "unknown"}`); + } + } + streams.push({ + chainId: chain.chainId, + tokenId: id, + deposited: deposited.result as bigint, + withdrawn: withdrawn.result as bigint, + startTime: Number(start.result), + endTime: Number(end.result), + cliffTime: Number(cliff.result), + }); + }); + + return streams.sort((a, b) => b.startTime - a.startTime); +} + +// --------------------------------------------------------------------------- +// Labels +// --------------------------------------------------------------------------- + +/** + * Generic schedule label when no preset label was remembered at lock time. + * + * Sablier requires cliff < total, so /create writes "lock until" and Panic + * Lock streams with total = cliff + 1 s. That one-second tail is still a + * single drop, not a reload schedule. + */ +export function getScheduleType( + cliffSeconds: number, + totalSeconds: number, + isTranched = false, +): string { + if (isTranched) return "Strict Payouts"; + if (cliffSeconds > 0 && cliffSeconds >= totalSeconds - 1) return "One Drop"; + if (cliffSeconds > 0) return "Wait, then reloads"; + return "Steady reloads"; +}