fix(rwallet): migrate to new Safe Global API (api.safe.global)

Safe Global deprecated per-chain subdomains (safe-transaction-*.safe.global)
in favour of api.safe.global/tx-service/{shortcode}. The old URLs now 308
redirect, and the /balances/usd/ endpoint no longer exists.

- Update CHAIN_MAP prefixes to new shortcodes (eth, oeth, gno, etc.)
- Switch all Safe API calls to new base URL
- Use /balances/ instead of /balances/usd/ (fiat data no longer available)
- Normalize balance response with native token info and placeholder fiat fields
- Update client to show tokens with non-zero balance even without fiat data
- Update encryptid/server.ts Safe verify endpoint to new API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 13:57:09 -07:00
parent f9ccd18f15
commit f5388ecc2c
3 changed files with 43 additions and 26 deletions

View File

@ -492,7 +492,7 @@ class FolkWalletViewer extends HTMLElement {
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Tokens</div> <div class="stat-label">Tokens</div>
<div class="stat-value">${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0).length}</div> <div class="stat-value">${this.balances.filter((b) => parseFloat(b.fiatBalance || "0") > 0 || BigInt(b.balance || "0") > 0n).length}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Chains</div> <div class="stat-label">Chains</div>
@ -511,8 +511,12 @@ class FolkWalletViewer extends HTMLElement {
</thead> </thead>
<tbody> <tbody>
${this.balances ${this.balances
.filter((b) => parseFloat(b.fiatBalance || "0") > 0.01) .filter((b) => parseFloat(b.fiatBalance || "0") > 0.01 || BigInt(b.balance || "0") > 0n)
.sort((a, b) => parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0")) .sort((a, b) => {
const fiatDiff = parseFloat(b.fiatBalance || "0") - parseFloat(a.fiatBalance || "0");
if (fiatDiff !== 0) return fiatDiff;
return Number(BigInt(b.balance || "0") - BigInt(a.balance || "0"));
})
.map((b) => ` .map((b) => `
<tr> <tr>
<td> <td>

View File

@ -20,9 +20,18 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => {
const chainPrefix = getSafePrefix(chainId); const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/balances/usd/?trusted=true&exclude_spam=true`); const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`);
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
const data = await res.json(); const raw = await res.json() as any[];
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
// Normalize: fill in native token info and ensure fiatBalance fields exist
const data = raw.map((item: any) => ({
tokenAddress: item.tokenAddress,
token: item.token || nativeToken,
balance: item.balance || "0",
fiatBalance: item.fiatBalance || "0",
fiatConversion: item.fiatConversion || "0",
}));
c.header("Cache-Control", "public, max-age=30"); c.header("Cache-Control", "public, max-age=30");
return c.json(data); return c.json(data);
}); });
@ -34,7 +43,7 @@ routes.get("/api/safe/:chainId/:address/transfers", async (c) => {
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
const limit = c.req.query("limit") || "100"; const limit = c.req.query("limit") || "100";
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/all-transactions/?limit=${limit}&executed=true`); const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/all-transactions/?limit=${limit}&executed=true`);
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
return c.json(await res.json()); return c.json(await res.json());
}); });
@ -45,7 +54,7 @@ routes.get("/api/safe/:chainId/:address/info", async (c) => {
const chainPrefix = getSafePrefix(chainId); const chainPrefix = getSafePrefix(chainId);
if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400); if (!chainPrefix) return c.json({ error: "Unsupported chain" }, 400);
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/`); const res = await fetch(`${safeApiBase(chainPrefix)}/safes/${address}/`);
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any); if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
const data = await res.json(); const data = await res.json();
c.header("Cache-Control", "public, max-age=300"); c.header("Cache-Control", "public, max-age=300");
@ -62,7 +71,7 @@ routes.get("/api/safe/detect/:address", async (c) => {
await Promise.allSettled( await Promise.allSettled(
chains.map(async ([chainId, info]) => { chains.map(async ([chainId, info]) => {
try { try {
const res = await fetch(`https://safe-transaction-${info.prefix}.safe.global/api/v1/safes/${address}/`, { const res = await fetch(`${safeApiBase(info.prefix)}/safes/${address}/`, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
if (res.ok) results.push({ chainId, name: info.name, prefix: info.prefix }); if (res.ok) results.push({ chainId, name: info.name, prefix: info.prefix });
@ -82,20 +91,20 @@ routes.get("/api/safe/detect/:address", async (c) => {
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) }); return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
}); });
// ── Chain mapping ── // ── Chain mapping (prefix = Safe TX Service shortcode) ──
const CHAIN_MAP: Record<string, { name: string; prefix: string }> = { const CHAIN_MAP: Record<string, { name: string; prefix: string }> = {
"1": { name: "Ethereum", prefix: "mainnet" }, "1": { name: "Ethereum", prefix: "eth" },
"10": { name: "Optimism", prefix: "optimism" }, "10": { name: "Optimism", prefix: "oeth" },
"100": { name: "Gnosis", prefix: "gnosis-chain" }, "100": { name: "Gnosis", prefix: "gno" },
"137": { name: "Polygon", prefix: "polygon" }, "137": { name: "Polygon", prefix: "pol" },
"8453": { name: "Base", prefix: "base" }, "8453": { name: "Base", prefix: "base" },
"42161": { name: "Arbitrum", prefix: "arbitrum" }, "42161": { name: "Arbitrum", prefix: "arb1" },
"42220": { name: "Celo", prefix: "celo" }, "42220": { name: "Celo", prefix: "celo" },
"43114": { name: "Avalanche", prefix: "avalanche" }, "43114": { name: "Avalanche", prefix: "avax" },
"56": { name: "BSC", prefix: "bsc" }, "56": { name: "BSC", prefix: "bnb" },
"324": { name: "zkSync", prefix: "zksync" }, "324": { name: "zkSync", prefix: "zksync" },
"11155111": { name: "Sepolia", prefix: "sepolia" }, "11155111": { name: "Sepolia", prefix: "sep" },
"84532": { name: "Base Sepolia", prefix: "base-sepolia" }, "84532": { name: "Base Sepolia", prefix: "basesep" },
}; };
const TESTNET_CHAIN_IDS = new Set(["11155111", "84532"]); const TESTNET_CHAIN_IDS = new Set(["11155111", "84532"]);
@ -108,6 +117,10 @@ function getSafePrefix(chainId: string): string | null {
return CHAIN_MAP[chainId]?.prefix || null; return CHAIN_MAP[chainId]?.prefix || null;
} }
function safeApiBase(prefix: string): string {
return `https://api.safe.global/tx-service/${prefix}/api/v1`;
}
// ── Public RPC endpoints for EOA balance lookups ── // ── Public RPC endpoints for EOA balance lookups ──
const RPC_URLS: Record<string, string> = { const RPC_URLS: Record<string, string> = {
"1": "https://eth.llamarpc.com", "1": "https://eth.llamarpc.com",
@ -195,7 +208,7 @@ routes.post("/api/safe/:chainId/:address/propose", async (c) => {
// Submit proposal to Safe Transaction Service // Submit proposal to Safe Transaction Service
const res = await fetch( const res = await fetch(
`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/multisig-transactions/`, `${safeApiBase(chainPrefix)}/safes/${address}/multisig-transactions/`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -246,7 +259,7 @@ routes.post("/api/safe/:chainId/:address/confirm", async (c) => {
} }
const res = await fetch( const res = await fetch(
`https://safe-transaction-${chainPrefix}.safe.global/api/v1/multisig-transactions/${safeTxHash}/confirmations/`, `${safeApiBase(chainPrefix)}/multisig-transactions/${safeTxHash}/confirmations/`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -294,7 +307,7 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => {
// Fetch the transaction details from Safe Transaction Service // Fetch the transaction details from Safe Transaction Service
const txRes = await fetch( const txRes = await fetch(
`https://safe-transaction-${chainPrefix}.safe.global/api/v1/multisig-transactions/${safeTxHash}/`, `${safeApiBase(chainPrefix)}/multisig-transactions/${safeTxHash}/`,
); );
if (!txRes.ok) { if (!txRes.ok) {
@ -409,7 +422,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-wallet-viewer></folk-wallet-viewer>`, body: `<folk-wallet-viewer></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=2"></script>`, scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`, styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
})); }));
}); });

View File

@ -2640,9 +2640,9 @@ app.post('/encryptid/api/safe/verify', async (c) => {
const chain = chainId || 84532; const chain = chainId || 84532;
const CHAIN_PREFIXES: Record<number, string> = { const CHAIN_PREFIXES: Record<number, string> = {
1: 'mainnet', 10: 'optimism', 100: 'gnosis-chain', 137: 'polygon', 1: 'eth', 10: 'oeth', 100: 'gno', 137: 'pol',
8453: 'base', 42161: 'arbitrum', 42220: 'celo', 43114: 'avalanche', 8453: 'base', 42161: 'arb1', 42220: 'celo', 43114: 'avax',
56: 'bsc', 324: 'zksync', 11155111: 'sepolia', 84532: 'base-sepolia', 56: 'bnb', 324: 'zksync', 11155111: 'sep', 84532: 'basesep',
}; };
const prefix = CHAIN_PREFIXES[chain]; const prefix = CHAIN_PREFIXES[chain];
if (!prefix) { if (!prefix) {
@ -2651,7 +2651,7 @@ app.post('/encryptid/api/safe/verify', async (c) => {
try { try {
const safeRes = await fetch( const safeRes = await fetch(
`https://safe-transaction-${prefix}.safe.global/api/v1/safes/${safeAddress}/`, `https://api.safe.global/tx-service/${prefix}/api/v1/safes/${safeAddress}/`,
); );
if (!safeRes.ok) { if (!safeRes.ok) {
return c.json({ isOwner: false, error: 'Safe not found' }); return c.json({ isOwner: false, error: 'Safe not found' });