Skip to content

Commit 31a11e9

Browse files
authored
feat(swap): resolve arbitrary ERC-20 addresses in token picker (#98)
feat(swap): resolve arbitrary ERC-20 addresses in token picker
2 parents 3b60541 + 8d9be71 commit 31a11e9

3 files changed

Lines changed: 201 additions & 6 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { type NextRequest, NextResponse } from "next/server";
2+
import { getCoin, setApiKey } from "@zoralabs/coins-sdk";
3+
import { getAddress, isAddress } from "viem";
4+
5+
const ALCHEMY_RPC_BASES: Record<string, string> = {
6+
"8453": "https://base-mainnet.g.alchemy.com/v2",
7+
"1": "https://eth-mainnet.g.alchemy.com/v2",
8+
"10": "https://opt-mainnet.g.alchemy.com/v2",
9+
"42161": "https://arb-mainnet.g.alchemy.com/v2",
10+
};
11+
12+
const TRUSTWALLET_CHAIN_NAMES: Record<string, string> = {
13+
"8453": "base",
14+
"1": "ethereum",
15+
"10": "optimism",
16+
"42161": "arbitrum",
17+
};
18+
19+
export interface LookedUpToken {
20+
address: string;
21+
symbol: string;
22+
name: string;
23+
decimals: number;
24+
logoUrl: string | null;
25+
}
26+
27+
export async function GET(req: NextRequest) {
28+
const { searchParams } = new URL(req.url);
29+
const address = searchParams.get("address");
30+
const chainId = searchParams.get("chainId") ?? "8453";
31+
32+
if (!address || !isAddress(address)) {
33+
return NextResponse.json({ error: "Invalid address" }, { status: 400 });
34+
}
35+
36+
const alchemyKey = process.env.ALCHEMY_API_KEY;
37+
if (!alchemyKey) {
38+
return NextResponse.json({ error: "Not configured" }, { status: 500 });
39+
}
40+
41+
const rpcBase = ALCHEMY_RPC_BASES[chainId];
42+
if (!rpcBase) {
43+
return NextResponse.json({ error: "Unsupported chain" }, { status: 400 });
44+
}
45+
46+
const checksumAddr = getAddress(address);
47+
const rpcUrl = `${rpcBase}/${alchemyKey}`;
48+
49+
// Fetch Alchemy metadata and Zora coin data in parallel.
50+
// Zora is only attempted on Base where creator coins live.
51+
const [metaRes, zoraToken] = await Promise.all([
52+
fetch(rpcUrl, {
53+
method: "POST",
54+
headers: { "Content-Type": "application/json" },
55+
body: JSON.stringify({
56+
id: 1,
57+
jsonrpc: "2.0",
58+
method: "alchemy_getTokenMetadata",
59+
params: [checksumAddr],
60+
}),
61+
next: { revalidate: 3600 },
62+
}),
63+
chainId === "8453"
64+
? (async () => {
65+
try {
66+
const key = process.env.NEXT_PUBLIC_ZORA_API_KEY;
67+
if (key) setApiKey(key);
68+
const res = await getCoin({ address: checksumAddr, chain: 8453 });
69+
return res?.data?.zora20Token ?? null;
70+
} catch {
71+
return null;
72+
}
73+
})()
74+
: Promise.resolve(null),
75+
]);
76+
77+
if (!metaRes.ok) {
78+
return NextResponse.json({ error: "Metadata request failed" }, { status: 502 });
79+
}
80+
81+
const meta: { name: string | null; symbol: string | null; decimals: number | null; logo: string | null } =
82+
(await metaRes.json())?.result ?? {};
83+
84+
if (!meta.symbol || !meta.name || meta.decimals == null) {
85+
return NextResponse.json({ error: "Not a valid ERC-20 token" }, { status: 404 });
86+
}
87+
88+
// Logo priority: Zora media → Alchemy logo → TrustWallet CDN.
89+
let logoUrl: string | null = null;
90+
91+
if (zoraToken?.mediaContent?.previewImage) {
92+
const preview = zoraToken.mediaContent.previewImage;
93+
const raw =
94+
typeof preview === "object"
95+
? (preview as Record<string, string>)?.medium ?? (preview as Record<string, string>)?.small
96+
: (preview as string | undefined);
97+
if (raw) {
98+
logoUrl = raw.startsWith("ipfs://") ? raw.replace("ipfs://", "https://dweb.link/ipfs/") : raw;
99+
}
100+
}
101+
102+
if (!logoUrl) logoUrl = meta.logo ?? null;
103+
104+
if (!logoUrl) {
105+
const twChain = TRUSTWALLET_CHAIN_NAMES[chainId];
106+
if (twChain) {
107+
logoUrl = `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/${twChain}/assets/${checksumAddr}/logo.png`;
108+
}
109+
}
110+
111+
return NextResponse.json({
112+
address: checksumAddr,
113+
symbol: meta.symbol,
114+
name: meta.name,
115+
decimals: meta.decimals,
116+
logoUrl,
117+
} satisfies LookedUpToken);
118+
}

src/app/swap/SwapWidget.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ArrowRight, Check, ChevronDown, Info, Loader2, Search } from "lucide-re
77
import { toast } from "sonner";
88
import { prepareContractCall, prepareTransaction, sendTransaction, waitForReceipt } from "thirdweb";
99
import { useActiveWallet, useActiveWalletChain } from "thirdweb/react";
10-
import { formatUnits, maxUint256, parseUnits, type Address, type Hex } from "viem";
10+
import { formatUnits, isAddress, maxUint256, parseUnits, type Address, type Hex } from "viem";
1111
import { Button } from "@/components/ui/button";
1212
import { Checkbox } from "@/components/ui/checkbox";
1313
import {
@@ -27,7 +27,7 @@ import { ensureOnChain, normalizeTxError } from "@/lib/thirdweb-tx";
2727
import { cn } from "@/lib/utils";
2828
import { getDefaultPair, NATIVE_TOKEN, type SwapToken } from "./chains";
2929
import { useSwapChain } from "./SwapChainContext";
30-
import { useWalletTokens } from "./useWalletTokens";
30+
import { useTokenLookup, useWalletTokens } from "./useWalletTokens";
3131
import {
3232
formatBalanceDisplay,
3333
useAllTokenBalances,
@@ -192,6 +192,11 @@ function TokenPicker({
192192
tokens,
193193
});
194194

195+
// When the query looks like a contract address and nothing in the list
196+
// matches, resolve it via Alchemy metadata + Zora (on Base).
197+
const queryTrimmed = query.trim();
198+
const lookup = useTokenLookup({ address: queryTrimmed, chainId: chain.id });
199+
195200
// Show the first 4 tokens of the chain as the "popular" row — chain
196201
// registries are ordered to put the staples first.
197202
const popular = React.useMemo(() => tokens.slice(0, 4), [tokens]);
@@ -295,9 +300,7 @@ function TokenPicker({
295300
)}
296301

297302
<div className="-mx-6 max-h-80 overflow-y-auto border-t">
298-
{filtered.length === 0 ? (
299-
<p className="py-8 text-center text-sm text-muted-foreground">No tokens found</p>
300-
) : (
303+
{filtered.length > 0 ? (
301304
filtered.map((t) => {
302305
const isSelected = t.address === value.address;
303306
const isExcluded = t.address === exclude;
@@ -335,6 +338,43 @@ function TokenPicker({
335338
</button>
336339
);
337340
})
341+
) : isAddress(queryTrimmed) ? (
342+
// Address pasted but not in token list — resolve it on-the-fly.
343+
lookup.isLoading ? (
344+
<p className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
345+
<Loader2 className="h-4 w-4 animate-spin" />
346+
Resolving token…
347+
</p>
348+
) : lookup.data ? (
349+
<button
350+
type="button"
351+
disabled={lookup.data.address === exclude}
352+
onClick={() => choose(lookup.data!)}
353+
className={cn(
354+
"flex w-full items-center gap-3 px-6 py-2.5 text-left transition-colors",
355+
lookup.data.address === exclude
356+
? "cursor-not-allowed opacity-40"
357+
: "hover:bg-accent",
358+
)}
359+
>
360+
<TokenLogo token={lookup.data} size={32} chainId={chain.id} />
361+
<div className="min-w-0 flex-1">
362+
<div className="flex items-baseline gap-2">
363+
<span className="text-sm font-semibold">{lookup.data.symbol}</span>
364+
<span className="truncate text-xs text-muted-foreground">{lookup.data.name}</span>
365+
</div>
366+
<p className="truncate font-mono text-[10px] text-muted-foreground">
367+
{`${lookup.data.address.slice(0, 6)}${lookup.data.address.slice(-4)}`}
368+
</p>
369+
</div>
370+
</button>
371+
) : (
372+
<p className="py-8 text-center text-sm text-muted-foreground">
373+
No ERC-20 token found at this address
374+
</p>
375+
)
376+
) : (
377+
<p className="py-8 text-center text-sm text-muted-foreground">No tokens found</p>
338378
)}
339379
</div>
340380
</div>

src/app/swap/useWalletTokens.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import * as React from "react";
44
import { useQuery, useQueryClient } from "@tanstack/react-query";
5-
import { type Address } from "viem";
5+
import { isAddress, type Address } from "viem";
66
import { type SwapChain, type SwapToken, type WalletToken, NATIVE_TOKEN } from "./chains";
77

88
/**
@@ -91,3 +91,40 @@ export function useWalletTokens({
9191

9292
return { tokens: mergedTokens, usdValues, isLoading: query.isLoading };
9393
}
94+
95+
/**
96+
* Resolves an arbitrary ERC-20 address to a SwapToken by calling
97+
* /api/wallet/token-lookup (Alchemy metadata + Zora image on Base).
98+
*
99+
* Only fires when `address` is a valid 0x address. Results are cached
100+
* for 24 h — token metadata is stable.
101+
*/
102+
export function useTokenLookup({
103+
address,
104+
chainId,
105+
}: {
106+
address: string;
107+
chainId: number;
108+
}) {
109+
return useQuery<SwapToken | null>({
110+
queryKey: ["token-lookup", chainId, address.toLowerCase()],
111+
enabled: isAddress(address),
112+
staleTime: 24 * 60 * 60 * 1000,
113+
retry: false,
114+
queryFn: async () => {
115+
const res = await fetch(
116+
`/api/wallet/token-lookup?address=${address}&chainId=${chainId}`,
117+
);
118+
if (!res.ok) return null;
119+
const data = await res.json();
120+
if (!data?.symbol) return null;
121+
return {
122+
address: data.address as `0x${string}`,
123+
symbol: data.symbol,
124+
name: data.name,
125+
decimals: data.decimals,
126+
logo: data.logoUrl ?? undefined,
127+
};
128+
},
129+
});
130+
}

0 commit comments

Comments
 (0)