rfunds-online/lib/api/safe-client.ts

151 lines
4.1 KiB
TypeScript

/**
* 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<number, ChainConfig> = {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function fetchJSON<T>(url: string, retries = 5): Promise<T | null> {
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<T>
}
return null
}
export async function getSafeInfo(address: string, chainId: number): Promise<SafeInfo | null> {
const data = await fetchJSON<Record<string, unknown>>(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<SafeBalance[]> {
const data = await fetchJSON<Array<Record<string, unknown>>>(
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<DetectedChain[]> {
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
}