Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion node/proxy/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
"@shapeshiftoss/common-api": "^10.0.0",
"@shapeshiftoss/prometheus": "^10.0.0",
"bottleneck": "^2.19.5",
"elliptic-sdk": "^0.7.2"
"fast-xml-parser": "^4.3.0"
}
}
85 changes: 47 additions & 38 deletions node/proxy/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Zrx } from './zrx'
import { Portals } from './portals'
import { MarketDataConnectionHandler } from './marketData'
import { CoincapWebsocketClient } from './coincap'
import { Ofac } from './ofac'

const PORT = process.env.PORT ?? 3000
const COINCAP_API_KEY = process.env.COINCAP_API_KEY
Expand All @@ -21,59 +22,67 @@ export const logger = new Logger({
level: process.env.LOG_LEVEL,
})

export const ofac = new Ofac({ logger })

const prometheus = new Prometheus({ coinstack: 'proxy' })

const app = express()
const main = async () => {
await ofac.initialize()

app.use(...middleware.common(prometheus))
const app = express()

app.get('/health', async (_, res) => res.json({ status: 'ok' }))
app.use(...middleware.common(prometheus))

app.get('/metrics', async (_, res) => {
res.setHeader('Content-Type', prometheus.register.contentType)
res.send(await prometheus.register.metrics())
})
app.get('/health', async (_, res) => res.json({ status: 'ok' }))

const options: swaggerUi.SwaggerUiOptions = {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'ShapeShift Proxy API Docs',
customfavIcon: '/public/favi-blue.png',
swaggerUrl: '/swagger.json',
}
app.get('/metrics', async (_, res) => {
res.setHeader('Content-Type', prometheus.register.contentType)
res.send(await prometheus.register.metrics())
})

app.use('/public', express.static(join(__dirname, '../../../../../../coinstacks/common/api/public/')))
app.use('/swagger.json', express.static(join(__dirname, './swagger.json')))
app.use('/docs', swaggerUi.serve, swaggerUi.setup(undefined, options))
const options: swaggerUi.SwaggerUiOptions = {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'ShapeShift Proxy API Docs',
customfavIcon: '/public/favi-blue.png',
swaggerUrl: '/swagger.json',
}

RegisterRoutes(app)
app.use('/public', express.static(join(__dirname, '../../../../../../coinstacks/common/api/public/')))
app.use('/swagger.json', express.static(join(__dirname, './swagger.json')))
app.use('/docs', swaggerUi.serve, swaggerUi.setup(undefined, options))

const coingecko = new CoinGecko()
app.get('/api/v1/markets/*', coingecko.handler.bind(coingecko))
RegisterRoutes(app)

const zerion = new Zerion()
app.get('/api/v1/zerion/*', zerion.handler.bind(zerion))
const coingecko = new CoinGecko()
app.get('/api/v1/markets/*', coingecko.handler.bind(coingecko))

const zrx = new Zrx()
app.get('/api/v1/zrx/*', zrx.handler.bind(zrx))
const zerion = new Zerion()
app.get('/api/v1/zerion/*', zerion.handler.bind(zerion))

const portals = new Portals()
app.get('/api/v1/portals/*', portals.handler.bind(portals))
const zrx = new Zrx()
app.get('/api/v1/zrx/*', zrx.handler.bind(zrx))

// redirect any unmatched routes to docs
app.get('/', async (_, res) => {
res.redirect('/docs')
})
const portals = new Portals()
app.get('/api/v1/portals/*', portals.handler.bind(portals))

app.use(middleware.errorHandler, middleware.notFoundHandler)
// redirect any unmatched routes to docs
app.get('/', async (_, res) => {
res.redirect('/docs')
})

const server = app.listen(PORT, () => logger.info('Server started'))
app.use(middleware.errorHandler, middleware.notFoundHandler)

const coincap = new CoincapWebsocketClient(`wss://wss.coincap.io/prices?assets=ALL&apiKey=${COINCAP_API_KEY}`, {
logger,
})
const server = app.listen(PORT, () => logger.info('Server started'))

const wsServer = new Server({ server })
const coincap = new CoincapWebsocketClient(`wss://wss.coincap.io/prices?assets=ALL&apiKey=${COINCAP_API_KEY}`, {
logger,
})

wsServer.on('connection', (connection) => {
MarketDataConnectionHandler.start(connection, coincap, prometheus, logger)
})
const wsServer = new Server({ server })

wsServer.on('connection', (connection) => {
MarketDataConnectionHandler.start(connection, coincap, prometheus, logger)
})
}

main()
6 changes: 2 additions & 4 deletions node/proxy/api/src/controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { Controller, Example, Get, Path, Response, Route, Tags } from 'tsoa'
import { BadRequestError, InternalServerError, ValidationError } from '../../../coinstacks/common/api/src' // unable to import models from a module with tsoa
import { ValidationResult } from './models'
import { Elliptic } from './elliptic'
import { ofac } from './app'
import { handleError } from '@shapeshiftoss/common-api'

