From a55a3df93dfa58f793d563411bb3cc11b57b522e Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:27:17 -0700 Subject: [PATCH 1/3] feat: update validate address endpoint --- node/proxy/api/package.json | 2 +- node/proxy/api/src/app.ts | 85 ++++++++++-------- node/proxy/api/src/controller.ts | 6 +- node/proxy/api/src/elliptic.ts | 78 ----------------- node/proxy/api/src/ofac.ts | 146 +++++++++++++++++++++++++++++++ yarn.lock | 40 ++++----- 6 files changed, 215 insertions(+), 142 deletions(-) delete mode 100644 node/proxy/api/src/elliptic.ts create mode 100644 node/proxy/api/src/ofac.ts diff --git a/node/proxy/api/package.json b/node/proxy/api/package.json index 5bdffd92c..86e7e68b8 100644 --- a/node/proxy/api/package.json +++ b/node/proxy/api/package.json @@ -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" } } diff --git a/node/proxy/api/src/app.ts b/node/proxy/api/src/app.ts index a1c263a69..063b8cc25 100644 --- a/node/proxy/api/src/app.ts +++ b/node/proxy/api/src/app.ts @@ -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 @@ -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() diff --git a/node/proxy/api/src/controller.ts b/node/proxy/api/src/controller.ts index 31c5eac07..768763d6b 100644 --- a/node/proxy/api/src/controller.ts +++ b/node/proxy/api/src/controller.ts @@ -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 { /** @@ -23,7 +21,7 @@ export class Proxy extends Controller { @Get('/validate/{address}') async validateAddress(@Path() address: string): Promise { try { - return await elliptic.validateAddress(address) + return await ofac.validateAddress(address) } catch (err) { throw handleError(err) } diff --git a/node/proxy/api/src/elliptic.ts b/node/proxy/api/src/elliptic.ts deleted file mode 100644 index 8703cf1cb..000000000 --- a/node/proxy/api/src/elliptic.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { isAxiosError } from 'axios' -import { AML } from 'elliptic-sdk' - -const ELLIPTIC_API_KEY = process.env.ELLIPTIC_API_KEY -const ELLIPTIC_API_SECRET = process.env.ELLIPTIC_API_SECRET - -if (!ELLIPTIC_API_KEY) throw new Error('ELLIPTIC_API_KEY env var not set') -if (!ELLIPTIC_API_SECRET) throw new Error('ELLIPTIC_API_SECRET env var not set') - -const RISK_SCORE_THRESHOLD = 1 -const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 hours - -type AddressCache = Partial> - -interface WalletResponse { - id: string - risk_score?: number -} - -interface WalletError { - name: string - message: string -} - -export class Elliptic { - private aml: AML - private addressCache: AddressCache - - constructor() { - this.aml = new AML({ - key: ELLIPTIC_API_KEY as string, - secret: ELLIPTIC_API_SECRET as string, - }) - this.addressCache = {} - } - - async validateAddress(address: string): Promise<{ valid: boolean }> { - const valid = this.addressCache[address] - if (valid !== undefined) return { valid } - - try { - const { data } = await this.aml.client.post('/v2/wallet/synchronous', { - subject: { - asset: 'holistic', - blockchain: 'holistic', - type: 'address', - hash: address, - }, - type: 'wallet_exposure', - }) - - if (data.risk_score && data.risk_score >= RISK_SCORE_THRESHOLD) { - this.addressCache[address] = false - return { valid: false } - } - - this.addressCache[address] = true - return { valid: true } - } catch (err) { - // the submitted address has not yet been processed into the elliptic tool or does not exist on the blockchain - // assume valid and put responsibility back on client for any further validation - if ( - isAxiosError(err) && - err.response?.status === 404 && - err.response.data.name === 'NotInBlockchain' - ) { - this.addressCache[address] = true - return { valid: true } - } - - throw err - } finally { - if (this.addressCache[address] === true) { - setTimeout(() => delete this.addressCache[address], CACHE_TTL_MS) - } - } - } -} diff --git a/node/proxy/api/src/ofac.ts b/node/proxy/api/src/ofac.ts new file mode 100644 index 000000000..8d01be77a --- /dev/null +++ b/node/proxy/api/src/ofac.ts @@ -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 = new Set() + private logger: Logger + private refreshInterval: NodeJS.Timeout | undefined + + constructor(args: OfacArgs) { + this.logger = args.logger + } + + async initialize(): Promise { + 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> { + const { data } = await axios.get(OFAC_SDN_URL, { responseType: 'text' }) + return this.parseXml(data) + } + + private parseXml(xmlData: string): Set { + const addresses = new Set() + + 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() + 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 + } + } +} diff --git a/yarn.lock b/yarn.lock index 6a14db68d..2b574e54f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1963,7 +1963,7 @@ __metadata: "@shapeshiftoss/common-api": "npm:^10.0.0" "@shapeshiftoss/prometheus": "npm:^10.0.0" bottleneck: "npm:^2.19.5" - elliptic-sdk: "npm:^0.7.2" + fast-xml-parser: "npm:^4.3.0" languageName: unknown linkType: soft @@ -3110,17 +3110,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.3.4": - version: 1.6.8 - resolution: "axios@npm:1.6.8" - dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.0" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/0f22da6f490335479a89878bc7d5a1419484fbb437b564a80c34888fc36759ae4f56ea28d55a191695e5ed327f0bad56e7ff60fb6770c14d1be6501505d47ab9 - languageName: node - linkType: hard - "axios@npm:^1.6.2": version: 1.6.2 resolution: "axios@npm:1.6.2" @@ -4256,15 +4245,6 @@ __metadata: languageName: node linkType: hard -"elliptic-sdk@npm:^0.7.2": - version: 0.7.2 - resolution: "elliptic-sdk@npm:0.7.2" - dependencies: - axios: "npm:^1.3.4" - checksum: 10c0/c8aaaec84ef45a6d38dab562152ace47ded95c1ef356c78c8c569c32924f794c8f8a7485b364594c7d77c95995170b07e285ae712fd8d166309b6bfe96984cdd - languageName: node - linkType: hard - "elliptic@npm:6.5.4, elliptic@npm:^6.5.4": version: 6.5.4 resolution: "elliptic@npm:6.5.4" @@ -4902,6 +4882,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^4.3.0": + version: 4.5.3 + resolution: "fast-xml-parser@npm:4.5.3" + dependencies: + strnum: "npm:^1.1.1" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/bf9ccadacfadc95f6e3f0e7882a380a7f219cf0a6f96575149f02cb62bf44c3b7f0daee75b8ff3847bcfd7fbcb201e402c71045936c265cf6d94b141ec4e9327 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.11.0 resolution: "fastq@npm:1.11.0" @@ -8353,6 +8344,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^1.1.1": + version: 1.1.2 + resolution: "strnum@npm:1.1.2" + checksum: 10c0/a0fce2498fa3c64ce64a40dada41beb91cabe3caefa910e467dc0518ef2ebd7e4d10f8c2202a6104f1410254cae245066c0e94e2521fb4061a5cb41831952392 + languageName: node + linkType: hard + "superstruct@npm:^2.0.2": version: 2.0.2 resolution: "superstruct@npm:2.0.2" From 46a5a91c102c6cc819e44cbf5df61811ef706a8a Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:57:23 -0700 Subject: [PATCH 2/3] non strict address check --- node/proxy/api/src/ofac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/proxy/api/src/ofac.ts b/node/proxy/api/src/ofac.ts index 8d01be77a..ef946ff1b 100644 --- a/node/proxy/api/src/ofac.ts +++ b/node/proxy/api/src/ofac.ts @@ -133,7 +133,7 @@ export class Ofac { } private normalizeAddress(address: string): string { - if (isAddress(address)) return getAddress(address) + if (isAddress(address, { strict: false })) return getAddress(address) return address } From 09e5d2e4aa2ddf483fb8d2be193e64e2d527f448 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:59:43 -0700 Subject: [PATCH 3/3] clean up error log --- node/proxy/api/src/ofac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/proxy/api/src/ofac.ts b/node/proxy/api/src/ofac.ts index ef946ff1b..116a7155b 100644 --- a/node/proxy/api/src/ofac.ts +++ b/node/proxy/api/src/ofac.ts @@ -33,7 +33,7 @@ export class Ofac { } }, REFRESH_INTERVAL_MS) } catch (err) { - this.logger.error({ err }, 'Failed to initialize OFAC service, failing open') + this.logger.error({ err }, 'Failed to initialize OFAC service') throw err } }