feat: add EOA wallet support to rWallet for any address

Previously rWallet only supported Safe multisig wallets. Now it falls
back to EOA detection via public RPC endpoints when no Safe is found,
allowing any Ethereum address to view native balances across chains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-06 23:17:46 -08:00
parent c2f9a07389
commit 5f3ffcc8d2
2 changed files with 134 additions and 10 deletions

View File

@ -42,6 +42,7 @@ class FolkWalletViewer extends HTMLElement {
private loading = false; private loading = false;
private error = ""; private error = "";
private isDemo = false; private isDemo = false;
private walletType: "safe" | "eoa" | "" = "";
constructor() { constructor() {
super(); super();
@ -102,10 +103,13 @@ class FolkWalletViewer extends HTMLElement {
this.error = ""; this.error = "";
this.detectedChains = []; this.detectedChains = [];
this.balances = []; this.balances = [];
this.walletType = "";
this.render(); this.render();
try { try {
const base = this.getApiBase(); const base = this.getApiBase();
// Try Safe detection first
const res = await fetch(`${base}/api/safe/detect/${this.address}`); const res = await fetch(`${base}/api/safe/detect/${this.address}`);
const data = await res.json(); const data = await res.json();
@ -114,15 +118,30 @@ class FolkWalletViewer extends HTMLElement {
color: CHAIN_COLORS[c.chainId] || "#888", color: CHAIN_COLORS[c.chainId] || "#888",
})); }));
if (this.detectedChains.length === 0) { if (this.detectedChains.length > 0) {
this.error = "No Safe wallets found for this address on any supported chain."; this.walletType = "safe";
} else {
// Auto-select first chain
this.selectedChain = this.detectedChains[0].chainId; this.selectedChain = this.detectedChains[0].chainId;
await this.loadBalances(); await this.loadBalances();
} else {
// Fall back to EOA detection (any wallet)
const eoaRes = await fetch(`${base}/api/eoa/detect/${this.address}`);
const eoaData = await eoaRes.json();
this.detectedChains = (eoaData.chains || []).map((c: any) => ({
...c,
color: CHAIN_COLORS[c.chainId] || "#888",
}));
if (this.detectedChains.length > 0) {
this.walletType = "eoa";
this.selectedChain = this.detectedChains[0].chainId;
await this.loadBalances();
} else {
this.error = "No balances found for this address on any supported chain.";
}
} }
} catch (e) { } catch (e) {
this.error = "Failed to detect chains. Check the address and try again."; this.error = "Failed to detect wallet. Check the address and try again.";
} }
this.loading = false; this.loading = false;
@ -134,7 +153,10 @@ class FolkWalletViewer extends HTMLElement {
if (!this.selectedChain) return; if (!this.selectedChain) return;
try { try {
const base = this.getApiBase(); const base = this.getApiBase();
const res = await fetch(`${base}/api/safe/${this.selectedChain}/${this.address}/balances`); const apiPath = this.walletType === "safe"
? `${base}/api/safe/${this.selectedChain}/${this.address}/balances`
: `${base}/api/eoa/${this.selectedChain}/${this.address}/balances`;
const res = await fetch(apiPath);
if (res.ok) { if (res.ok) {
this.balances = await res.json(); this.balances = await res.json();
} }
@ -254,20 +276,21 @@ class FolkWalletViewer extends HTMLElement {
</style> </style>
<form class="address-bar" id="address-form"> <form class="address-bar" id="address-form">
<input id="address-input" type="text" placeholder="Enter Safe address (0x...)" <input id="address-input" type="text" placeholder="Enter wallet address (0x...)"
value="${this.address}" spellcheck="false"> value="${this.address}" spellcheck="false">
<button type="submit">Load</button> <button type="submit">Load</button>
</form> </form>
${!this.address && !this.loading ? ` ${!this.address && !this.loading ? `
<div class="empty"> <div class="empty">
<p style="font-size:16px;margin-bottom:8px">Enter a Safe wallet address to visualize</p> <p style="font-size:16px;margin-bottom:8px">Enter any wallet address to visualize</p>
<p>Try: <span class="demo-link" data-address="0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1">TEC Commons Fund</span></p> <p>Supports Safe multisigs and regular wallets (EOA)</p>
<p style="margin-top:8px">Try: <span class="demo-link" data-address="0x29567BdBcC92aCF37AC6B56B69180857bB69f7D1">TEC Commons Fund (Safe)</span></p>
</div> </div>
` : ""} ` : ""}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""} ${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading">Detecting Safe wallets across chains...</div>' : ""} ${this.loading ? '<div class="loading">Detecting wallet across chains...</div>' : ""}
${!this.loading && this.detectedChains.length > 0 ? ` ${!this.loading && this.detectedChains.length > 0 ? `
<div class="chains"> <div class="chains">

View File

@ -92,6 +92,48 @@ function getSafePrefix(chainId: string): string | null {
return CHAIN_MAP[chainId]?.prefix || null; return CHAIN_MAP[chainId]?.prefix || null;
} }
// ── Public RPC endpoints for EOA balance lookups ──
const RPC_URLS: Record<string, string> = {
"1": "https://eth.llamarpc.com",
"10": "https://mainnet.optimism.io",
"100": "https://rpc.gnosischain.com",
"137": "https://polygon-rpc.com",
"8453": "https://mainnet.base.org",
"42161": "https://arb1.arbitrum.io/rpc",
"42220": "https://forno.celo.org",
"43114": "https://api.avax.network/ext/bc/C/rpc",
"56": "https://bsc-dataseed.binance.org",
"324": "https://mainnet.era.zksync.io",
"11155111": "https://rpc.sepolia.org",
"84532": "https://sepolia.base.org",
};
const NATIVE_TOKENS: Record<string, { name: string; symbol: string; decimals: number }> = {
"1": { name: "Ether", symbol: "ETH", decimals: 18 },
"10": { name: "Ether", symbol: "ETH", decimals: 18 },
"100": { name: "xDAI", symbol: "xDAI", decimals: 18 },
"137": { name: "MATIC", symbol: "MATIC", decimals: 18 },
"8453": { name: "Ether", symbol: "ETH", decimals: 18 },
"42161": { name: "Ether", symbol: "ETH", decimals: 18 },
"42220": { name: "CELO", symbol: "CELO", decimals: 18 },
"43114": { name: "AVAX", symbol: "AVAX", decimals: 18 },
"56": { name: "BNB", symbol: "BNB", decimals: 18 },
"324": { name: "Ether", symbol: "ETH", decimals: 18 },
"11155111": { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
"84532": { name: "Base Sepolia ETH", symbol: "ETH", decimals: 18 },
};
async function rpcCall(rpcUrl: string, method: string, params: any[]): Promise<any> {
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
return data.result;
}
// ── Safe Proposal / Confirm / Execute (EncryptID-authenticated) ── // ── Safe Proposal / Confirm / Execute (EncryptID-authenticated) ──
// Helper: extract and verify JWT from request // Helper: extract and verify JWT from request
@ -271,6 +313,65 @@ routes.post("/api/safe/:chainId/:address/execute", async (c) => {
}); });
}); });
// ── EOA (any wallet) balance endpoints ──
// Detect which chains have a non-zero native balance for any address
routes.get("/api/eoa/detect/:address", async (c) => {
const address = c.req.param("address");
const results: Array<{ chainId: string; name: string; prefix: string; balance: string }> = [];
await Promise.allSettled(
Object.entries(CHAIN_MAP).map(async ([chainId, info]) => {
const rpcUrl = RPC_URLS[chainId];
if (!rpcUrl) return;
try {
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
if (balHex && balHex !== "0x0" && balHex !== "0x") {
results.push({ chainId, name: info.name, prefix: info.prefix, balance: balHex });
}
} catch {}
})
);
return c.json({ address, chains: results.sort((a, b) => a.name.localeCompare(b.name)) });
});
// Get native token balance for an EOA on a specific chain
routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
const chainId = c.req.param("chainId");
const address = c.req.param("address");
const rpcUrl = RPC_URLS[chainId];
if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400);
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };
const balances: BalanceItem[] = [];
try {
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
const balWei = BigInt(balHex || "0x0");
if (balWei > 0n) {
balances.push({
tokenAddress: null,
token: nativeToken,
balance: balWei.toString(),
fiatBalance: "0",
fiatConversion: "0",
});
}
} catch {}
c.header("Cache-Control", "public, max-age=30");
return c.json(balances);
});
interface BalanceItem {
tokenAddress: string | null;
token: { name: string; symbol: string; decimals: number };
balance: string;
fiatBalance: string;
fiatConversion: string;
}
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo"; const spaceSlug = c.req.param("space") || "demo";