/** * 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 = { 100: { name: 'Gnosis', slug: 'gnosis-chain', txService: 'https://safe-transaction-gnosis-chain.safe.global', explorer: 'https://gnosisscan.io', color: '#04795b', symbol: 'xDAI', }, 10: { name: 'Optimism', slug: 'optimism', txService: 'https://safe-transaction-optimism.safe.global', explorer: 'https://optimistic.etherscan.io', color: '#ff0420', 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 }