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
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')
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, { strict: false })) return getAddress(address)
return address
}

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