diff --git a/packages/rest-api/.eslintrc.js b/packages/rest-api/.eslintrc.js index e17fcaafc9..59c197292d 100644 --- a/packages/rest-api/.eslintrc.js +++ b/packages/rest-api/.eslintrc.js @@ -7,5 +7,11 @@ module.exports = { 'prettier/prettier': 'off', }, }, + { + files: ['**/*.ts'], + rules: { + 'guard-for-in': 'off', + }, + }, ], } diff --git a/packages/rest-api/src/constants/bridgeable.ts b/packages/rest-api/src/constants/bridgeable.ts index f6a919ce79..e0bbd61d80 100644 --- a/packages/rest-api/src/constants/bridgeable.ts +++ b/packages/rest-api/src/constants/bridgeable.ts @@ -1,7 +1,6 @@ import { BridgeableToken } from '../types' import { CHAINS } from './chains' - -const ZeroAddress = '0x0000000000000000000000000000000000000000' +import { ZeroAddress } from '.' export const GOHM: BridgeableToken = { addresses: { diff --git a/packages/rest-api/src/constants/index.ts b/packages/rest-api/src/constants/index.ts index 716c1fd365..c136beb9fa 100644 --- a/packages/rest-api/src/constants/index.ts +++ b/packages/rest-api/src/constants/index.ts @@ -3,3 +3,6 @@ export const VALID_BRIDGE_MODULES = [ 'SynapseCCTP', 'SynapseRFQ', ] + +export const ZeroAddress = '0x0000000000000000000000000000000000000000' +export const NativeGasAddress = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' diff --git a/packages/rest-api/src/controllers/destinationTokensController.ts b/packages/rest-api/src/controllers/destinationTokensController.ts new file mode 100644 index 0000000000..e606cd383b --- /dev/null +++ b/packages/rest-api/src/controllers/destinationTokensController.ts @@ -0,0 +1,29 @@ +import { validationResult } from 'express-validator' + +import { tokenAddressToToken } from '../utils/tokenAddressToToken' +import { BRIDGE_ROUTE_MAPPING } from '../utils/bridgeRouteMapping' + +export const destinationTokensController = async (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + try { + const { fromChain, fromToken } = req.query + + const fromTokenInfo = tokenAddressToToken(fromChain.toString(), fromToken) + + const constructedKey = `${fromTokenInfo.symbol}-${fromChain}` + + const options = BRIDGE_ROUTE_MAPPING[constructedKey] + + res.json(options) + } catch (err) { + res.status(500).json({ + error: + 'An unexpected error occurred in /destinationTokens. Please try again later.', + details: err.message, + }) + } +} diff --git a/packages/rest-api/src/middleware/checksumAddresses.ts b/packages/rest-api/src/middleware/checksumAddresses.ts new file mode 100644 index 0000000000..d1d91a1a2b --- /dev/null +++ b/packages/rest-api/src/middleware/checksumAddresses.ts @@ -0,0 +1,14 @@ +import { Request, Response, NextFunction } from 'express' +import { getAddress, isAddress } from 'ethers/lib/utils' + +export const checksumAddresses = (addressFields: string[]) => { + return (req: Request, _res: Response, next: NextFunction) => { + for (const field of addressFields) { + const address = req.query[field] + if (typeof address === 'string' && isAddress(address)) { + req.query[field] = getAddress(address) + } + } + next() + } +} diff --git a/packages/rest-api/src/routes/bridgeRoute.ts b/packages/rest-api/src/routes/bridgeRoute.ts index b27e9c99d8..63e8e76300 100644 --- a/packages/rest-api/src/routes/bridgeRoute.ts +++ b/packages/rest-api/src/routes/bridgeRoute.ts @@ -6,11 +6,13 @@ import { CHAINS_ARRAY } from '../constants/chains' import { showFirstValidationError } from '../middleware/showFirstValidationError' import { bridgeController } from '../controllers/bridgeController' import { isTokenSupportedOnChain } from '../utils/isTokenSupportedOnChain' +import { checksumAddresses } from '../middleware/checksumAddresses' const router = express.Router() router.get( '/', + checksumAddresses(['fromToken', 'toToken']), [ check('fromChain') .isNumeric() diff --git a/packages/rest-api/src/routes/bridgeTxInfoRoute.ts b/packages/rest-api/src/routes/bridgeTxInfoRoute.ts index 8287143cea..f17771be1d 100644 --- a/packages/rest-api/src/routes/bridgeTxInfoRoute.ts +++ b/packages/rest-api/src/routes/bridgeTxInfoRoute.ts @@ -7,11 +7,13 @@ import { showFirstValidationError } from '../middleware/showFirstValidationError import { bridgeTxInfoController } from '../controllers/bridgeTxInfoController' import { isTokenAddress } from '../utils/isTokenAddress' import { isTokenSupportedOnChain } from '../utils/isTokenSupportedOnChain' +import { checksumAddresses } from '../middleware/checksumAddresses' const router = express.Router() router.get( '/', + checksumAddresses(['fromToken', 'toToken']), [ check('fromChain') .isNumeric() diff --git a/packages/rest-api/src/routes/destinationTokensRoute.ts b/packages/rest-api/src/routes/destinationTokensRoute.ts new file mode 100644 index 0000000000..0c63d69791 --- /dev/null +++ b/packages/rest-api/src/routes/destinationTokensRoute.ts @@ -0,0 +1,40 @@ +import express from 'express' +import { check } from 'express-validator' +import { isAddress } from 'ethers/lib/utils' + +import { CHAINS_ARRAY } from '../constants/chains' +import { showFirstValidationError } from '../middleware/showFirstValidationError' +import { destinationTokensController } from '../controllers/destinationTokensController' +import { isTokenAddress } from '../utils/isTokenAddress' +import { isTokenSupportedOnChain } from '../utils/isTokenSupportedOnChain' +import { checksumAddresses } from '../middleware/checksumAddresses' + +const router = express.Router() + +router.get( + '/', + checksumAddresses(['fromToken']), + [ + check('fromChain') + .exists() + .withMessage('fromChain is required') + .isNumeric() + .custom((value) => CHAINS_ARRAY.some((c) => c.id === Number(value))) + .withMessage('Unsupported fromChain'), + check('fromToken') + .exists() + .withMessage('fromToken is required') + .custom((value) => isAddress(value)) + .withMessage('Invalid fromToken address') + .custom((value) => isTokenAddress(value)) + .withMessage('Unsupported fromToken address') + .custom((value, { req }) => + isTokenSupportedOnChain(value, req.query.fromChain as string) + ) + .withMessage('Token not supported on specified chain'), + ], + showFirstValidationError, + destinationTokensController +) + +export default router diff --git a/packages/rest-api/src/routes/index.ts b/packages/rest-api/src/routes/index.ts index b09c01f3b7..895e7e2518 100644 --- a/packages/rest-api/src/routes/index.ts +++ b/packages/rest-api/src/routes/index.ts @@ -9,6 +9,7 @@ import getSynapseTxIdRoute from './getSynapseTxIdRoute' import getBridgeTxStatusRoute from './getBridgeTxStatusRoute' import getDestinationTxRoute from './getDestinationTxRoute' import tokenListRoute from './tokenListRoute' +import destinationTokensRoute from './destinationTokensRoute' const router = express.Router() @@ -21,5 +22,6 @@ router.use('/getSynapseTxId', getSynapseTxIdRoute) router.use('/getBridgeTxStatus', getBridgeTxStatusRoute) router.use('/getDestinationTx', getDestinationTxRoute) router.use('/tokenList', tokenListRoute) +router.use('/destinationTokens', destinationTokensRoute) export default router diff --git a/packages/rest-api/src/routes/swapRoute.ts b/packages/rest-api/src/routes/swapRoute.ts index 931efec5ef..964119c199 100644 --- a/packages/rest-api/src/routes/swapRoute.ts +++ b/packages/rest-api/src/routes/swapRoute.ts @@ -6,11 +6,13 @@ import { swapController } from '../controllers/swapController' import { CHAINS_ARRAY } from '../constants/chains' import { isTokenAddress } from '../utils/isTokenAddress' import { isTokenSupportedOnChain } from '../utils/isTokenSupportedOnChain' +import { checksumAddresses } from '../middleware/checksumAddresses' const router = express.Router() router.get( '/', + checksumAddresses(['fromToken', 'toToken']), [ check('chain') .isNumeric() diff --git a/packages/rest-api/src/routes/swapTxInfoRoute.ts b/packages/rest-api/src/routes/swapTxInfoRoute.ts index a54ce1cef9..8d85530fee 100644 --- a/packages/rest-api/src/routes/swapTxInfoRoute.ts +++ b/packages/rest-api/src/routes/swapTxInfoRoute.ts @@ -7,11 +7,13 @@ import { showFirstValidationError } from '../middleware/showFirstValidationError import { swapTxInfoController } from '../controllers/swapTxInfoController' import { isTokenAddress } from '../utils/isTokenAddress' import { isTokenSupportedOnChain } from '../utils/isTokenSupportedOnChain' +import { checksumAddresses } from '../middleware/checksumAddresses' const router = express.Router() router.get( '/', + checksumAddresses(['fromToken', 'toToken']), [ check('chain') .isNumeric() diff --git a/packages/rest-api/src/tests/destinationTokensRoute.test.ts b/packages/rest-api/src/tests/destinationTokensRoute.test.ts new file mode 100644 index 0000000000..4fe40a7a62 --- /dev/null +++ b/packages/rest-api/src/tests/destinationTokensRoute.test.ts @@ -0,0 +1,143 @@ +import request from 'supertest' +import express from 'express' + +import destinationTokensRoute from '../routes/destinationTokensRoute' + +const app = express() +app.use('/destinationTokens', destinationTokensRoute) + +describe('destinatonTokens Route', () => { + it('should return destination tokens for valid input', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '1', + fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }) + + expect(response.status).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeGreaterThan(0) + expect(response.body[0]).toHaveProperty('symbol') + expect(response.body[0]).toHaveProperty('address') + expect(response.body[0]).toHaveProperty('chainId') + }) + + it('should return destination tokens for valid gas Tokens', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '1', + fromToken: '0x0000000000000000000000000000000000000000', + }) + + expect(response.status).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeGreaterThan(0) + expect(response.body[0]).toHaveProperty('symbol') + expect(response.body[0]).toHaveProperty('address') + expect(response.body[0]).toHaveProperty('chainId') + }) + + it('should return precisely the number of destination tokens', async () => { + // 'USDC-534352': [ 'USDC-1', 'USDC-10', 'USDC-8453', 'USDC-42161', 'USDC-59144' ] + + const response = await request(app).get('/destinationTokens').query({ + fromChain: '534352', + fromToken: '0x06eFdBFf2a14a7c8E15944D1F4A48F9F95F663A4', + }) + + expect(response.status).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBe(5) + expect(response.body[0]).toHaveProperty('symbol') + expect(response.body[0]).toHaveProperty('address') + expect(response.body[0]).toHaveProperty('chainId') + }) + + it('should return destination tokens for non-checksummed address', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '43114', + fromToken: '0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', + }) + + expect(response.status).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeGreaterThan(0) + expect(response.body[0]).toHaveProperty('symbol') + expect(response.body[0]).toHaveProperty('address') + expect(response.body[0]).toHaveProperty('chainId') + }) + + it('should return 400 for unsupported fromChain', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '999', + fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'Unsupported fromChain' + ) + }) + + it('should return 400 for invalid fromToken address', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '1', + fromToken: 'invalid_address', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'Invalid fromToken address' + ) + }) + + it('should return 400 for token not supported by Synapse', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '1', + fromToken: '0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'Unsupported fromToken address' + ) + }) + + it('should return 400 for token not supported on specified chain', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '10', + fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'Token not supported on specified chain' + ) + }) + + it('should return 400 for missing fromChain', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'fromChain is required' + ) + }) + + it('should return 400 for missing fromToken', async () => { + const response = await request(app).get('/destinationTokens').query({ + fromChain: '1', + }) + + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'fromToken is required' + ) + }) +}) diff --git a/packages/rest-api/src/utils/bridgeRouteMapping.ts b/packages/rest-api/src/utils/bridgeRouteMapping.ts new file mode 100644 index 0000000000..1cb537c181 --- /dev/null +++ b/packages/rest-api/src/utils/bridgeRouteMapping.ts @@ -0,0 +1,85 @@ +import { BRIDGE_MAP } from '../constants/bridgeMap' +import * as ALL_TOKENS from '../constants/bridgeable' + +type TokenData = { + symbol: string + address: string + chainId: number +} + +type StringifiedBridgeRoutes = Record +type TransformedBridgeRoutes = Record + +const constructJSON = ( + swappableMap, + exclusionList +): TransformedBridgeRoutes => { + const result = {} + + // Iterate through the chains + for (const chainA in swappableMap) { + for (const tokenA in swappableMap[chainA]) { + const symbolA = swappableMap[chainA][tokenA].symbol + const key = `${symbolA}-${chainA}` + + if (exclusionList.includes(key)) { + continue + } + + // Iterate through other chains to compare + for (const chainB in swappableMap) { + if (chainA !== chainB) { + for (const tokenB in swappableMap[chainB]) { + const symbolB = swappableMap[chainB][tokenB].symbol + const value = `${symbolB}-${chainB}` + + if (exclusionList.includes(value)) { + continue + } + + // Check if there's a bridge between the origins and destinations + for (const bridgeSymbol of swappableMap[chainA][tokenA].origin) { + if ( + swappableMap[chainA][tokenA].origin.includes(bridgeSymbol) && + swappableMap[chainB][tokenB].destination.includes(bridgeSymbol) + ) { + // Add to the result if the key exists, else create a new array + if (result[key]) { + result[key].push(value) + } else { + result[key] = [value] + } + } + } + } + } + } + } + } + + return transformBridgeRouteValues(result) +} + +const transformPair = (string: string): any => { + const [symbol, chainId] = string.split('-') + const token = Object.values(ALL_TOKENS).find((t) => t.routeSymbol === symbol) + const address = token?.addresses[chainId] + if (token && address) { + return { + symbol, + chainId, + address, + } + } +} + +const transformBridgeRouteValues = (routes: StringifiedBridgeRoutes) => { + return Object.fromEntries( + Object.entries(routes).map(([key, values]) => [ + key, + values.map(transformPair).filter((pair) => pair !== undefined), + ]) + ) +} + +export const BRIDGE_ROUTE_MAPPING = constructJSON(BRIDGE_MAP, []) diff --git a/packages/rest-api/src/utils/tokenAddressToToken.ts b/packages/rest-api/src/utils/tokenAddressToToken.ts index f28c9b86e1..21c073e7d5 100644 --- a/packages/rest-api/src/utils/tokenAddressToToken.ts +++ b/packages/rest-api/src/utils/tokenAddressToToken.ts @@ -1,3 +1,4 @@ +import { NativeGasAddress, ZeroAddress } from '../constants' import { BRIDGE_MAP } from '../constants/bridgeMap' export const tokenAddressToToken = (chain: string, tokenAddress: string) => { @@ -5,7 +6,10 @@ export const tokenAddressToToken = (chain: string, tokenAddress: string) => { if (!chainData) { return null } - const tokenInfo = chainData[tokenAddress] + + const address = tokenAddress === ZeroAddress ? NativeGasAddress : tokenAddress + + const tokenInfo = chainData[address] if (!tokenInfo) { return null }