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

279 lines
7.6 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> = {
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<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
}
// ─── 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<SafeTransfer[]> {
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<SafeTransfer[]> {
const data = await fetchJSON<{ results: Array<Record<string, unknown>> }>(
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<TransferSummary> {
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,
}
}