rwallet-online/js/safe-api.js

213 lines
8.8 KiB
JavaScript

/**
* Safe Global API Client for rWallet.online
* Browser-side client for Safe Transaction Service API
* Chain config adapted from payment-infra/packages/safe-core/src/chains.ts
*/
const SafeAPI = (() => {
// ─── Chain Configuration ───────────────────────────────────────
const CHAINS = {
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: 'POL' },
8453: { name: 'Base', slug: 'base', txService: 'https://safe-transaction-base.safe.global', explorer: 'https://basescan.org', color: '#0052ff', symbol: 'ETH' },
42161: { name: 'Arbitrum', slug: 'arbitrum', txService: 'https://safe-transaction-arbitrum.safe.global', explorer: 'https://arbiscan.io', color: '#28a0f0', symbol: 'ETH' },
43114: { name: 'Avalanche', slug: 'avalanche', txService: 'https://safe-transaction-avalanche.safe.global', explorer: 'https://snowtrace.io', color: '#e84142', symbol: 'AVAX' },
};
// ─── Helpers ───────────────────────────────────────────────────
function getChain(chainId) {
const chain = CHAINS[chainId];
if (!chain) throw new Error(`Unsupported chain ID: ${chainId}`);
return chain;
}
function apiUrl(chainId, path) {
return `${getChain(chainId).txService}/api/v1${path}`;
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchJSON(url, retries = 4) {
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(1000 * Math.pow(2, attempt), 10000);
console.warn(`Rate limited (429), retrying in ${delay}ms... (${url})`);
await sleep(delay);
continue;
}
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText} (${url})`);
return res.json();
}
throw new Error(`Rate limited after ${retries} retries: ${url}`);
}
// ─── Core API Methods ──────────────────────────────────────────
/**
* Get Safe info (owners, threshold, nonce, etc.)
*/
async function getSafeInfo(address, chainId) {
const data = await fetchJSON(apiUrl(chainId, `/safes/${address}/`));
if (!data) return null;
return {
address: data.address,
nonce: data.nonce,
threshold: data.threshold,
owners: data.owners,
modules: data.modules,
fallbackHandler: data.fallbackHandler,
guard: data.guard,
version: data.version,
chainId,
};
}
/**
* Get token + native balances
*/
async function getBalances(address, chainId) {
const data = await fetchJSON(apiUrl(chainId, `/safes/${address}/balances/?trusted=true&exclude_spam=true`));
if (!data) return [];
return data.map(b => ({
tokenAddress: b.tokenAddress,
token: b.token ? {
name: b.token.name,
symbol: b.token.symbol,
decimals: b.token.decimals,
logoUri: b.token.logoUri,
} : null,
balance: b.balance,
// Human-readable balance
balanceFormatted: b.token
? (parseFloat(b.balance) / Math.pow(10, b.token.decimals)).toFixed(b.token.decimals > 6 ? 4 : 2)
: (parseFloat(b.balance) / 1e18).toFixed(4),
symbol: b.token ? b.token.symbol : CHAINS[chainId]?.symbol || 'ETH',
fiatBalance: b.fiatBalance || '0',
fiatConversion: b.fiatConversion || '0',
}));
}
/**
* Fetch all multisig transactions (paginated)
*/
async function getAllMultisigTransactions(address, chainId, limit = 100) {
const allTxs = [];
let url = apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&ordering=-executionDate`);
while (url) {
const data = await fetchJSON(url);
if (!data || !data.results) break;
allTxs.push(...data.results);
url = data.next;
// Safety: cap at 1000 transactions
if (allTxs.length >= 1000) break;
}
return allTxs;
}
/**
* Fetch all incoming transfers (paginated)
*/
async function getAllIncomingTransfers(address, chainId, limit = 100) {
const allTransfers = [];
let url = apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}`);
while (url) {
const data = await fetchJSON(url);
if (!data || !data.results) break;
allTransfers.push(...data.results);
url = data.next;
if (allTransfers.length >= 1000) break;
}
return allTransfers;
}
/**
* Fetch all-transactions (combines multisig + module + incoming)
*/
async function getAllTransactions(address, chainId, limit = 100) {
const allTxs = [];
let url = apiUrl(chainId, `/safes/${address}/all-transactions/?limit=${limit}&ordering=-executionDate&executed=true`);
while (url) {
const data = await fetchJSON(url);
if (!data || !data.results) break;
allTxs.push(...data.results);
url = data.next;
if (allTxs.length >= 1000) break;
}
return allTxs;
}
/**
* Detect which chains have a Safe deployed for this address.
* Each chain has its own API server, so we can check all in parallel.
* Returns array of { chainId, chain, safeInfo }
*/
async function detectSafeChains(address) {
const checks = Object.entries(CHAINS).map(async ([chainId, chain]) => {
try {
const info = await getSafeInfo(address, parseInt(chainId));
if (info) return { chainId: parseInt(chainId), chain, safeInfo: info };
} catch (e) {
// Chain doesn't have this Safe or API error - skip
}
return null;
});
const results = await Promise.all(checks);
return results.filter(Boolean);
}
/**
* Fetch comprehensive wallet data for a single chain.
* Sequential within the same chain to respect per-server rate limits.
* Returns { info, balances, outgoing, incoming }
*/
async function fetchChainData(address, chainId) {
const info = await getSafeInfo(address, chainId);
const balances = await getBalances(address, chainId);
const outgoing = await getAllMultisigTransactions(address, chainId);
const incoming = await getAllIncomingTransfers(address, chainId);
return { chainId, info, balances, outgoing, incoming };
}
/**
* Fetch wallet data across all detected chains.
* Parallel across chains (different servers), sequential within each chain.
* Returns Map<chainId, chainData>
*/
async function fetchAllChainsData(address, detectedChains) {
const dataMap = new Map();
const fetches = detectedChains.map(async ({ chainId }) => {
const data = await fetchChainData(address, chainId);
dataMap.set(chainId, data);
});
await Promise.all(fetches);
return dataMap;
}
// ─── Public API ────────────────────────────────────────────────
return {
CHAINS,
getChain,
getSafeInfo,
getBalances,
getAllMultisigTransactions,
getAllIncomingTransfers,
getAllTransactions,
detectSafeChains,
fetchChainData,
fetchAllChainsData,
};
})();