295 lines
7.9 KiB
TypeScript
295 lines
7.9 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|