From 98362bb7cd8972d83d7a628b2db4fb06831871c0 Mon Sep 17 00:00:00 2001 From: bigboydiamonds <57741810+bigboydiamonds@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:33:47 -0700 Subject: [PATCH] feat(api): bridge limits [SLT-165] (#3179) * adds `/bridgeLimits` route, controller * fetch best sdk quote for min/max origin amounts * add tests * implement middleware to normalize addresses * adds swagger doc --- packages/rest-api/package.json | 1 + .../src/controllers/bridgeLimitsController.ts | 108 ++++++++++++++ .../rest-api/src/routes/bridgeLimitsRoute.ts | 138 ++++++++++++++++++ packages/rest-api/src/routes/index.ts | 2 + .../src/tests/bridgeLimitsRoute.test.ts | 81 ++++++++++ 5 files changed, 330 insertions(+) create mode 100644 packages/rest-api/src/controllers/bridgeLimitsController.ts create mode 100644 packages/rest-api/src/routes/bridgeLimitsRoute.ts create mode 100644 packages/rest-api/src/tests/bridgeLimitsRoute.test.ts diff --git a/packages/rest-api/package.json b/packages/rest-api/package.json index 4d0f56ef06..79c289ccd0 100644 --- a/packages/rest-api/package.json +++ b/packages/rest-api/package.json @@ -18,6 +18,7 @@ "test:coverage": "jest --collect-coverage" }, "dependencies": { + "@ethersproject/address": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.2", "@ethersproject/units": "5.7.0", diff --git a/packages/rest-api/src/controllers/bridgeLimitsController.ts b/packages/rest-api/src/controllers/bridgeLimitsController.ts new file mode 100644 index 0000000000..1ef292338f --- /dev/null +++ b/packages/rest-api/src/controllers/bridgeLimitsController.ts @@ -0,0 +1,108 @@ +import { validationResult } from 'express-validator' +import { BigNumber } from 'ethers' +import { parseUnits } from '@ethersproject/units' + +import { Synapse } from '../services/synapseService' +import { tokenAddressToToken } from '../utils/tokenAddressToToken' +import { formatBNToString } from '../utils/formatBNToString' + +export const bridgeLimitsController = async (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + try { + const { fromChain, fromToken, toChain, toToken } = req.query + + const fromTokenInfo = tokenAddressToToken(fromChain, fromToken) + const toTokenInfo = tokenAddressToToken(toChain, toToken) + + const upperLimitValue = parseUnits('1000000', fromTokenInfo.decimals) + const upperLimitBridgeQuotes = await Synapse.allBridgeQuotes( + Number(fromChain), + Number(toChain), + fromTokenInfo.address, + toTokenInfo.address, + upperLimitValue + ) + + const lowerLimitValues = ['0.01', '10'] + let lowerLimitBridgeQuotes = null + + for (const limit of lowerLimitValues) { + const lowerLimitAmount = parseUnits(limit, fromTokenInfo.decimals) + + lowerLimitBridgeQuotes = await Synapse.allBridgeQuotes( + Number(fromChain), + Number(toChain), + fromTokenInfo.address, + toTokenInfo.address, + lowerLimitAmount + ) + + if (lowerLimitBridgeQuotes && lowerLimitBridgeQuotes.length > 0) { + break + } + } + + const maxBridgeAmountQuote = upperLimitBridgeQuotes.reduce( + (maxQuote, currentQuote) => { + const currentMaxAmount = currentQuote.maxAmountOut + const maxAmount = maxQuote ? maxQuote.maxAmountOut : BigNumber.from(0) + + return currentMaxAmount.gt(maxAmount) ? currentQuote : maxQuote + }, + null + ) + + const minBridgeAmountQuote = lowerLimitBridgeQuotes.reduce( + (minQuote, currentQuote) => { + const currentFeeAmount = currentQuote.feeAmount + const minFeeAmount = minQuote ? minQuote.feeAmount : null + + return !minFeeAmount || currentFeeAmount.lt(minFeeAmount) + ? currentQuote + : minQuote + }, + null + ) + + if (!maxBridgeAmountQuote || !minBridgeAmountQuote) { + return res.json({ + maxOriginAmount: null, + minOriginAmount: null, + }) + } + + const maxAmountOriginQueryTokenOutInfo = tokenAddressToToken( + toChain, + maxBridgeAmountQuote.destQuery.tokenOut + ) + + const minAmountOriginQueryTokenOutInfo = tokenAddressToToken( + fromChain, + minBridgeAmountQuote.originQuery.tokenOut + ) + + const maxOriginAmount = formatBNToString( + maxBridgeAmountQuote.maxAmountOut, + maxAmountOriginQueryTokenOutInfo.decimals + ) + + const minOriginAmount = formatBNToString( + minBridgeAmountQuote.feeAmount, + minAmountOriginQueryTokenOutInfo.decimals + ) + + return res.json({ + maxOriginAmount, + minOriginAmount, + }) + } catch (err) { + res.status(500).json({ + error: + 'An unexpected error occurred in /bridgeLimits. Please try again later.', + details: err.message, + }) + } +} diff --git a/packages/rest-api/src/routes/bridgeLimitsRoute.ts b/packages/rest-api/src/routes/bridgeLimitsRoute.ts new file mode 100644 index 0000000000..14ab637671 --- /dev/null +++ b/packages/rest-api/src/routes/bridgeLimitsRoute.ts @@ -0,0 +1,138 @@ +import express from 'express' +import { check } from 'express-validator' + +import { CHAINS_ARRAY } from '../constants/chains' +import { showFirstValidationError } from '../middleware/showFirstValidationError' +import { bridgeLimitsController } from '../controllers/bridgeLimitsController' +import { isTokenSupportedOnChain } from './../utils/isTokenSupportedOnChain' +import { isTokenAddress } from '../utils/isTokenAddress' +import { normalizeNativeTokenAddress } from '../middleware/normalizeNativeTokenAddress' +import { checksumAddresses } from '../middleware/checksumAddresses' + +const router = express.Router() + +/** + * @openapi + * /bridgeLimits: + * get: + * summary: Get min/max origin values for bridge quote + * description: Retrieve min/max bridgeable amounts to bridge from source chain to destination chain. Returns null for min/max amounts if limits are unavailable. + * parameters: + * - in: query + * name: fromChain + * required: true + * schema: + * type: integer + * description: The source chain ID. + * - in: query + * name: toChain + * required: true + * schema: + * type: integer + * description: The destination chain ID. + * - in: query + * name: fromToken + * required: true + * schema: + * type: string + * description: The address of the token on the source chain. + * - in: query + * name: toToken + * required: true + * schema: + * type: string + * description: The address of the token on the destination chain. + * responses: + * 200: + * description: Successful response containing min and max origin amounts. + * content: + * application/json: + * schema: + * type: object + * properties: + * maxOriginAmount: + * type: string + * description: Maximum amount of tokens that can be bridged from the origin chain. + * minOriginAmount: + * type: string + * description: Minimum amount of tokens that can be bridged from the origin chain. + * example: + * maxOriginAmount: "999600" + * minOriginAmount: "4" + * 400: + * description: Invalid input + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: object + * properties: + * value: + * type: string + * message: + * type: string + * field: + * type: string + * location: + * type: string + * example: + * error: + * value: "999" + * message: "Unsupported fromChain" + * field: "fromChain" + * location: "query" + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * details: + * type: string + */ +router.get( + '/', + normalizeNativeTokenAddress(['fromToken', 'toToken']), + checksumAddresses(['fromToken', 'toToken']), + [ + check('fromChain') + .exists() + .withMessage('fromChain is required') + .isNumeric() + .custom((value) => CHAINS_ARRAY.some((c) => c.id === Number(value))) + .withMessage('Unsupported fromChain'), + check('toChain') + .exists() + .withMessage('toChain is required') + .isNumeric() + .custom((value) => CHAINS_ARRAY.some((c) => c.id === Number(value))) + .withMessage('Unsupported toChain'), + check('fromToken') + .exists() + .withMessage('fromToken is required') + .custom((value) => isTokenAddress(value)) + .withMessage('Invalid fromToken address') + .custom((value, { req }) => + isTokenSupportedOnChain(value, req.query.fromChain as string) + ) + .withMessage('Token not supported on specified chain'), + check('toToken') + .exists() + .withMessage('toToken is required') + .custom((value) => isTokenAddress(value)) + .withMessage('Invalid toToken address') + .custom((value, { req }) => + isTokenSupportedOnChain(value, req.query.toChain as string) + ) + .withMessage('Token not supported on specified chain'), + ], + showFirstValidationError, + bridgeLimitsController +) + +export default router diff --git a/packages/rest-api/src/routes/index.ts b/packages/rest-api/src/routes/index.ts index 1bbcf3ea51..2c5e1c547e 100644 --- a/packages/rest-api/src/routes/index.ts +++ b/packages/rest-api/src/routes/index.ts @@ -10,6 +10,7 @@ import bridgeTxStatusRoute from './bridgeTxStatusRoute' import destinationTxRoute from './destinationTxRoute' import tokenListRoute from './tokenListRoute' import destinationTokensRoute from './destinationTokensRoute' +import bridgeLimitsRoute from './bridgeLimitsRoute' const router = express.Router() @@ -18,6 +19,7 @@ router.use('/swap', swapRoute) router.use('/swapTxInfo', swapTxInfoRoute) router.use('/bridge', bridgeRoute) router.use('/bridgeTxInfo', bridgeTxInfoRoute) +router.use('/bridgeLimits', bridgeLimitsRoute) router.use('/synapseTxId', synapseTxIdRoute) router.use('/bridgeTxStatus', bridgeTxStatusRoute) router.use('/destinationTx', destinationTxRoute) diff --git a/packages/rest-api/src/tests/bridgeLimitsRoute.test.ts b/packages/rest-api/src/tests/bridgeLimitsRoute.test.ts new file mode 100644 index 0000000000..921393773b --- /dev/null +++ b/packages/rest-api/src/tests/bridgeLimitsRoute.test.ts @@ -0,0 +1,81 @@ +import request from 'supertest' +import express from 'express' + +import bridgeLimitsRoute from '../routes/bridgeLimitsRoute' +import { USDC, ETH } from '../constants/bridgeable' + +const app = express() +app.use('/bridgeLimits', bridgeLimitsRoute) + +describe('Get Bridge Limits Route', () => { + it('should return min/max origin amounts bridging USDC', async () => { + const response = await request(app).get('/bridgeLimits').query({ + fromChain: 1, + fromToken: USDC.addresses[1], + toChain: 10, + toToken: USDC.addresses[10], + }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('maxOriginAmount') + expect(response.body).toHaveProperty('minOriginAmount') + }, 10_000) + + it('should return min/max origin amounts bridging ETH', async () => { + const response = await request(app).get('/bridgeLimits').query({ + fromChain: 1, + fromToken: ETH.addresses[1], + toChain: 10, + toToken: ETH.addresses[10], + }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('maxOriginAmount') + expect(response.body).toHaveProperty('minOriginAmount') + }, 10_000) + + it('should return 400 for unsupported fromChain', async () => { + const response = await request(app).get('/bridgeLimits').query({ + fromChain: '999', + toChain: '137', + fromToken: '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', + toToken: USDC.addresses[137], + }) + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty( + 'message', + 'Unsupported fromChain' + ) + }, 10_000) + + it('should return 400 for unsupported toChain', async () => { + const response = await request(app).get('/bridgeLimits').query({ + fromChain: '137', + toChain: '999', + fromToken: USDC.addresses[137], + toToken: '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', + }) + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty('message', 'Unsupported toChain') + }, 10_000) + + it('should return 400 for missing fromToken', async () => { + const response = await request(app).get('/bridgeLimits').query({ + fromChain: '1', + toChain: '137', + toToken: USDC.addresses[137], + }) + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty('field', 'fromToken') + }, 10_000) + + it('should return 400 for missing toToken', async () => { + const response = await request(app).get('/bridgeLimits').query({ + fromChain: '1', + toChain: '137', + fromToken: USDC.addresses[1], + }) + expect(response.status).toBe(400) + expect(response.body.error).toHaveProperty('field', 'toToken') + }, 10_000) +})