const elliptic = new Elliptic()

@Route('api/v1')
export class Proxy extends Controller {
/**
Expand All @@ -23,7 +21,7 @@ export class Proxy extends Controller {
@Get('/validate/{address}')
async validateAddress(@Path() address: string): Promise<ValidationResult> {
try {
return await elliptic.validateAddress(address)
return await ofac.validateAddress(address)
} catch (err) {
throw handleError(err)
}
Expand Down
78 changes: 0 additions & 78 deletions node/proxy/api/src/elliptic.ts

This file was deleted.

146 changes: 146 additions & 0 deletions node/proxy/api/src/ofac.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import axios from 'axios'
import { XMLParser } from 'fast-xml-parser'
import { Logger } from '@shapeshiftoss/logger'
import { getAddress, isAddress } from 'viem'

const OFAC_SDN_URL = 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN_ADVANCED.XML'
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours

interface OfacArgs {
logger: Logger
}

export class Ofac {
private sanctionedAddresses: Set<string> = new Set()
private logger: Logger
private refreshInterval: NodeJS.Timeout | undefined

constructor(args: OfacArgs) {
this.logger = args.logger
}

async initialize(): Promise<void> {
try {
this.sanctionedAddresses = await this.fetchAndParseOfacList()
this.logger.info({ addressCount: this.sanctionedAddresses.size }, 'OFAC service initialized')

this.refreshInterval = setInterval(async () => {
try {
this.sanctionedAddresses = await this.fetchAndParseOfacList()
this.logger.info({ addressCount: this.sanctionedAddresses.size }, 'OFAC list refreshed')
} catch (err) {
this.logger.error({ err }, 'Failed to refresh OFAC list')
}
}, REFRESH_INTERVAL_MS)
} catch (err) {
this.logger.error({ err }, 'Failed to initialize OFAC service, failing open')
throw err
}
}

async validateAddress(address: string): Promise<{ valid: boolean }> {
if (this.sanctionedAddresses.has(this.normalizeAddress(address))) {
return { valid: false }
}

return { valid: true }
}

private async fetchAndParseOfacList(): Promise<Set<string>> {
const { data } = await axios.get<string>(OFAC_SDN_URL, { responseType: 'text' })
return this.parseXml(data)
}

private parseXml(xmlData: string): Set<string> {
const addresses = new Set<string>()

const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
numberParseOptions: { hex: false, leadingZeros: false },
})

const result = parser.parse(xmlData)

const sanctions = result?.Sanctions
if (!sanctions) throw new Error('No Sanctions element found in OFAC XML')

const featureTypeIds = new Map<number, string>()
const referenceValueSets = sanctions.ReferenceValueSets

if (referenceValueSets?.FeatureTypeValues?.FeatureType) {
const featureTypes = Array.isArray(referenceValueSets.FeatureTypeValues.FeatureType)
? referenceValueSets.FeatureTypeValues.FeatureType
: [referenceValueSets.FeatureTypeValues.FeatureType]

for (const featureType of featureTypes) {
const name = String(featureType['#text'] ?? featureType ?? '')

if (name.includes('Digital Currency Address')) {
const id = parseInt(featureType['@_ID'], 10)
if (!isNaN(id)) featureTypeIds.set(id, name)
}
}
}

if (featureTypeIds.size === 0) throw new Error('No Digital Currency Address feature types found')

const parties = sanctions.DistinctParties?.DistinctParty
if (!parties) throw new Error('No DistinctParty entries found')

const partyList = Array.isArray(parties) ? parties : [parties]

for (const party of partyList) {
const profiles = party.Profile
if (!profiles) continue

const profileList = Array.isArray(profiles) ? profiles : [profiles]

for (const profile of profileList) {
const features = profile.Feature
if (!features) continue

const featureList = Array.isArray(features) ? features : [features]

for (const feature of featureList) {
const featureTypeId = parseInt(feature['@_FeatureTypeID'], 10)
if (!featureTypeIds.has(featureTypeId)) continue

const featureVersions = feature.FeatureVersion
if (!featureVersions) continue

const featureVersionList = Array.isArray(featureVersions) ? featureVersions : [featureVersions]

for (const featureVersion of featureVersionList) {
const versionDetails = featureVersion.VersionDetail
if (!versionDetails) continue

const detailList = Array.isArray(versionDetails) ? versionDetails : [versionDetails]

for (const detail of detailList) {
const addr = typeof detail === 'string' ? detail : detail['#text']
if (typeof addr === 'string' && addr.trim()) {
addresses.add(this.normalizeAddress(addr.trim()))
}
}
}
}
}
}

return addresses
}

private normalizeAddress(address: string): string {
if (isAddress(address)) return getAddress(address)
return address
}

stop(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = undefined
}
}
}
Loading