/** * Drips Protocol read-only client. * * Fetches stream/split state via Drips GraphQL API (primary) with * direct eth_call fallback. 5-min cache + in-flight dedup. * Pattern: mirrors rwallet/lib/defi-positions.ts. */ import { getRpcUrl } from '../../rwallet/mod'; // ── Contract addresses ── export const DRIPS_CONTRACTS = { 1: { drips: '0xd0Dd053392db676D57317CD4fe96Fc2cCf42D0b4', addressDriver: '0x1455d9bD6B98f95dd8FEB2b3D60ed825fcef0610', nftDriver: '0xcf9c49B0962EDb01Cdaa5326299ba85D72405258', }, 11155111: { drips: '0x74A32a38D945b9527524900429b083547DeB9bF4', addressDriver: '0x70E1E1437AeFe8024B6780C94490662b45C3B567', nftDriver: '0xdC773a04C0D6EFdb80E7dfF961B6a7B063a28B44', }, } as Record; // Drips subgraph API endpoints by chain const DRIPS_API: Record = { 1: 'https://drips-api.onrender.com/graphql', 11155111: 'https://drips-api-sepolia.onrender.com/graphql', }; // ── Types ── export interface DripsStream { id: string; sender: string; receiver: string; tokenAddress: string; amtPerSec: string; // raw bigint string (extra 9 decimals) startTime: number; duration: number; // 0 = indefinite isPaused: boolean; } export interface DripsSplit { receiver: string; weight: number; // out of 1_000_000 } export interface DripsAccountState { address: string; chainId: number; incomingStreams: DripsStream[]; outgoingStreams: DripsStream[]; splits: DripsSplit[]; splitsHash: string; collectableAmounts: Record; // token → amount fetchedAt: number; } // ── Cache ── interface CacheEntry { state: DripsAccountState; ts: number; } const TTL = 5 * 60 * 1000; const cache = new Map(); const inFlight = new Map>(); // ── Helpers ── /** Convert Drips amtPerSec (with 9 extra decimals) to monthly USD. */ export function dripsAmtToMonthly(amtPerSec: string, tokenDecimals: number, usdPrice: number): number { const raw = BigInt(amtPerSec); // Remove 9 extra decimals from Drips encoding const perSec = Number(raw) / 1e9; // Convert to token units const tokenPerSec = perSec / Math.pow(10, tokenDecimals); // Monthly = per-sec × 60 × 60 × 24 × 30 return tokenPerSec * usdPrice * 2_592_000; } /** Compute Drips account ID from address (AddressDriver: address as uint256). */ function addressToAccountId(address: string): string { // AddressDriver account ID = (driverIndex << 224) | addressAsUint160 // driverIndex for AddressDriver = 0, so accountId = address as uint256 return BigInt(address).toString(); } // ── GraphQL fetch ── const ACCOUNT_QUERY = ` query DripsAccount($accountId: ID!) { userById(accountId: $accountId) { splitsEntries { receiver { accountId, address } weight } streams { outgoing { id receiver { address } config { amtPerSec, start, duration } isPaused tokenAddress } incoming { id sender { address } config { amtPerSec, start, duration } isPaused tokenAddress } } } }`; async function fetchGraphQL(chainId: number, address: string): Promise { const apiUrl = DRIPS_API[chainId]; if (!apiUrl) return null; const accountId = addressToAccountId(address); try { const res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ACCOUNT_QUERY, variables: { accountId } }), signal: AbortSignal.timeout(15000), }); if (!res.ok) { console.warn(`[drips-client] GraphQL API error: ${res.status}`); return null; } const json = await res.json() as any; const user = json.data?.userById; if (!user) return emptyState(chainId, address); const incomingStreams: DripsStream[] = (user.streams?.incoming || []).map((s: any) => ({ id: s.id || '', sender: s.sender?.address || '', receiver: address, tokenAddress: s.tokenAddress || '', amtPerSec: s.config?.amtPerSec || '0', startTime: Number(s.config?.start || 0), duration: Number(s.config?.duration || 0), isPaused: s.isPaused ?? false, })); const outgoingStreams: DripsStream[] = (user.streams?.outgoing || []).map((s: any) => ({ id: s.id || '', sender: address, receiver: s.receiver?.address || '', tokenAddress: s.tokenAddress || '', amtPerSec: s.config?.amtPerSec || '0', startTime: Number(s.config?.start || 0), duration: Number(s.config?.duration || 0), isPaused: s.isPaused ?? false, })); const splits: DripsSplit[] = (user.splitsEntries || []).map((s: any) => ({ receiver: s.receiver?.address || s.receiver?.accountId || '', weight: Number(s.weight || 0), })); return { address, chainId, incomingStreams, outgoingStreams, splits, splitsHash: '', // GraphQL doesn't return hash directly collectableAmounts: {}, fetchedAt: Date.now(), }; } catch (e) { console.warn('[drips-client] GraphQL fetch failed:', e); return null; } } // ── Fallback: direct eth_call ── // Drips.splitsHash(uint256 accountId) → bytes32 const SPLITS_HASH_SELECTOR = '0xeca563dd'; // Drips.hashSplits(SplitsReceiver[] memory receivers) — for verification only async function rpcCall(rpcUrl: string, to: string, data: string): Promise { try { const res = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to, data }, 'latest'], }), signal: AbortSignal.timeout(10000), }); const json = await res.json() as any; return json.result || null; } catch { return null; } } function padUint256(value: string): string { return BigInt(value).toString(16).padStart(64, '0'); } async function fetchViaRPC(chainId: number, address: string): Promise { const rpcUrl = getRpcUrl(String(chainId)); if (!rpcUrl) return null; const contracts = DRIPS_CONTRACTS[chainId]; if (!contracts) return null; const accountId = addressToAccountId(address); // Get splitsHash const splitsData = SPLITS_HASH_SELECTOR + padUint256(accountId); const splitsHash = await rpcCall(rpcUrl, contracts.drips, splitsData); return { address, chainId, incomingStreams: [], outgoingStreams: [], splits: [], // Can't enumerate splits from hash alone splitsHash: splitsHash || '', collectableAmounts: {}, fetchedAt: Date.now(), }; } function emptyState(chainId: number, address: string): DripsAccountState { return { address, chainId, incomingStreams: [], outgoingStreams: [], splits: [], splitsHash: '', collectableAmounts: {}, fetchedAt: Date.now(), }; } // ── Public API ── /** * Fetch Drips account state with 5-min cache and in-flight dedup. * Tries GraphQL first, falls back to direct eth_call. */ export async function getDripsAccountState(chainId: number, address: string): Promise { const key = `${chainId}:${address.toLowerCase()}`; // Check cache const cached = cache.get(key); if (cached && Date.now() - cached.ts < TTL) return cached.state; // Deduplicate concurrent requests const pending = inFlight.get(key); if (pending) return pending; const promise = (async (): Promise => { try { // Try GraphQL first const graphqlResult = await fetchGraphQL(chainId, address); if (graphqlResult) { cache.set(key, { state: graphqlResult, ts: Date.now() }); return graphqlResult; } // Fallback to RPC const rpcResult = await fetchViaRPC(chainId, address); if (rpcResult) { cache.set(key, { state: rpcResult, ts: Date.now() }); return rpcResult; } return emptyState(chainId, address); } catch (e) { console.warn('[drips-client] Failed to fetch:', e); return emptyState(chainId, address); } finally { inFlight.delete(key); } })(); inFlight.set(key, promise); return promise; }