rspace-online/modules/rflows/lib/drips-client.ts

295 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<number, { drips: string; addressDriver: string; nftDriver: string }>;
// Drips subgraph API endpoints by chain
const DRIPS_API: Record<number, string> = {
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<string, string>; // token → amount
fetchedAt: number;
}
// ── Cache ──
interface CacheEntry {
state: DripsAccountState;
ts: number;
}
const TTL = 5 * 60 * 1000;
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<DripsAccountState>>();
// ── 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<DripsAccountState | null> {
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<string | null> {
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<DripsAccountState | null> {
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<DripsAccountState> {
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<DripsAccountState> => {
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;
}