/** * Safe Global API Client for rFunds.online * Ported from rWallet's safe-api.js — TypeScript, Gnosis + Optimism only */ export interface ChainConfig { name: string slug: string txService: string explorer: string color: string symbol: string } export const SUPPORTED_CHAINS: Record = { 1: { name: 'Ethereum', slug: 'mainnet', txService: 'https://safe-transaction-mainnet.safe.global', explorer: 'https://etherscan.io', color: '#627eea', symbol: 'ETH', }, 10: { name: 'Optimism', slug: 'optimism', txService: 'https://safe-transaction-optimism.safe.global', explorer: 'https://optimistic.etherscan.io', color: '#ff0420', symbol: 'ETH', }, 100: { name: 'Gnosis', slug: 'gnosis-chain', txService: 'https://safe-transaction-gnosis-chain.safe.global', explorer: 'https://gnosisscan.io', color: '#04795b', symbol: 'xDAI', }, 137: { name: 'Polygon', slug: 'polygon', txService: 'https://safe-transaction-polygon.safe.global', explorer: 'https://polygonscan.com', color: '#8247e5', symbol: 'MATIC', }, 8453: { name: 'Base', slug: 'base', txService: 'https://safe-transaction-base.safe.global', explorer: 'https://basescan.org', color: '#0052ff', symbol: 'ETH', }, 42161: { name: 'Arbitrum One', slug: 'arbitrum', txService: 'https://safe-transaction-arbitrum.safe.global', explorer: 'https://arbiscan.io', color: '#28a0f0', symbol: 'ETH', }, } export interface SafeInfo { address: string nonce: number threshold: number owners: string[] version: string chainId: number } export interface SafeBalance { tokenAddress: string | null token: { name: string symbol: string decimals: number logoUri?: string } | null balance: string balanceFormatted: string symbol: string fiatBalance: string fiatConversion: string } export interface DetectedChain { chainId: number chain: ChainConfig safeInfo: SafeInfo } function getChain(chainId: number): ChainConfig { const chain = SUPPORTED_CHAINS[chainId] if (!chain) throw new Error(`Unsupported chain ID: ${chainId}`) return chain } function apiUrl(chainId: number, path: string): string { return `${getChain(chainId).txService}/api/v1${path}` } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } async function fetchJSON(url: string, retries = 5): Promise { for (let attempt = 0; attempt <= retries; attempt++) { const res = await fetch(url) if (res.status === 404) return null if (res.status === 429) { const delay = Math.min(2000 * Math.pow(2, attempt), 32000) await sleep(delay) continue } if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`) return res.json() as Promise } return null } export async function getSafeInfo(address: string, chainId: number): Promise { const data = await fetchJSON>(apiUrl(chainId, `/safes/${address}/`)) if (!data) return null return { address: data.address as string, nonce: data.nonce as number, threshold: data.threshold as number, owners: data.owners as string[], version: data.version as string, chainId, } } export async function getBalances(address: string, chainId: number): Promise { const data = await fetchJSON>>( apiUrl(chainId, `/safes/${address}/balances/?trusted=true&exclude_spam=true`) ) if (!data) return [] const chain = SUPPORTED_CHAINS[chainId] return data.map((b) => { const token = b.token as { name: string; symbol: string; decimals: number; logoUri?: string } | null return { tokenAddress: (b.tokenAddress as string) || null, token, balance: b.balance as string, balanceFormatted: token ? (parseFloat(b.balance as string) / Math.pow(10, token.decimals)).toFixed(token.decimals > 6 ? 4 : 2) : (parseFloat(b.balance as string) / 1e18).toFixed(4), symbol: token ? token.symbol : chain?.symbol || 'ETH', fiatBalance: (b.fiatBalance as string) || '0', fiatConversion: (b.fiatConversion as string) || '0', } }) } export async function detectSafeChains( address: string, chainIds?: number[] ): Promise { const ids = chainIds || Object.keys(SUPPORTED_CHAINS).map(Number) const results: DetectedChain[] = [] // Check chains sequentially with small delay to avoid rate limits for (const chainId of ids) { const chain = SUPPORTED_CHAINS[chainId] if (!chain) continue try { const info = await getSafeInfo(address, chainId) if (info) results.push({ chainId, chain, safeInfo: info }) } catch { // skip failed chains } if (ids.length > 1) await sleep(300) } return results } // ─── Transfer History ────────────────────────────────────── export interface SafeTransfer { type: 'ETHER_TRANSFER' | 'ERC20_TRANSFER' executionDate: string transactionHash: string to: string from: string value: string tokenAddress: string | null tokenInfo?: { name: string symbol: string decimals: number } } export interface TransferSummary { chainId: number totalInflow30d: number totalOutflow30d: number inflowRate: number outflowRate: number incomingTransfers: SafeTransfer[] outgoingTransfers: SafeTransfer[] } export async function getIncomingTransfers( address: string, chainId: number, limit = 100 ): Promise { const data = await fetchJSON<{ results: SafeTransfer[] }>( apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}&executed=true`) ) return data?.results || [] } export async function getOutgoingTransfers( address: string, chainId: number, limit = 100 ): Promise { const data = await fetchJSON<{ results: Array> }>( apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&executed=true`) ) if (!data?.results) return [] return data.results .filter(tx => tx.value && parseInt(tx.value as string, 10) > 0) .map(tx => ({ type: (tx.dataDecoded ? 'ERC20_TRANSFER' : 'ETHER_TRANSFER') as SafeTransfer['type'], executionDate: (tx.executionDate as string) || '', transactionHash: (tx.transactionHash as string) || '', to: (tx.to as string) || '', from: address, value: (tx.value as string) || '0', tokenAddress: null, tokenInfo: undefined, })) } export async function computeTransferSummary( address: string, chainId: number ): Promise { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) const [incoming, outgoing] = await Promise.all([ getIncomingTransfers(address, chainId), getOutgoingTransfers(address, chainId), ]) const recentIncoming = incoming.filter(t => new Date(t.executionDate) >= thirtyDaysAgo) const recentOutgoing = outgoing.filter(t => new Date(t.executionDate) >= thirtyDaysAgo) const sumTransfers = (transfers: SafeTransfer[]) => transfers.reduce((sum, t) => { const decimals = t.tokenInfo?.decimals ?? 18 return sum + parseFloat(t.value) / Math.pow(10, decimals) }, 0) const totalIn = sumTransfers(recentIncoming) const totalOut = sumTransfers(recentOutgoing) return { chainId, totalInflow30d: totalIn, totalOutflow30d: totalOut, inflowRate: totalIn, outflowRate: totalOut, incomingTransfers: recentIncoming, outgoingTransfers: recentOutgoing, } }