279 lines
7.6 KiB
TypeScript
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,
|
|
}
|
|
}
|