Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@
}
}
}
}
}
1 change: 1 addition & 0 deletions node/proxy/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
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=
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading