diff --git a/.prettierignore b/.prettierignore index 5422abbfd..e36ed9bde 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,4 @@ generated *.yaml *.xml *.go +.yarn diff --git a/go/coinstacks/cosmos/api/railway.json b/go/coinstacks/cosmos/api/railway.json index 772399f8c..a49c25c09 100644 --- a/go/coinstacks/cosmos/api/railway.json +++ b/go/coinstacks/cosmos/api/railway.json @@ -2,11 +2,7 @@ "build": { "builder": "dockerfile", "dockerfilePath": "go/coinstacks/cosmos/build/Dockerfile", - "watchPatterns": [ - "go/shared/**", - "go/pkg/cosmos/**", - "go/coinstacks/cosmos/**" - ] + "watchPatterns": ["go/shared/**", "go/pkg/cosmos/**", "go/coinstacks/cosmos/**"] }, "deploy": { "startCommand": "/go/bin/app -swagger swagger.json -swaggerui static/swaggerui", @@ -18,4 +14,4 @@ } } } -} \ No newline at end of file +} diff --git a/go/coinstacks/mayachain/api/railway.json b/go/coinstacks/mayachain/api/railway.json index 692ee9b52..174131ae1 100644 --- a/go/coinstacks/mayachain/api/railway.json +++ b/go/coinstacks/mayachain/api/railway.json @@ -2,11 +2,7 @@ "build": { "builder": "dockerfile", "dockerfilePath": "go/coinstacks/mayachain/build/Dockerfile", - "watchPatterns": [ - "go/shared/**", - "go/pkg/mayachain/**", - "go/coinstacks/mayachain/**" - ] + "watchPatterns": ["go/shared/**", "go/pkg/mayachain/**", "go/coinstacks/mayachain/**"] }, "deploy": { "startCommand": "/go/bin/app -swagger swagger.json -swaggerui static/swaggerui", @@ -18,4 +14,4 @@ } } } -} \ No newline at end of file +} diff --git a/go/coinstacks/thorchain-v1/api/railway.json b/go/coinstacks/thorchain-v1/api/railway.json index ca52335eb..c8fca73f4 100644 --- a/go/coinstacks/thorchain-v1/api/railway.json +++ b/go/coinstacks/thorchain-v1/api/railway.json @@ -2,11 +2,7 @@ "build": { "builder": "dockerfile", "dockerfilePath": "go/coinstacks/thorchain-v1/build/Dockerfile", - "watchPatterns": [ - "go/shared/**", - "go/pkg/thorchain-v1/**", - "go/coinstacks/thorchain-v1/**" - ] + "watchPatterns": ["go/shared/**", "go/pkg/thorchain-v1/**", "go/coinstacks/thorchain-v1/**"] }, "deploy": { "startCommand": "/go/bin/app -swagger swagger.json -swaggerui static/swaggerui", @@ -18,4 +14,4 @@ } } } -} \ No newline at end of file +} diff --git a/go/coinstacks/thorchain/api/railway.json b/go/coinstacks/thorchain/api/railway.json index ec7570b72..cfb035b46 100644 --- a/go/coinstacks/thorchain/api/railway.json +++ b/go/coinstacks/thorchain/api/railway.json @@ -2,11 +2,7 @@ "build": { "builder": "dockerfile", "dockerfilePath": "go/coinstacks/thorchain/build/Dockerfile", - "watchPatterns": [ - "go/shared/**", - "go/pkg/thorchain/**", - "go/coinstacks/thorchain/**" - ] + "watchPatterns": ["go/shared/**", "go/pkg/thorchain/**", "go/coinstacks/thorchain/**"] }, "deploy": { "startCommand": "/go/bin/app -swagger swagger.json -swaggerui static/swaggerui", @@ -18,4 +14,4 @@ } } } -} \ No newline at end of file +} diff --git a/node/proxy/api/package.json b/node/proxy/api/package.json index 86e7e68b8..cd7c375b6 100644 --- a/node/proxy/api/package.json +++ b/node/proxy/api/package.json @@ -14,6 +14,7 @@ "dependencies": { "@shapeshiftoss/common-api": "^10.0.0", "@shapeshiftoss/prometheus": "^10.0.0", + "@solana/web3.js": "^1.95.3", "bottleneck": "^2.19.5", "fast-xml-parser": "^4.3.0" } diff --git a/node/proxy/api/src/app.ts b/node/proxy/api/src/app.ts index 063b8cc25..9864792a6 100644 --- a/node/proxy/api/src/app.ts +++ b/node/proxy/api/src/app.ts @@ -13,6 +13,7 @@ import { Portals } from './portals' import { MarketDataConnectionHandler } from './marketData' import { CoincapWebsocketClient } from './coincap' import { Ofac } from './ofac' +import { TokenMetadata } from './tokenMetadata' const PORT = process.env.PORT ?? 3000 const COINCAP_API_KEY = process.env.COINCAP_API_KEY @@ -65,6 +66,9 @@ const main = async () => { const portals = new Portals() app.get('/api/v1/portals/*', portals.handler.bind(portals)) + const tokenMetadata = new TokenMetadata() + app.get('/api/v1/tokens/metadata', tokenMetadata.handler.bind(tokenMetadata)) + // redirect any unmatched routes to docs app.get('/', async (_, res) => { res.redirect('/docs') diff --git a/node/proxy/api/src/tokenMetadata.ts b/node/proxy/api/src/tokenMetadata.ts new file mode 100644 index 000000000..14130c76d --- /dev/null +++ b/node/proxy/api/src/tokenMetadata.ts @@ -0,0 +1,180 @@ +import { PublicKey } from '@solana/web3.js' +import axios, { isAxiosError } from 'axios' +import type { Request, Response } from 'express' +import { isAddress } from 'viem' + +const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY + +if (!ALCHEMY_API_KEY) throw new Error('ALCHEMY_API_KEY env var not set') + +interface EvmResult { + name: string + symbol: string + decimals: number + logo: string +} + +interface SolanaResult { + content?: { + metadata?: { name?: string; symbol?: string } + links?: { image?: string } + files?: Array<{ uri?: string; mime?: string }> + } + token_info?: { symbol?: string; decimals?: number } +} + +interface TokenMetadataPayload { + name: string + symbol: string + decimals: number | null + logo: string | null +} + +interface ChainConfig { + url: string + method: string + params: (tokenAddress: string) => unknown + parse: (r: unknown) => TokenMetadataPayload + validateAddress: (tokenAddress: string) => boolean +} + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 + +const parseEvm = (r: unknown): TokenMetadataPayload => { + const { name, symbol, decimals, logo } = r as EvmResult + return { name, symbol, decimals: decimals ?? null, logo: logo ?? null } +} + +const parseSolana = (r: unknown): TokenMetadataPayload => { + const { content, token_info } = r as SolanaResult + return { + name: content?.metadata?.name ?? '', + symbol: content?.metadata?.symbol ?? token_info?.symbol ?? '', + decimals: token_info?.decimals ?? null, + logo: content?.links?.image ?? content?.files?.find((f) => f.mime?.startsWith('image/'))?.uri ?? null, + } +} + +const isValidSolanaAddress = (address: string): boolean => { + try { + new PublicKey(address) + return true + } catch { + return false + } +} + +const CHAIN_CONFIGS: Record = { + 'eip155:1': { + url: `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + method: 'alchemy_getTokenMetadata', + params: (a) => [a], + parse: parseEvm, + validateAddress: isAddress, + }, + 'eip155:10': { + url: `https://opt-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + method: 'alchemy_getTokenMetadata', + params: (a) => [a], + parse: parseEvm, + validateAddress: isAddress, + }, + 'eip155:137': { + url: `https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + method: 'alchemy_getTokenMetadata', + params: (a) => [a], + parse: parseEvm, + validateAddress: isAddress, + }, + 'eip155:8453': { + url: `https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + method: 'alchemy_getTokenMetadata', + params: (a) => [a], + parse: parseEvm, + validateAddress: isAddress, + }, + 'eip155:42161': { + url: `https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + method: 'alchemy_getTokenMetadata', + params: (a) => [a], + parse: parseEvm, + validateAddress: isAddress, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + url: `https://solana-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, + method: 'getAsset', + params: (a) => ({ id: a }), + parse: parseSolana, + validateAddress: isValidSolanaAddress, + }, +} + +export class TokenMetadata { + private axiosInstance = axios.create({ timeout: 10_000 }) + private requestCache: Partial> = {} + + async handler(req: Request, res: Response): Promise { + const { chainId, tokenAddress } = req.query + + if (typeof chainId !== 'string' || !chainId) { + res.status(400).json({ error: 'chainId is required' }) + return + } + + if (typeof tokenAddress !== 'string' || !tokenAddress) { + res.status(400).json({ error: 'tokenAddress is required' }) + return + } + + const config = CHAIN_CONFIGS[chainId] + if (!config) { + res.status(422).json({ error: 'Unsupported chainId', supported: Object.keys(CHAIN_CONFIGS) }) + return + } + + if (!config.validateAddress(tokenAddress)) { + res.status(422).json({ error: 'Invalid tokenAddress' }) + return + } + + const cacheKey = `${chainId}:${tokenAddress}` + const cached = this.requestCache[cacheKey] + if (cached) { + res.set('X-Cache', 'HIT').json({ chainId, tokenAddress, ...cached }) + return + } + + try { + const { data } = await this.axiosInstance.post(config.url, { + jsonrpc: '2.0', + id: crypto.randomUUID(), + method: config.method, + params: config.params(tokenAddress), + }) + + if (data.error?.message) { + res.status(502).json({ error: data.error.message }) + return + } + + const metadata = config.parse(data.result) + if (!metadata.name && !metadata.symbol) { + res.status(404).json({ error: 'Token not found' }) + return + } + + this.requestCache[cacheKey] = metadata + setTimeout(() => delete this.requestCache[cacheKey], CACHE_TTL_MS) + + res.set('X-Cache', 'MISS').json({ chainId, tokenAddress, ...metadata }) + } catch (err) { + if (isAxiosError(err)) { + res.status(502).json({ error: err.message || 'Upstream request failed' }) + } else if (err instanceof Error) { + res.status(500).json({ error: err.message || 'Internal server error' }) + } else { + res.status(500).json({ error: 'Internal server error' }) + } + } + } +} diff --git a/node/proxy/sample.env b/node/proxy/sample.env index 6f43cb680..1d33907e0 100644 --- a/node/proxy/sample.env +++ b/node/proxy/sample.env @@ -4,4 +4,7 @@ ELLIPTIC_API_SECRET= COINCAP_API_KEY= COINGECKO_API_KEY= ZERION_API_KEY= -ZRX_API_KEY= \ No newline at end of file +ZRX_API_KEY= + +# Alchemy key for token metadata lookups (EVM + Solana) +ALCHEMY_API_KEY= diff --git a/yarn.lock b/yarn.lock index 2b574e54f..bd77cceb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1962,6 +1962,7 @@ __metadata: dependencies: "@shapeshiftoss/common-api": "npm:^10.0.0" "@shapeshiftoss/prometheus": "npm:^10.0.0" + "@solana/web3.js": "npm:^1.95.3" bottleneck: "npm:^2.19.5" fast-xml-parser: "npm:^4.3.0" languageName: unknown