Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ generated
*.yaml
*.xml
*.go
.yarn
8 changes: 2 additions & 6 deletions go/coinstacks/cosmos/api/railway.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,4 +14,4 @@
}
}
}
}
}
8 changes: 2 additions & 6 deletions go/coinstacks/mayachain/api/railway.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,4 +14,4 @@
}
}
}
}
}
8 changes: 2 additions & 6 deletions go/coinstacks/thorchain-v1/api/railway.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,4 +14,4 @@
}
}
}
}
}
8 changes: 2 additions & 6 deletions go/coinstacks/thorchain/api/railway.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,4 +14,4 @@
}
}
}
}
}
4 changes: 4 additions & 0 deletions node/proxy/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
180 changes: 180 additions & 0 deletions node/proxy/api/src/tokenMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<string, ChainConfig> = {
'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<Record<string, TokenMetadataPayload>> = {}

async handler(req: Request, res: Response): Promise<void> {
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' })
}
}
}
}
5 changes: 4 additions & 1 deletion node/proxy/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ ELLIPTIC_API_SECRET=
COINCAP_API_KEY=
COINGECKO_API_KEY=
ZERION_API_KEY=
ZRX_API_KEY=
ZRX_API_KEY=

# Alchemy key for token metadata lookups (EVM + Solana)
ALCHEMY_API_KEY=
Loading