151 lines
4.1 KiB
TypeScript
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
|
|
}
|