From db6d9a4649ec21bcd1351b569daf6b8e2706e418 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 15:06:54 +0300 Subject: [PATCH 01/19] feat: add poolId param to selectProductPools + docs --- src/store/selectors.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/store/selectors.js b/src/store/selectors.js index 814b9f50..48cef597 100644 --- a/src/store/selectors.js +++ b/src/store/selectors.js @@ -8,13 +8,25 @@ const selectProduct = (store, productId) => { return products[productId]; }; -const selectProductPools = (store, productId) => { +/** + * Retrieves the product pools associated with a specific product ID, optionally filtered by a pool ID. + * + * @param {Object} store - The Redux store containing the application state. + * @param {number} productId - The ID of the product for which to retrieve pools. + * @param {number|null} [poolId=null] - The ID of the pool to filter by. + * If not provided, all pools associated with the product are returned. + * @returns {Array} Array of product pool objects associated with the specified product (and pool, if provided). + */ +const selectProductPools = (store, productId, poolId = null) => { const { poolProducts, productPoolIds } = store.getState(); const poolIds = productPoolIds[productId] || []; - return poolIds.map(poolId => { + + if (poolId) { const key = `${productId}_${poolId}`; - return poolProducts[key]; - }); + return poolIds.includes(poolId) ? [poolProducts[key]] : []; + } + + return poolIds.map(id => poolProducts[`${productId}_${id}`]); }; const selectAsset = (store, assetId) => { From b5233b970057d9782784aefb52c0124beaafd163 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:16:35 +0300 Subject: [PATCH 02/19] test: add selectProductPools unit tests --- test/unit/selectors.js | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/unit/selectors.js diff --git a/test/unit/selectors.js b/test/unit/selectors.js new file mode 100644 index 00000000..d1ab6c6a --- /dev/null +++ b/test/unit/selectors.js @@ -0,0 +1,53 @@ +const { expect } = require('chai'); + +const { selectProductPools } = require('../../src/store/selectors'); +const mockStore = require('../mocks/store'); + +describe('selectProductPools', function () { + let store; + + before(function () { + store = { getState: () => mockStore }; + }); + + it('should return an empty array when productId is not found', function () { + const nonExistentProductId = 999; + const result = selectProductPools(store, nonExistentProductId); + + expect(result).to.have.lengthOf(0); + }); + + it('should return an empty array when poolId is provided but not found', function () { + const productId = 0; + const nonExistentPoolId = 999; + const result = selectProductPools(store, productId, nonExistentPoolId); + + expect(result).to.have.lengthOf(0); + }); + + it('should return a specific pool for a product when poolId is provided', function () { + const productId = 0; + const existingPoolId = 2; + const [poolProduct] = selectProductPools(store, productId, existingPoolId); + + expect(poolProduct).to.deep.equal(mockStore.poolProducts['0_2']); + }); + + it('should return all pools for a product when poolId is not provided', function () { + const productId = 0; + const [poolProduct1, poolProduct2, poolProduct3] = selectProductPools(store, productId); + + expect(poolProduct1).to.deep.equal(mockStore.poolProducts['0_1']); + expect(poolProduct2).to.deep.equal(mockStore.poolProducts['0_2']); + expect(poolProduct3).to.deep.equal(mockStore.poolProducts['0_3']); + }); + + it('should return pools in the correct custom priority order for specific products', function () { + const productIdWithCustomPoolOrder = 4; + const result = selectProductPools(store, productIdWithCustomPoolOrder); + + expect(result).to.have.lengthOf(3); + const expectedPoolIds = mockStore.productPoolIds[productIdWithCustomPoolOrder]; + expect(result.map(pool => pool.poolId)).to.deep.equal(expectedPoolIds); + }); +}); From b921a12e1418379f22834ad2327c05be7799b6bc Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:17:44 +0300 Subject: [PATCH 03/19] feat: update capacityEngine to filter by pool > product > period --- src/lib/capacityEngine.js | 97 ++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 6ca8687e..2faf99fd 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -7,6 +7,17 @@ const { selectAsset, selectProduct, selectProductPools } = require('../store/sel const { WeiPerEther, Zero } = ethers.constants; +const SECONDS_PER_DAY = BigNumber.from(24 * 3600); + +/** + * Calculates capacity and pricing data for a specific tranche of product pools. + * + * @param {Array} productPools - Array of product pool objects. + * @param {number} firstUsableTrancheIndex - Index of the first usable tranche. + * @param {boolean} useFixedPrice - Flag indicating whether to use fixed pricing. + * @param {BigNumber} now - Current timestamp in seconds. + * @returns {Object} An object containing capacity used, capacity available, minimum price, and total premium. + */ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, useFixedPrice, now) { return productPools.reduce( (accumulated, pool) => { @@ -18,9 +29,10 @@ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, u const total = trancheCapacities.reduce((total, capacity) => total.add(capacity), Zero); const unused = trancheCapacities.reduce((available, capacity, index) => { + const allocationDifference = capacity.sub(allocations[index]); return index < firstUsableTrancheIndex - ? available.add(bnMin(capacity.sub(allocations[index]), Zero)) // only carry over the negative - : available.add(capacity.sub(allocations[index])); + ? available.add(bnMin(allocationDifference, Zero)) // only carry over the negative + : available.add(allocationDifference); }, Zero); const availableCapacity = bnMax(unused, Zero); @@ -71,31 +83,81 @@ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, u ); } -function capacityEngine(store, productIds, time, period = 30) { - const { assets, assetRates } = store.getState(); - const capacities = []; - const ids = productIds.length === 0 ? Object.keys(store.getState().products) : [...productIds]; +/** + * Retrieves all product IDs that are associated with a specific pool. + * + * @param {Object} store - The Redux store containing application state. + * @param {number|string} poolId - The ID of the pool to filter products by. + * @returns {Array} An array of product IDs associated with the specified pool. + */ +function getProductsInPool(store, poolId) { + const { products } = store.getState(); + return Object.keys(products).filter(productId => { + const productPools = selectProductPools(store, productId, poolId); + return productPools?.length > 0; + }); +} + +/** + * Calculates tranche indices for capacity calculations based on time and product data. + * + * @param {BigNumber} time - The current timestamp in seconds. + * @param {Object} product - The product object containing product details. + * @param {number} period - The coverage period in days. + * @returns {Object} Contains indices for the first usable tranche / first usable tranche for the maximum period. + */ +function calculateTrancheInfo(time, product, period) { + const firstActiveTrancheId = calculateTrancheId(time); + const gracePeriodExpiration = time.add(SECONDS_PER_DAY.mul(period)).add(product.gracePeriod); + const firstUsableTrancheId = calculateTrancheId(gracePeriodExpiration); + const firstUsableTrancheIndex = firstUsableTrancheId - firstActiveTrancheId; + const firstUsableTrancheForMaxPeriodId = calculateTrancheId(time.add(MAX_COVER_PERIOD).add(product.gracePeriod)); + const firstUsableTrancheForMaxPeriodIndex = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; + + return { + firstUsableTrancheIndex, + firstUsableTrancheForMaxPeriodIndex, + }; +} + +/** + * Calculates the capacity and pricing information for products and pools. + * + * @param {Object} store - The Redux store containing application state. + * @param {Object} [options={}] - Optional parameters for capacity calculation. + * @param {number|null} [options.poolId=null] - The ID of the pool to filter products by. + * @param {Array} [options.productIds=[]] - Array of product IDs to process. + * @param {number} [options.period=30] - The coverage period in days. + * @returns {Array} An array of capacity information objects for each product. + */ +function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = {}) { + const { assets, assetRates, products } = store.getState(); const now = BigNumber.from(Date.now()).div(1000); + const capacities = []; + + let productIdsToProcess; + if (productIds.length > 0) { + productIdsToProcess = [...productIds]; + } else if (poolId !== null) { + // If only poolId is provided, get all products in that pool + productIdsToProcess = getProductsInPool(store, poolId); + } else { + // If neither productIds nor poolId is provided, process all products + productIdsToProcess = Object.keys(products); + } - for (const productId of ids) { + for (const productId of productIdsToProcess) { const product = selectProduct(store, productId); if (!product) { continue; } - const secondsPerDay = BigNumber.from(24 * 3600); - - const firstActiveTrancheId = calculateTrancheId(time); - const gracePeriodExpiration = time.add(secondsPerDay.mul(period)).add(product.gracePeriod); - const firstUsableTrancheId = calculateTrancheId(gracePeriodExpiration); - const firstUsableTrancheIndex = firstUsableTrancheId - firstActiveTrancheId; - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(time.add(MAX_COVER_PERIOD).add(product.gracePeriod)); - const firstUsableTrancheForMaxPeriodIndex = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; - - const productPools = selectProductPools(store, productId); + const { firstUsableTrancheIndex, firstUsableTrancheForMaxPeriodIndex } = calculateTrancheInfo(now, product, period); + const productPools = selectProductPools(store, productId, poolId); if (product.useFixedPrice) { + // Fixed Price const productData = calculateProductDataForTranche(productPools, firstUsableTrancheIndex, true, now); const { capacityAvailableNXM, capacityUsedNXM, minPrice, totalPremium } = productData; @@ -118,6 +180,7 @@ function capacityEngine(store, productIds, time, period = 30) { maxAnnualPrice, }); } else { + // Non-fixed Price let productData = {}; let maxAnnualPrice = BigNumber.from(0); From 03515299da98a6210f905745818ae47ede3eb6c3 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:18:01 +0300 Subject: [PATCH 04/19] test: update capacityEngine unit tests --- test/unit/capacityEngine.js | 153 ++++++++++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 17 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index e4f18bec..3527fe1c 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -1,24 +1,30 @@ const { expect } = require('chai'); -const { BigNumber } = require('ethers'); +const ethers = require('ethers'); const sinon = require('sinon'); -const { capacities } = require('./responses'); +const { capacities, poolProductCapacities } = require('./responses'); const capacityEngine = require('../../src/lib/capacityEngine'); const { selectAsset } = require('../../src/store/selectors'); const mockStore = require('../mocks/store'); -describe('Capacity Engine tests', () => { +const { BigNumber } = ethers; +const { parseEther } = ethers.utils; + +describe('Capacity Engine tests', function () { const store = { getState: () => null }; + beforeEach(function () { + sinon.stub(store, 'getState').callsFake(() => mockStore); + }); + afterEach(function () { sinon.restore(); }); - it('should return capacity for all products', () => { - sinon.stub(store, 'getState').callsFake(() => mockStore); - const now = BigNumber.from(Date.now()).div(1000); - const response = capacityEngine(store, [], now); + it('should return capacity for all products when no productIds or poolId are provided', function () { + const response = capacityEngine(store, { period: 30 }); + expect(response).to.have.lengthOf(Object.keys(mockStore.products).length); response.forEach((product, i) => { expect(product.productId).to.be.equal(capacities[i].productId); product.availableCapacity.forEach(({ assetId, amount, asset }, j) => { @@ -28,12 +34,11 @@ describe('Capacity Engine tests', () => { }); }); - it('should return capacity for one product', () => { - sinon.stub(store, 'getState').callsFake(() => mockStore); - const now = BigNumber.from(Date.now()).div(1000); + it('should return capacity for 1 product across all pools when productId is provided and poolId is not', function () { + const productId = '0'; + const [product] = capacityEngine(store, { productIds: [productId] }); // Removed poolId and period - const [product] = capacityEngine(store, ['0'], now); - const [expectedCapacities] = capacities; + const expectedCapacities = capacities[Number(productId)]; expect(product.productId).to.be.equal(expectedCapacities.productId); product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { @@ -42,12 +47,126 @@ describe('Capacity Engine tests', () => { }); }); - it('should throw non existing product', () => { - sinon.stub(store, 'getState').callsFake(() => mockStore); - const now = BigNumber.from(Date.now()).div(1000); + it('should return undefined for non-existing product', function () { + const nonExistingProductId = '999'; + const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); // Removed poolId and period + expect(product).to.be.equal(undefined); + }); - const [product] = capacityEngine(store, ['999'], now); + it('should return capacity for a specific product and pool when both productId and poolId are provided', function () { + const productId = '0'; + const poolId = 2; + const [product] = capacityEngine(store, { poolId, productIds: [productId] }); - expect(product).to.be.equal(undefined); + const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === Number(productId)); + + console.log('product.minAnnualPrice', product.minAnnualPrice.toString()); + console.log('expectedCapacity.minAnnualPrice', expectedCapacity.minAnnualPrice); + console.log('product.maxAnnualPrice', product.maxAnnualPrice.toString()); + console.log('expectedCapacity.maxAnnualPrice', expectedCapacity.maxAnnualPrice); + + expect(product.productId).to.equal(Number(productId)); + expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); + expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); + expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); + expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); + + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).to.be.equal(expectedCapacity.availableCapacity[i].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + + it('should return capacities for all products in a specific pool when only poolId is provided', function () { + const poolId = 2; + const response = capacityEngine(store, { poolId }); + + expect(response.length).to.be.greaterThan(0); + + response.forEach(product => { + const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === product.productId); + const productPools = mockStore.productPoolIds[product.productId]; + + expect(productPools).to.include(poolId); + expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); + expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); + expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); + expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); + + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).to.be.equal(expectedCapacity.availableCapacity[i].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + }); + + it('should return the same total capacity for a product across all pools as when poolId is not given', function () { + const productId = '0'; + const poolIds = mockStore.productPoolIds[productId]; + + // Get capacity for product 0 across all pools + const [allPoolsProduct] = capacityEngine(store, { productIds: [productId] }); + + const initObject = { + productId: Number(productId), + usedCapacity: BigNumber.from(0), + minAnnualPrice: BigNumber.from(0), + maxAnnualPrice: BigNumber.from(0), + availableCapacity: [], + }; + + // Get capacity for product 0 for each pool and sum them up + const summedCapacity = poolIds.reduce((acc, poolId) => { + const [product] = capacityEngine(store, { poolId: Number(poolId), productIds: [productId] }); + + if (!product) { + return acc; + } + + // Sum up all numeric fields + acc.usedCapacity = acc.usedCapacity.add(product.usedCapacity); + acc.minAnnualPrice = acc.minAnnualPrice.gt(product.minAnnualPrice) ? acc.minAnnualPrice : product.minAnnualPrice; + acc.maxAnnualPrice = acc.maxAnnualPrice.gt(product.maxAnnualPrice) ? acc.maxAnnualPrice : product.maxAnnualPrice; + + // Sum up availableCapacity for each asset + product.availableCapacity.forEach((capacity, index) => { + if (!acc.availableCapacity[index]) { + acc.availableCapacity[index] = { ...capacity, amount: BigNumber.from(0) }; + } + acc.availableCapacity[index].amount = acc.availableCapacity[index].amount.add(capacity.amount); + }); + + return acc; + }, initObject); + + // Assert that all fields match + expect(summedCapacity.productId).to.equal(allPoolsProduct.productId); + expect(summedCapacity.usedCapacity.toString()).to.equal(allPoolsProduct.usedCapacity.toString()); + expect(summedCapacity.minAnnualPrice.toString()).to.equal(allPoolsProduct.minAnnualPrice.toString()); + expect(summedCapacity.maxAnnualPrice.toString()).to.equal(allPoolsProduct.maxAnnualPrice.toString()); + + // Assert that availableCapacity matches for each asset + expect(summedCapacity.availableCapacity.length).to.equal(allPoolsProduct.availableCapacity.length); + summedCapacity.availableCapacity.forEach((capacity, index) => { + expect(capacity.amount.toString()).to.equal(allPoolsProduct.availableCapacity[index].amount.toString()); + expect(capacity.assetId).to.equal(allPoolsProduct.availableCapacity[index].assetId); + expect(capacity.asset).to.deep.equal(allPoolsProduct.availableCapacity[index].asset); + }); + }); + + it('should handle products with fixed price correctly', function () { + const fixedPricedProductId = '2'; + const [product] = capacityEngine(store, { productIds: [fixedPricedProductId] }); + + expect(product.productId).to.equal(Number(fixedPricedProductId)); + expect(product.minAnnualPrice).to.deep.equal(product.maxAnnualPrice); + }); + + it('should handle products without fixed price correctly', function () { + const nonFixedPricedProductId = '0'; + const [product] = capacityEngine(store, { productIds: [nonFixedPricedProductId] }); + + expect(product.productId).to.equal(Number(nonFixedPricedProductId)); + expect(product.minAnnualPrice).to.not.deep.equal(product.maxAnnualPrice); }); }); From 3b618931388c048ba3ca12cd0cc57a10aea6736b Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:19:26 +0300 Subject: [PATCH 05/19] test: add poolProductCapacities to expected test responses --- test/unit/responses.js | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/test/unit/responses.js b/test/unit/responses.js index 66c9dfe9..a1dfe177 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -7,6 +7,7 @@ const assets = { 255: { id: 255, symbol: 'NXM', decimals: 18 }, }; +// capacities response for product across ALL pools const capacities = [ { productId: 0, @@ -175,6 +176,97 @@ const capacities = [ }, ]; +// capacities response for product by pool +const poolProductCapacities = { + // poolId 2 + 2: [ + { + productId: 0, + availableCapacity: [ + { + assetId: 0, + amount: '3750158703091230720', + asset: { id: 0, symbol: 'ETH', decimals: 18 }, + }, + { + assetId: 1, + amount: '10478675352241508148979', + asset: { id: 1, symbol: 'DAI', decimals: 18 }, + }, + { + assetId: 6, + amount: '10478675347', + asset: { id: 6, symbol: 'USDC', decimals: 6 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.03', + }, + { + productId: 1, + availableCapacity: [ + { + assetId: 0, + amount: '3750158703091230720', + asset: { id: 0, symbol: 'ETH', decimals: 18 }, + }, + { + assetId: 1, + amount: '10478675352241508148979', + asset: { id: 1, symbol: 'DAI', decimals: 18 }, + }, + { + assetId: 6, + amount: '10478675347', + asset: { id: 6, symbol: 'USDC', decimals: 6 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.03', + }, + { + productId: 2, + availableCapacity: [ + { + assetId: 0, + amount: '3750158703091230720', + asset: { id: 0, symbol: 'ETH', decimals: 18 }, + }, + { + assetId: 1, + amount: '10478675352241508148979', + asset: { id: 1, symbol: 'DAI', decimals: 18 }, + }, + { + assetId: 6, + amount: '10478675347', + asset: { id: 6, symbol: 'USDC', decimals: 6 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.02', + }, + ], +}; + const ethQuote = { annualPrice: '199', premiumInNXM: '194600000000000000', @@ -264,5 +356,6 @@ const getQuote = assetId => ({ module.exports = { capacities, + poolProductCapacities, getQuote, }; From 29f9b711e7fcc5e2393873c1e7164c8de099be70 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:22:01 +0300 Subject: [PATCH 06/19] feat: add /capacity/pool and /capacity/pool/product routes --- src/routes/capacity.js | 345 ++++++++++++++++++++++++++++------------- 1 file changed, 233 insertions(+), 112 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 8be9b9a9..58e6b8d3 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -34,68 +34,31 @@ const formatCapacityResult = ({ productId, availableCapacity, usedCapacity, minA * schema: * type: array * items: - * type: object - * properties: - * productId: - * type: integer - * description: The product id - * availableCapacity: - * type: array - * description: The maximum available capacity for the product. - * The max amount of cover a user can buy for the product. - * items: - * type: object - * properties: - * assetId: - * type: integer - * description: The asset id - * amount: - * type: string - * format: integer - * description: The capacity amount expressed in the asset - * asset: - * type: object - * description: An object containing asset info - * properties: - * id: - * type: integer - * description: The id of the asset - * symbol: - * type: string - * description: The symbol of the asset - * decimals: - * type: integer - * description: The decimals of the asset - * example: 18 - * allocatedNxm: - * type: string - * format: integer - * description: The used capacity amount for active covers on the product. - * The amount of capacity locked for active covers on the product. - * minAnnualPrice: - * type: string - * description: The minimal annual price is a percentage value between 0-1. - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. - * maxAnnualPrice: - * type: string - * description: The maximal annual price is a percentage value between 0-1. - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid period + * 500: + * description: Internal Server Error */ router.get( '/capacity', asyncRoute(async (req, res) => { - const store = req.app.get('store'); - const now = BigNumber.from(Date.now()).div(1000); - const period = BigNumber.from(req.query.period || 30); + const periodQuery = Number(req.query.period) || 30; - if (period.lt(28) || period.gt(365)) { - return res.status(400).send({ error: 'Invalid period', response: null }); + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); } - const response = capacityEngine(store, [], now, period); - res.json(response.map(capacity => formatCapacityResult(capacity))); + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const response = capacityEngine(store, { period }); + + res.json(response.map(capacity => formatCapacityResult(capacity))); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); + } }), ); @@ -109,7 +72,7 @@ router.get( * parameters: * - in: path * name: productId - * required: false + * required: true * schema: * type: integer * description: The product id @@ -119,75 +82,233 @@ router.get( * content: * application/json: * schema: - * type: object - * properties: - * productId: - * type: integer - * description: The product id - * availableCapacity: - * type: array - * description: The maximum available capacity for the product. - * The max amount of cover a user can buy for the product. - * items: - * type: object - * properties: - * assetId: - * type: integer - * description: The asset id - * amount: - * type: string - * format: integer - * description: The capacity amount expressed in the the asset - * asset: - * type: object - * description: An object containing asset info - * properties: - * id: - * type: integer - * description: The id of the asset - * symbol: - * type: string - * description: The symbol of the asset - * decimals: - * type: integer - * description: The decimals of the asset - * example: 18 - * allocatedNxm: - * type: string - * description: The used capacity amount for active covers on the product. - * The amount of capacity locked for active covers on the product. - * minAnnualPrice: - * type: string - * description: The minimal annual price is a percentage value (2 decimals). - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. - * maxAnnualPrice: - * type: string - * description: The maximal annual price is a percentage value (2 decimals). - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid productId or period + * 500: + * description: Internal Server Error */ router.get( '/capacity/:productId', asyncRoute(async (req, res) => { const productId = Number(req.params.productId); - const store = req.app.get('store'); - const now = BigNumber.from(Date.now()).div(1000); - const period = BigNumber.from(req.query.period || 30); + const periodQuery = Number(req.query.period) || 30; - if (period.lt(28) || period.gt(365)) { - return res.status(400).send({ error: 'Invalid period', response: null }); + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); } + if (!Number.isInteger(productId) || productId < 0) { + return res.status(400).send({ error: 'Invalid productId: must be an integer', response: null }); + } + + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const [capacity] = capacityEngine(store, { productIds: [productId], period }); - const [capacity] = capacityEngine(store, [productId], now, period); + if (!capacity) { + return res.status(400).send({ error: 'Invalid Product Id', response: null }); + } - if (!capacity) { - return res.status(400).send({ error: 'Invalid Product Id', response: null }); + res.json(formatCapacityResult(capacity)); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); } + }), +); - res.json(formatCapacityResult(capacity)); +/** + * @openapi + * /v2/capacity/pools/{poolId}: + * get: + * tags: + * - Capacity + * description: Get capacity data for all products in a specific pool + * parameters: + * - in: path + * name: poolId + * required: true + * schema: + * type: integer + * description: The pool id + * - in: query + * name: period + * required: false + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: The period in days + * responses: + * 200: + * description: Returns capacity for all products in the specified pool + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid pool id or period + * 404: + * description: Pool not found + * 500: + * description: Internal Server Error + */ +router.get( + '/capacity/pools/:poolId', + asyncRoute(async (req, res) => { + const poolId = Number(req.params.poolId); + const periodQuery = Number(req.query.period) || 30; + + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); + } + if (!Number.isInteger(poolId) || poolId <= 0) { + return res.status(400).send({ error: 'Invalid poolId: must be a positive integer', response: null }); + } + + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const response = capacityEngine(store, { poolId, period }); + + if (response.length === 0) { + return res.status(404).send({ error: 'Pool not found', response: null }); + } + + res.json(response.map(capacity => formatCapacityResult(capacity))); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); + } }), ); +/** + * @openapi + * /v2/capacity/pools/{poolId}/products/{productId}: + * get: + * tags: + * - Capacity + * description: Get capacity data for a specific product in a specific pool + * parameters: + * - in: path + * name: poolId + * required: true + * schema: + * type: integer + * description: The pool id + * - in: path + * name: productId + * required: true + * schema: + * type: integer + * description: The product id + * - in: query + * name: period + * required: false + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: The period in days + * responses: + * 200: + * description: Returns capacity for the specified product in the specified pool + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid pool id, product id, or period + * 404: + * description: Product not found in the specified pool + * 500: + * description: Internal Server Error + */ +router.get( + '/capacity/pools/:poolId/products/:productId', + asyncRoute(async (req, res) => { + const poolId = Number(req.params.poolId); + const productId = Number(req.params.productId); + const periodQuery = Number(req.query.period) || 30; + + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); + } + if (!Number.isInteger(poolId) || poolId <= 0) { + return res.status(400).send({ error: 'Invalid poolId: must be a positive integer', response: null }); + } + if (!Number.isInteger(productId) || productId < 0) { + return res.status(400).send({ error: 'Invalid productId: must be an integer', response: null }); + } + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const [capacity] = capacityEngine(store, { poolId, productIds: [productId], period }); + if (!capacity) { + return res.status(404).send({ error: 'Product not found in the specified pool', response: null }); + } + res.json(formatCapacityResult(capacity)); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); + } + }), +); + +/** + * @openapi + * components: + * schemas: + * CapacityResult: + * type: object + * properties: + * productId: + * type: integer + * description: The product id + * availableCapacity: + * type: array + * description: The maximum available capacity for the product. + * items: + * type: object + * properties: + * assetId: + * type: integer + * description: The asset id + * amount: + * type: string + * format: integer + * description: The capacity amount expressed in the asset + * asset: + * type: object + * description: An object containing asset info + * properties: + * id: + * type: integer + * description: The id of the asset + * symbol: + * type: string + * description: The symbol of the asset + * decimals: + * type: integer + * description: The decimals of the asset + * example: 18 + * allocatedNxm: + * type: string + * format: integer + * description: The used capacity amount for active covers on the product. + * minAnnualPrice: + * type: string + * description: The minimal annual price is a percentage value between 0-1. + * maxAnnualPrice: + * type: string + * description: The maximal annual price is a percentage value between 0-1. + */ + module.exports = router; From 23de44da9774232b6c939797f46d17a4ff6b06a9 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:22:19 +0300 Subject: [PATCH 07/19] test: update capacity routes unit tests --- test/unit/routes/capacity.js | 135 ++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 16 deletions(-) diff --git a/test/unit/routes/capacity.js b/test/unit/routes/capacity.js index e3d8b910..d4cef6ab 100644 --- a/test/unit/routes/capacity.js +++ b/test/unit/routes/capacity.js @@ -1,32 +1,135 @@ const { expect } = require('chai'); +const sinon = require('sinon'); const supertest = require('supertest'); +const selectorsModule = require('../../../src/store/selectors'); const initApp = require('../../mocks/server'); -const { capacities } = require('../responses'); +const { capacities, poolProductCapacities } = require('../responses'); -describe('GET /capacity', () => { +describe('Capacity Routes', () => { let server; - before(() => { + let selectProductStub; + + beforeEach(() => { const app = initApp(); server = supertest(app); }); - it('should get all capacities for products', async function () { - const { body: response } = await server.get('/v2/capacity').expect('Content-Type', /json/).expect(200); - expect(response).to.be.deep.equal(capacities); + afterEach(() => { + if (selectProductStub) { + selectProductStub.restore(); + } + }); + + describe('GET /capacity', () => { + it('should get all capacities for products', async function () { + const url = '/v2/capacity'; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(capacities); + }); + + it('should return 400 Invalid period', async function () { + const invalidPeriod = 10; + const url = `/v2/capacity?period=${invalidPeriod}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid period: must be an integer between 28 and 365'); + }); }); - it('should get all capacities for one product', async function () { - const productId = 0; - const { body: response } = await server.get(`/v2/capacity/${productId}`).expect('Content-Type', /json/).expect(200); - expect(response).to.be.deep.equal(capacities[0]); + describe('GET /capacity/:productId', () => { + it('should get all capacities for one product', async function () { + const productId = 0; + const url = `/v2/capacity/${productId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(capacities[0]); + }); + + it('should return 400 Invalid Product Id for non-existent productId', async function () { + const nonExistentProductId = 999; + const url = `/v2/capacity/${nonExistentProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid Product Id'); + }); + + it('should return 400 Invalid productId if the productId is not an integer', async function () { + const invalidProductId = -1; + const url = `/v2/capacity/${invalidProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid productId: must be an integer'); + }); }); - it('should throw an error if product is non-existant', async function () { - const productId = 5; - const { - body: { error }, - } = await server.get(`/v2/capacity/${productId}`).expect('Content-Type', /json/).expect(400); - expect(error).to.be.equal('Invalid Product Id'); + describe('GET /capacity/pools/:poolId', () => { + it('should get all capacities for all products in a specific pool', async function () { + const poolId = 2; + const url = `/v2/capacity/pools/${poolId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(poolProductCapacities[poolId]); + }); + + it('should return 400 Invalid poolId', async function () { + const invalidPoolId = 0; + const url = `/v2/capacity/pools/${invalidPoolId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid poolId: must be a positive integer'); + }); + + it('should return 400 Invalid period', async function () { + const invalidPeriod = 10; + const poolId = 2; + const url = `/v2/capacity/pools/${poolId}?period=${invalidPeriod}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid period: must be an integer between 28 and 365'); + }); + + it('should return 404 Pool not found', async function () { + const nonExistentPoolId = 999; + const url = `/v2/capacity/pools/${nonExistentPoolId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(404); + expect(response.error).to.be.equal('Pool not found'); + }); + }); + + describe('GET /capacity/pools/:poolId/products/:productId', () => { + it('should get capacity for a specific product in a specific pool', async function () { + const poolId = 2; + const productId = 0; + const url = `/v2/capacity/pools/${poolId}/products/${productId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(poolProductCapacities[poolId][productId]); + }); + + it('should return 400 Invalid productId', async function () { + const poolId = 2; + const invalidProductId = -1; + const url = `/v2/capacity/pools/${poolId}/products/${invalidProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid productId: must be an integer'); + }); + + it('should return 400 Invalid poolId', async function () { + const invalidPoolId = 0; + const productId = 0; + const url = `/v2/capacity/pools/${invalidPoolId}/products/${productId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid poolId: must be a positive integer'); + }); + + it('should return 400 Invalid period', async function () { + const invalidPeriod = 10; + const poolId = 2; + const productId = 0; + const url = `/v2/capacity/pools/${poolId}/products/${productId}?period=${invalidPeriod}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid period: must be an integer between 28 and 365'); + }); + + it('should return 404 Product not found in the specified pool', async function () { + const poolId = 2; + const nonExistentProductId = 999; + const url = `/v2/capacity/pools/${poolId}/products/${nonExistentProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(404); + expect(response.error).to.be.equal('Product not found in the specified pool'); + }); }); }); From b5affd45639aac380338d5d1b81e466101ccfd37 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:22:56 +0300 Subject: [PATCH 08/19] docs: update README to add docs on new /capacity/pool and /capacity/pool/product endpoints --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index effe51a3..6c2cb961 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Computes the optimal capacity allocation in order to get the best price on cover - [Quote Route](#quote-route) - [Capacity Route](#capacity-route) - [Capacity Route for a specific product](#capacity-route-for-a-specific-product) + - [Capacity Route for all products in a pool](#capacity-route-for-all-products-in-a-pool) + - [Capacity Route for a specific product in a pool](#capacity-route-for-a-specific-product-in-a-pool) ## Setup @@ -49,4 +51,24 @@ best available combination of pools for the premium. - **OpenAPI**: [v2/api/docs/#/Capacity/get_v2_capacity__productId_](https://api.nexusmutual.io/v2/api/docs/#/Capacity/get_v2_capacity__productId_) - **Description**: Returns the current capacity for a specific product for a period of 30 days if no period query param is specified. +### Capacity Route for all products in a pool +- **URL**: `/v2/capacity/pools/{poolId}` +- **Method**: `GET` +- **OpenAPI**: [v2/api/docs/#/Capacity/get_v2_capacity_pools__poolId_](https://api.nexusmutual.io/v2/api/docs/#/Capacity/ +get_v2_capacity_pools__poolId_) + +- **Description**: Returns the current capacity for all products in a specific pool for a period of 30 days if no period query param is specified. +- **Parameters**: + - `poolId`: Required path parameter specifying the pool ID. + - `period`: Optional query parameter specifying the period in days (default is 30, range is 28-365). +### Capacity Route for a specific product in a pool +- **URL**: `/v2/capacity/pools/{poolId}/products/{productId}` +- **Method**: `GET` +- **OpenAPI**: [v2/api/docs/#/Capacity/get_v2_capacity_pools__poolId__products__productId_](https://api.nexusmutual.io/v2/api/docs/#/Capacity/get_v2_capacity_pools__poolId__products__productId_) + +- **Description**: Returns the current capacity for a specific product in a specific pool for a period of 30 days if no period query param is specified. +- **Parameters**: + - `poolId`: Required path parameter specifying the pool ID. + - `productId`: Required path parameter specifying the product ID. + - `period`: Optional query parameter specifying the period in days (default is 30, range is 28-365). From b82a6b34c1371c76235d386f544428e072e868e3 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:23:24 +0300 Subject: [PATCH 09/19] chore: update package version to 2.4.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77237f2a..bac0008b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cover-router", - "version": "2.3.8", + "version": "2.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cover-router", - "version": "2.3.8", + "version": "2.4.0", "license": "ISC", "dependencies": { "@nexusmutual/deployments": "^2.10.0", diff --git a/package.json b/package.json index 74ede524..cd4fe494 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cover-router", - "version": "2.3.8", + "version": "2.4.0", "description": "Cover Router", "main": "src/index.js", "engines": { From 8b1aa688f3bd7e9a3e958563ecbcfe2397b7ddec Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 2 Oct 2024 16:24:13 +0300 Subject: [PATCH 10/19] style: fix linting issues + remove dev comments --- test/unit/capacityEngine.js | 4 ++-- test/unit/routes/capacity.js | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index 3527fe1c..dccdfcd2 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -36,7 +36,7 @@ describe('Capacity Engine tests', function () { it('should return capacity for 1 product across all pools when productId is provided and poolId is not', function () { const productId = '0'; - const [product] = capacityEngine(store, { productIds: [productId] }); // Removed poolId and period + const [product] = capacityEngine(store, { productIds: [productId] }); const expectedCapacities = capacities[Number(productId)]; @@ -49,7 +49,7 @@ describe('Capacity Engine tests', function () { it('should return undefined for non-existing product', function () { const nonExistingProductId = '999'; - const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); // Removed poolId and period + const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); expect(product).to.be.equal(undefined); }); diff --git a/test/unit/routes/capacity.js b/test/unit/routes/capacity.js index d4cef6ab..87c4a636 100644 --- a/test/unit/routes/capacity.js +++ b/test/unit/routes/capacity.js @@ -1,8 +1,6 @@ const { expect } = require('chai'); -const sinon = require('sinon'); const supertest = require('supertest'); -const selectorsModule = require('../../../src/store/selectors'); const initApp = require('../../mocks/server'); const { capacities, poolProductCapacities } = require('../responses'); From cbde3b90616cc9d962e6e6bc6bafa0b66d763f8a Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 3 Oct 2024 18:28:27 +0300 Subject: [PATCH 11/19] feat: add utilizationRate field to capacity response --- src/lib/capacityEngine.js | 23 ++++++++++++++++++++++- src/routes/capacity.js | 19 ++++++++++++------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 2faf99fd..1cc6d1d5 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -9,6 +9,22 @@ const { WeiPerEther, Zero } = ethers.constants; const SECONDS_PER_DAY = BigNumber.from(24 * 3600); +/** + * Calculates the utilization rate of the capacity. + * + * @param {Array} capacityInAssets - Array of asset objects containing assetId and amount. + * @param {BigNumber} capacityUsedNXM - The amount of capacity used in NXM. + * @returns {BigNumber} The utilization rate as a BigNumber. Returns undefined is capacity in NXM is missing + */ +function getUtilizationRate(capacityInAssets, capacityUsedNXM) { + const availableCapacityInNxm = capacityInAssets.find(asset => asset.assetId === 255)?.amount; + if (!availableCapacityInNxm || !capacityUsedNXM) { + return undefined; + } + const totalCapacity = availableCapacityInNxm.add(capacityUsedNXM); + return capacityUsedNXM.mul(10000).div(totalCapacity).toNumber() / 10000; +} + /** * Calculates capacity and pricing data for a specific tranche of product pools. * @@ -176,6 +192,7 @@ function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = productId: Number(productId), availableCapacity: capacityInAssets, usedCapacity: capacityUsedNXM, + utilizationRate: getUtilizationRate(capacityInAssets, capacityUsedNXM), minAnnualPrice: minPrice, maxAnnualPrice, }); @@ -212,6 +229,7 @@ function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = productId: Number(productId), availableCapacity: capacityInAssets, usedCapacity: capacityUsedNXM, + utilizationRate: getUtilizationRate(capacityInAssets, capacityUsedNXM), minAnnualPrice: minPrice, maxAnnualPrice, }); @@ -221,4 +239,7 @@ function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = return capacities; } -module.exports = capacityEngine; +module.exports = { + capacityEngine, + getUtilizationRate, +}; diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 58e6b8d3..3238c5fb 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -1,22 +1,23 @@ const { ethers, BigNumber } = require('ethers'); const express = require('express'); -const capacityEngine = require('../lib/capacityEngine'); +const { capacityEngine } = require('../lib/capacityEngine'); const { asyncRoute } = require('../lib/helpers'); const router = express.Router(); const { formatUnits } = ethers.utils; -const formatCapacityResult = ({ productId, availableCapacity, usedCapacity, minAnnualPrice, maxAnnualPrice }) => ({ - productId, - availableCapacity: availableCapacity.map(({ assetId, amount, asset }) => ({ +const formatCapacityResult = capacity => ({ + productId: capacity.productId, + availableCapacity: capacity.availableCapacity.map(({ assetId, amount, asset }) => ({ assetId, amount: amount.toString(), asset, })), - allocatedNxm: usedCapacity.toString(), - minAnnualPrice: formatUnits(minAnnualPrice), - maxAnnualPrice: formatUnits(maxAnnualPrice), + allocatedNxm: capacity.usedCapacity.toString(), + utilizationRate: capacity.utilizationRate, + minAnnualPrice: formatUnits(capacity.minAnnualPrice), + maxAnnualPrice: formatUnits(capacity.maxAnnualPrice), }); /** @@ -303,6 +304,10 @@ router.get( * type: string * format: integer * description: The used capacity amount for active covers on the product. + * utilizationRate: + * type: number + * format: float + * description: The ratio of used capacity to total capacity, expressed as a value between 0 and 1. * minAnnualPrice: * type: string * description: The minimal annual price is a percentage value between 0-1. From 524dab9a6434f5c66319f4277f4833b02dedf987 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 3 Oct 2024 18:29:34 +0300 Subject: [PATCH 12/19] test: add utilizationRate and getUtilizationRate unit tests --- test/unit/capacityEngine.js | 287 +++++++++++++++++++++--------------- test/unit/responses.js | 9 ++ 2 files changed, 178 insertions(+), 118 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index dccdfcd2..0cca4f7d 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -2,93 +2,74 @@ const { expect } = require('chai'); const ethers = require('ethers'); const sinon = require('sinon'); -const { capacities, poolProductCapacities } = require('./responses'); -const capacityEngine = require('../../src/lib/capacityEngine'); +const { assets, capacities, poolProductCapacities } = require('./responses'); +const { capacityEngine, getUtilizationRate } = require('../../src/lib/capacityEngine'); // Import the function to test const { selectAsset } = require('../../src/store/selectors'); const mockStore = require('../mocks/store'); const { BigNumber } = ethers; const { parseEther } = ethers.utils; +const { Zero } = ethers.constants; describe('Capacity Engine tests', function () { - const store = { getState: () => null }; + describe('capacityEngine', function () { + const store = { getState: () => null }; - beforeEach(function () { - sinon.stub(store, 'getState').callsFake(() => mockStore); - }); - - afterEach(function () { - sinon.restore(); - }); - - it('should return capacity for all products when no productIds or poolId are provided', function () { - const response = capacityEngine(store, { period: 30 }); - - expect(response).to.have.lengthOf(Object.keys(mockStore.products).length); - response.forEach((product, i) => { - expect(product.productId).to.be.equal(capacities[i].productId); - product.availableCapacity.forEach(({ assetId, amount, asset }, j) => { - expect(amount.toString()).to.be.equal(capacities[i].availableCapacity[j].amount); - expect(asset).to.deep.equal(selectAsset(store, assetId)); - }); + beforeEach(function () { + sinon.stub(store, 'getState').callsFake(() => mockStore); }); - }); - it('should return capacity for 1 product across all pools when productId is provided and poolId is not', function () { - const productId = '0'; - const [product] = capacityEngine(store, { productIds: [productId] }); + afterEach(function () { + sinon.restore(); + }); - const expectedCapacities = capacities[Number(productId)]; + it('should return capacity for all products when no productIds or poolId are provided', function () { + const response = capacityEngine(store, { period: 30 }); - expect(product.productId).to.be.equal(expectedCapacities.productId); - product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { - expect(amount.toString()).not.to.be.equal(expectedCapacities.availableCapacity[i]); - expect(asset).to.deep.equal(selectAsset(store, assetId)); - }); - }); + expect(response).to.have.lengthOf(Object.keys(mockStore.products).length); - it('should return undefined for non-existing product', function () { - const nonExistingProductId = '999'; - const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); - expect(product).to.be.equal(undefined); - }); + response.forEach((product, i) => { + expect(product.productId).to.be.equal(capacities[i].productId); + expect(product.utilizationRate).to.be.equal(capacities[i].utilizationRate); - it('should return capacity for a specific product and pool when both productId and poolId are provided', function () { - const productId = '0'; - const poolId = 2; - const [product] = capacityEngine(store, { poolId, productIds: [productId] }); + product.availableCapacity.forEach(({ assetId, amount, asset }, j) => { + expect(amount.toString()).to.be.equal(capacities[i].availableCapacity[j].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + }); - const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === Number(productId)); + it('should return capacity for 1 product across all pools if productId is provided and poolId is not', function () { + const productId = '0'; + const [product] = capacityEngine(store, { productIds: [productId] }); - console.log('product.minAnnualPrice', product.minAnnualPrice.toString()); - console.log('expectedCapacity.minAnnualPrice', expectedCapacity.minAnnualPrice); - console.log('product.maxAnnualPrice', product.maxAnnualPrice.toString()); - console.log('expectedCapacity.maxAnnualPrice', expectedCapacity.maxAnnualPrice); + const expectedCapacity = capacities[Number(productId)]; - expect(product.productId).to.equal(Number(productId)); - expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); - expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); - expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); - expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); + expect(product.productId).to.be.equal(expectedCapacity.productId); + expect(product.utilizationRate).to.be.equal(expectedCapacity.utilizationRate); - product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { - expect(amount.toString()).to.be.equal(expectedCapacity.availableCapacity[i].amount); - expect(asset).to.deep.equal(selectAsset(store, assetId)); + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).not.to.be.equal(expectedCapacity.availableCapacity[i]); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); }); - }); - it('should return capacities for all products in a specific pool when only poolId is provided', function () { - const poolId = 2; - const response = capacityEngine(store, { poolId }); + it('should return undefined for non-existing product', function () { + const nonExistingProductId = '999'; + const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); + expect(product).to.be.equal(undefined); + }); - expect(response.length).to.be.greaterThan(0); + it('should return capacity for a specific product and pool if both productId and poolId are provided', function () { + const productId = '0'; + const poolId = 2; + const [product] = capacityEngine(store, { poolId, productIds: [productId] }); - response.forEach(product => { - const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === product.productId); - const productPools = mockStore.productPoolIds[product.productId]; + const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === Number(productId)); - expect(productPools).to.include(poolId); + expect(product.productId).to.equal(Number(productId)); expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); + expect(product.utilizationRate).to.be.equal(expectedCapacity.utilizationRate); expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); @@ -98,75 +79,145 @@ describe('Capacity Engine tests', function () { expect(asset).to.deep.equal(selectAsset(store, assetId)); }); }); - }); - it('should return the same total capacity for a product across all pools as when poolId is not given', function () { - const productId = '0'; - const poolIds = mockStore.productPoolIds[productId]; + it('should return capacities for all products in a specific pool when only poolId is provided', function () { + const poolId = 2; + const response = capacityEngine(store, { poolId }); + + expect(response.length).to.be.greaterThan(0); + + response.forEach(product => { + const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === product.productId); + const productPools = mockStore.productPoolIds[product.productId]; + expect(productPools).to.include(poolId); + expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); + expect(product.utilizationRate).to.be.equal(expectedCapacity.utilizationRate); + expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); + expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); + expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); + + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).to.be.equal(expectedCapacity.availableCapacity[i].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + }); - // Get capacity for product 0 across all pools - const [allPoolsProduct] = capacityEngine(store, { productIds: [productId] }); + it('should return the same total capacity for a product across all pools as when poolId is not given', function () { + const productId = '0'; + const poolIds = mockStore.productPoolIds[productId]; - const initObject = { - productId: Number(productId), - usedCapacity: BigNumber.from(0), - minAnnualPrice: BigNumber.from(0), - maxAnnualPrice: BigNumber.from(0), - availableCapacity: [], - }; + // Get capacity for product 0 across all pools + const [allPoolsProduct] = capacityEngine(store, { productIds: [productId] }); - // Get capacity for product 0 for each pool and sum them up - const summedCapacity = poolIds.reduce((acc, poolId) => { - const [product] = capacityEngine(store, { poolId: Number(poolId), productIds: [productId] }); + const initObject = { + productId: Number(productId), + usedCapacity: BigNumber.from(0), + minAnnualPrice: BigNumber.from(0), + maxAnnualPrice: BigNumber.from(0), + availableCapacity: [], + }; - if (!product) { - return acc; - } + // Get capacity for product 0 for each pool and sum them up + const summedCapacity = poolIds.reduce((acc, poolId) => { + const [product] = capacityEngine(store, { poolId: Number(poolId), productIds: [productId] }); - // Sum up all numeric fields - acc.usedCapacity = acc.usedCapacity.add(product.usedCapacity); - acc.minAnnualPrice = acc.minAnnualPrice.gt(product.minAnnualPrice) ? acc.minAnnualPrice : product.minAnnualPrice; - acc.maxAnnualPrice = acc.maxAnnualPrice.gt(product.maxAnnualPrice) ? acc.maxAnnualPrice : product.maxAnnualPrice; - - // Sum up availableCapacity for each asset - product.availableCapacity.forEach((capacity, index) => { - if (!acc.availableCapacity[index]) { - acc.availableCapacity[index] = { ...capacity, amount: BigNumber.from(0) }; + if (!product) { + return acc; } - acc.availableCapacity[index].amount = acc.availableCapacity[index].amount.add(capacity.amount); + + // Sum up all numeric fields + acc.usedCapacity = acc.usedCapacity.add(product.usedCapacity); + acc.minAnnualPrice = acc.minAnnualPrice.gt(product.minAnnualPrice) + ? acc.minAnnualPrice + : product.minAnnualPrice; + acc.maxAnnualPrice = acc.maxAnnualPrice.gt(product.maxAnnualPrice) + ? acc.maxAnnualPrice + : product.maxAnnualPrice; + + // Sum up availableCapacity for each asset + product.availableCapacity.forEach((capacity, index) => { + if (!acc.availableCapacity[index]) { + acc.availableCapacity[index] = { ...capacity, amount: BigNumber.from(0) }; + } + acc.availableCapacity[index].amount = acc.availableCapacity[index].amount.add(capacity.amount); + }); + + return acc; + }, initObject); + + // Assert that all fields match + const utilizationRate = getUtilizationRate(summedCapacity.availableCapacity, summedCapacity.usedCapacity); + expect(summedCapacity.productId).to.equal(allPoolsProduct.productId); + expect(summedCapacity.usedCapacity.toString()).to.equal(allPoolsProduct.usedCapacity.toString()); + expect(utilizationRate).to.be.equal(allPoolsProduct.utilizationRate); + expect(summedCapacity.minAnnualPrice.toString()).to.equal(allPoolsProduct.minAnnualPrice.toString()); + expect(summedCapacity.maxAnnualPrice.toString()).to.equal(allPoolsProduct.maxAnnualPrice.toString()); + + // Assert that availableCapacity matches for each asset + expect(summedCapacity.availableCapacity.length).to.equal(allPoolsProduct.availableCapacity.length); + summedCapacity.availableCapacity.forEach((capacity, index) => { + expect(capacity.amount.toString()).to.equal(allPoolsProduct.availableCapacity[index].amount.toString()); + expect(capacity.assetId).to.equal(allPoolsProduct.availableCapacity[index].assetId); + expect(capacity.asset).to.deep.equal(allPoolsProduct.availableCapacity[index].asset); }); + }); + + it('should handle products with fixed price correctly', function () { + const fixedPricedProductId = '2'; + const [product] = capacityEngine(store, { productIds: [fixedPricedProductId] }); - return acc; - }, initObject); - - // Assert that all fields match - expect(summedCapacity.productId).to.equal(allPoolsProduct.productId); - expect(summedCapacity.usedCapacity.toString()).to.equal(allPoolsProduct.usedCapacity.toString()); - expect(summedCapacity.minAnnualPrice.toString()).to.equal(allPoolsProduct.minAnnualPrice.toString()); - expect(summedCapacity.maxAnnualPrice.toString()).to.equal(allPoolsProduct.maxAnnualPrice.toString()); - - // Assert that availableCapacity matches for each asset - expect(summedCapacity.availableCapacity.length).to.equal(allPoolsProduct.availableCapacity.length); - summedCapacity.availableCapacity.forEach((capacity, index) => { - expect(capacity.amount.toString()).to.equal(allPoolsProduct.availableCapacity[index].amount.toString()); - expect(capacity.assetId).to.equal(allPoolsProduct.availableCapacity[index].assetId); - expect(capacity.asset).to.deep.equal(allPoolsProduct.availableCapacity[index].asset); + expect(product.productId).to.equal(Number(fixedPricedProductId)); + expect(product.minAnnualPrice).to.deep.equal(product.maxAnnualPrice); }); - }); - it('should handle products with fixed price correctly', function () { - const fixedPricedProductId = '2'; - const [product] = capacityEngine(store, { productIds: [fixedPricedProductId] }); + it('should handle products without fixed price correctly', function () { + const nonFixedPricedProductId = '0'; + const [product] = capacityEngine(store, { productIds: [nonFixedPricedProductId] }); - expect(product.productId).to.equal(Number(fixedPricedProductId)); - expect(product.minAnnualPrice).to.deep.equal(product.maxAnnualPrice); + expect(product.productId).to.equal(Number(nonFixedPricedProductId)); + expect(product.minAnnualPrice).to.not.deep.equal(product.maxAnnualPrice); + }); }); - it('should handle products without fixed price correctly', function () { - const nonFixedPricedProductId = '0'; - const [product] = capacityEngine(store, { productIds: [nonFixedPricedProductId] }); + describe('getUtilizationRate tests', function () { + it('should calculate utilization rate correctly when there is available capacity', function () { + const capacityInAssets = [{ assetId: 255, amount: parseEther('100'), asset: assets[255] }]; + const capacityUsedNXM = parseEther('50'); + + const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + + expect(utilizationRate).to.equal(0.3333); + }); + + it('should return 1 when ALL capacity is used (no available capacity)', function () { + const capacityInAssets = [{ assetId: 255, amount: Zero, asset: assets[255] }]; + const capacityUsedNXM = parseEther('150'); + + const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + + expect(utilizationRate).to.equal(1); + }); + + it('should handle multiple assets and return the correct utilization rate', function () { + const capacityInAssets = [ + { assetId: 255, amount: parseEther('200'), asset: assets[255] }, + { assetId: 1, amount: parseEther('100'), asset: assets[1] }, + ]; + const capacityUsedNXM = parseEther('100'); + + const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); - expect(product.productId).to.equal(Number(nonFixedPricedProductId)); - expect(product.minAnnualPrice).to.not.deep.equal(product.maxAnnualPrice); + expect(utilizationRate).to.equal(0.3333); + }); + + it('should return undefined when no asset with assetId 255 is present', function () { + const capacityInAssets = [{ assetId: 1, amount: parseEther('100'), asset: assets[1] }]; + const capacityUsedNXM = parseEther('50'); + + const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + + expect(utilizationRate).to.equal(undefined); + }); }); }); diff --git a/test/unit/responses.js b/test/unit/responses.js index a1dfe177..42b6a2dd 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -41,6 +41,7 @@ const capacities = [ asset: assets[255], }, ], + utilizationRate: 0.4405, }, { productId: 1, @@ -74,6 +75,7 @@ const capacities = [ asset: assets[255], }, ], + utilizationRate: 0, }, { productId: 2, @@ -107,6 +109,7 @@ const capacities = [ asset: assets[255], }, ], + utilizationRate: 0, }, { productId: 3, @@ -140,6 +143,7 @@ const capacities = [ allocatedNxm: '32725200000000000000000', minAnnualPrice: '0.0775', maxAnnualPrice: '0.104190714614767679', + utilizationRate: 0.3491, }, { productId: 4, @@ -173,6 +177,7 @@ const capacities = [ allocatedNxm: '20004610000000000000000', maxAnnualPrice: '0.077089706487431343', minAnnualPrice: '0.02', + utilizationRate: 0.8467, }, ]; @@ -207,6 +212,7 @@ const poolProductCapacities = { allocatedNxm: '0', minAnnualPrice: '0.02', maxAnnualPrice: '0.03', + utilizationRate: 0, }, { productId: 1, @@ -235,6 +241,7 @@ const poolProductCapacities = { allocatedNxm: '0', minAnnualPrice: '0.02', maxAnnualPrice: '0.03', + utilizationRate: 0, }, { productId: 2, @@ -263,6 +270,7 @@ const poolProductCapacities = { allocatedNxm: '0', minAnnualPrice: '0.02', maxAnnualPrice: '0.02', + utilizationRate: 0, }, ], }; @@ -355,6 +363,7 @@ const getQuote = assetId => ({ }); module.exports = { + assets, capacities, poolProductCapacities, getQuote, From 02cffa1c7af7d9e79ffe4699f7c30506de3af44f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 10 Oct 2024 10:41:11 +0300 Subject: [PATCH 13/19] refactor: make getUtilizationRate return value as basis points --- src/lib/capacityEngine.js | 7 +++++-- src/routes/capacity.js | 6 +++--- test/unit/responses.js | 8 ++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 1cc6d1d5..b4e2d584 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -8,21 +8,24 @@ const { selectAsset, selectProduct, selectProductPools } = require('../store/sel const { WeiPerEther, Zero } = ethers.constants; const SECONDS_PER_DAY = BigNumber.from(24 * 3600); +const BASIS_POINTS = 10000; /** * Calculates the utilization rate of the capacity. * * @param {Array} capacityInAssets - Array of asset objects containing assetId and amount. * @param {BigNumber} capacityUsedNXM - The amount of capacity used in NXM. - * @returns {BigNumber} The utilization rate as a BigNumber. Returns undefined is capacity in NXM is missing + * @returns {BigNumber} The utilization rate as a BigNumber, expressed in basis points (0-10,000). + * Returns undefined if capacity in NXM is missing. */ function getUtilizationRate(capacityInAssets, capacityUsedNXM) { const availableCapacityInNxm = capacityInAssets.find(asset => asset.assetId === 255)?.amount; if (!availableCapacityInNxm || !capacityUsedNXM) { return undefined; } + const totalCapacity = availableCapacityInNxm.add(capacityUsedNXM); - return capacityUsedNXM.mul(10000).div(totalCapacity).toNumber() / 10000; + return capacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); } /** diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 3238c5fb..1f18343f 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -15,7 +15,7 @@ const formatCapacityResult = capacity => ({ asset, })), allocatedNxm: capacity.usedCapacity.toString(), - utilizationRate: capacity.utilizationRate, + utilizationRate: capacity.utilizationRate.toNumber(), minAnnualPrice: formatUnits(capacity.minAnnualPrice), maxAnnualPrice: formatUnits(capacity.maxAnnualPrice), }); @@ -306,8 +306,8 @@ router.get( * description: The used capacity amount for active covers on the product. * utilizationRate: * type: number - * format: float - * description: The ratio of used capacity to total capacity, expressed as a value between 0 and 1. + * format: integer + * description: The percentage of used capacity to total capacity, expressed as basis points (0-10,000). * minAnnualPrice: * type: string * description: The minimal annual price is a percentage value between 0-1. diff --git a/test/unit/responses.js b/test/unit/responses.js index 42b6a2dd..8d41a2f6 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -41,7 +41,7 @@ const capacities = [ asset: assets[255], }, ], - utilizationRate: 0.4405, + utilizationRate: 4405, }, { productId: 1, @@ -143,7 +143,7 @@ const capacities = [ allocatedNxm: '32725200000000000000000', minAnnualPrice: '0.0775', maxAnnualPrice: '0.104190714614767679', - utilizationRate: 0.3491, + utilizationRate: 3491, }, { productId: 4, @@ -177,7 +177,7 @@ const capacities = [ allocatedNxm: '20004610000000000000000', maxAnnualPrice: '0.077089706487431343', minAnnualPrice: '0.02', - utilizationRate: 0.8467, + utilizationRate: 8467, }, ]; @@ -367,4 +367,4 @@ module.exports = { capacities, poolProductCapacities, getQuote, -}; +}; \ No newline at end of file From 377ffc99dc12df030137566a7a669e12e858f091 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 10 Oct 2024 10:43:19 +0300 Subject: [PATCH 14/19] test: fix utilizationRate assertions to basis points --- test/unit/capacityEngine.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index 0cca4f7d..512a01e9 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -30,7 +30,7 @@ describe('Capacity Engine tests', function () { response.forEach((product, i) => { expect(product.productId).to.be.equal(capacities[i].productId); - expect(product.utilizationRate).to.be.equal(capacities[i].utilizationRate); + expect(product.utilizationRate.toNumber()).to.be.equal(capacities[i].utilizationRate); product.availableCapacity.forEach(({ assetId, amount, asset }, j) => { expect(amount.toString()).to.be.equal(capacities[i].availableCapacity[j].amount); @@ -46,7 +46,7 @@ describe('Capacity Engine tests', function () { const expectedCapacity = capacities[Number(productId)]; expect(product.productId).to.be.equal(expectedCapacity.productId); - expect(product.utilizationRate).to.be.equal(expectedCapacity.utilizationRate); + expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { expect(amount.toString()).not.to.be.equal(expectedCapacity.availableCapacity[i]); @@ -69,7 +69,7 @@ describe('Capacity Engine tests', function () { expect(product.productId).to.equal(Number(productId)); expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); - expect(product.utilizationRate).to.be.equal(expectedCapacity.utilizationRate); + expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); @@ -91,7 +91,7 @@ describe('Capacity Engine tests', function () { const productPools = mockStore.productPoolIds[product.productId]; expect(productPools).to.include(poolId); expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); - expect(product.utilizationRate).to.be.equal(expectedCapacity.utilizationRate); + expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); @@ -150,7 +150,7 @@ describe('Capacity Engine tests', function () { const utilizationRate = getUtilizationRate(summedCapacity.availableCapacity, summedCapacity.usedCapacity); expect(summedCapacity.productId).to.equal(allPoolsProduct.productId); expect(summedCapacity.usedCapacity.toString()).to.equal(allPoolsProduct.usedCapacity.toString()); - expect(utilizationRate).to.be.equal(allPoolsProduct.utilizationRate); + expect(utilizationRate.toNumber()).to.be.equal(allPoolsProduct.utilizationRate.toNumber()); expect(summedCapacity.minAnnualPrice.toString()).to.equal(allPoolsProduct.minAnnualPrice.toString()); expect(summedCapacity.maxAnnualPrice.toString()).to.equal(allPoolsProduct.maxAnnualPrice.toString()); @@ -187,7 +187,9 @@ describe('Capacity Engine tests', function () { const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); - expect(utilizationRate).to.equal(0.3333); + const expectedRate = BigNumber.from(3333); // (50 / (100 + 50)) * 10000 = 3333 basis points + + expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); }); it('should return 1 when ALL capacity is used (no available capacity)', function () { @@ -196,7 +198,7 @@ describe('Capacity Engine tests', function () { const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); - expect(utilizationRate).to.equal(1); + expect(utilizationRate.toNumber()).to.equal(10000); }); it('should handle multiple assets and return the correct utilization rate', function () { @@ -208,7 +210,9 @@ describe('Capacity Engine tests', function () { const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); - expect(utilizationRate).to.equal(0.3333); + const expectedRate = BigNumber.from(3333); // (100 / (200 + 100)) * 10000 = 3333 basis points + + expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); }); it('should return undefined when no asset with assetId 255 is present', function () { From c81235c7e0f81e91e6193249ded1eea36dc5de8c Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 10 Oct 2024 10:44:16 +0300 Subject: [PATCH 15/19] fix: getUtilizationRate return 0 if totalCapacity is 0 + tests --- src/lib/capacityEngine.js | 4 ++++ test/unit/capacityEngine.js | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index b4e2d584..6b6d9323 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -25,6 +25,10 @@ function getUtilizationRate(capacityInAssets, capacityUsedNXM) { } const totalCapacity = availableCapacityInNxm.add(capacityUsedNXM); + if (totalCapacity.isZero()) { + return BigNumber.from(0); + } + return capacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); } diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index 512a01e9..2003af22 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -215,7 +215,8 @@ describe('Capacity Engine tests', function () { expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); }); - it('should return undefined when no asset with assetId 255 is present', function () { + it('should return undefined if utilizationRate cannot be calculated because of missing data', function () { + // missing capacity in NXM (255) const capacityInAssets = [{ assetId: 1, amount: parseEther('100'), asset: assets[1] }]; const capacityUsedNXM = parseEther('50'); @@ -223,5 +224,14 @@ describe('Capacity Engine tests', function () { expect(utilizationRate).to.equal(undefined); }); + + it('should return undefined when there is no capacity available', function () { + const capacityInAssets = [{ assetId: 255, amount: Zero, asset: assets[255] }]; + const capacityUsedNXM = Zero; + + const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + + expect(utilizationRate.toNumber()).to.equal(0); + }); }); }); From f117f00e22a8d35e3f90a082a1f99637336c1826 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 10 Oct 2024 10:45:00 +0300 Subject: [PATCH 16/19] style: fix linting issues --- test/unit/responses.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/responses.js b/test/unit/responses.js index 8d41a2f6..727867c8 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -367,4 +367,4 @@ module.exports = { capacities, poolProductCapacities, getQuote, -}; \ No newline at end of file +}; From a47b612642b7c9ad51fc89f1585aa91bd86ab70f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 19 Nov 2024 11:43:19 +0200 Subject: [PATCH 17/19] test: fix unit tests - update responses.js --- test/unit/responses.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/unit/responses.js b/test/unit/responses.js index 727867c8..21e5ab65 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -143,7 +143,7 @@ const capacities = [ allocatedNxm: '32725200000000000000000', minAnnualPrice: '0.0775', maxAnnualPrice: '0.104190714614767679', - utilizationRate: 3491, + utilizationRate: 3407, }, { productId: 4, @@ -203,6 +203,11 @@ const poolProductCapacities = { amount: '10478675347', asset: { id: 6, symbol: 'USDC', decimals: 6 }, }, + { + assetId: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, { assetId: 255, amount: '364800000000000000000', @@ -232,6 +237,11 @@ const poolProductCapacities = { amount: '10478675347', asset: { id: 6, symbol: 'USDC', decimals: 6 }, }, + { + assetId: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, { assetId: 255, amount: '364800000000000000000', @@ -261,6 +271,11 @@ const poolProductCapacities = { amount: '10478675347', asset: { id: 6, symbol: 'USDC', decimals: 6 }, }, + { + assetId: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, { assetId: 255, amount: '364800000000000000000', From 682302a5851e76996a29c57ef20de8e00d495af6 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 19 Nov 2024 16:36:51 +0200 Subject: [PATCH 18/19] refactor: change getUtilizationRate param to capacityAvailableNXM --- src/lib/capacityEngine.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 6b6d9323..eecc0d6e 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -13,18 +13,17 @@ const BASIS_POINTS = 10000; /** * Calculates the utilization rate of the capacity. * - * @param {Array} capacityInAssets - Array of asset objects containing assetId and amount. + * @param {BigNumber} capacityAvailableNXM - The amount of capacity available in NXM. * @param {BigNumber} capacityUsedNXM - The amount of capacity used in NXM. * @returns {BigNumber} The utilization rate as a BigNumber, expressed in basis points (0-10,000). * Returns undefined if capacity in NXM is missing. */ -function getUtilizationRate(capacityInAssets, capacityUsedNXM) { - const availableCapacityInNxm = capacityInAssets.find(asset => asset.assetId === 255)?.amount; - if (!availableCapacityInNxm || !capacityUsedNXM) { +function getUtilizationRate(capacityAvailableNXM, capacityUsedNXM) { + if (!capacityAvailableNXM || !capacityUsedNXM) { return undefined; } - const totalCapacity = availableCapacityInNxm.add(capacityUsedNXM); + const totalCapacity = capacityAvailableNXM.add(capacityUsedNXM); if (totalCapacity.isZero()) { return BigNumber.from(0); } @@ -199,7 +198,7 @@ function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = productId: Number(productId), availableCapacity: capacityInAssets, usedCapacity: capacityUsedNXM, - utilizationRate: getUtilizationRate(capacityInAssets, capacityUsedNXM), + utilizationRate: getUtilizationRate(capacityAvailableNXM, capacityUsedNXM), minAnnualPrice: minPrice, maxAnnualPrice, }); @@ -236,7 +235,7 @@ function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = productId: Number(productId), availableCapacity: capacityInAssets, usedCapacity: capacityUsedNXM, - utilizationRate: getUtilizationRate(capacityInAssets, capacityUsedNXM), + utilizationRate: getUtilizationRate(capacityAvailableNXM, capacityUsedNXM), minAnnualPrice: minPrice, maxAnnualPrice, }); From 2cf85c16a941855cbd4474fc2ef4c9f81e8d907f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 19 Nov 2024 16:37:45 +0200 Subject: [PATCH 19/19] test: fix unit tests --- test/unit/capacityEngine.js | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index 2003af22..2e0c4c00 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -2,7 +2,7 @@ const { expect } = require('chai'); const ethers = require('ethers'); const sinon = require('sinon'); -const { assets, capacities, poolProductCapacities } = require('./responses'); +const { capacities, poolProductCapacities } = require('./responses'); const { capacityEngine, getUtilizationRate } = require('../../src/lib/capacityEngine'); // Import the function to test const { selectAsset } = require('../../src/store/selectors'); const mockStore = require('../mocks/store'); @@ -147,7 +147,8 @@ describe('Capacity Engine tests', function () { }, initObject); // Assert that all fields match - const utilizationRate = getUtilizationRate(summedCapacity.availableCapacity, summedCapacity.usedCapacity); + const capacityAvailableNXM = summedCapacity.availableCapacity.find(c => c.assetId === 255)?.amount; + const utilizationRate = getUtilizationRate(capacityAvailableNXM, summedCapacity.usedCapacity); expect(summedCapacity.productId).to.equal(allPoolsProduct.productId); expect(summedCapacity.usedCapacity.toString()).to.equal(allPoolsProduct.usedCapacity.toString()); expect(utilizationRate.toNumber()).to.be.equal(allPoolsProduct.utilizationRate.toNumber()); @@ -182,10 +183,10 @@ describe('Capacity Engine tests', function () { describe('getUtilizationRate tests', function () { it('should calculate utilization rate correctly when there is available capacity', function () { - const capacityInAssets = [{ assetId: 255, amount: parseEther('100'), asset: assets[255] }]; + const capacityAvailableNXM = parseEther('100'); const capacityUsedNXM = parseEther('50'); - const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); const expectedRate = BigNumber.from(3333); // (50 / (100 + 50)) * 10000 = 3333 basis points @@ -193,43 +194,28 @@ describe('Capacity Engine tests', function () { }); it('should return 1 when ALL capacity is used (no available capacity)', function () { - const capacityInAssets = [{ assetId: 255, amount: Zero, asset: assets[255] }]; + const capacityAvailableNXM = Zero; const capacityUsedNXM = parseEther('150'); - const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); expect(utilizationRate.toNumber()).to.equal(10000); }); - it('should handle multiple assets and return the correct utilization rate', function () { - const capacityInAssets = [ - { assetId: 255, amount: parseEther('200'), asset: assets[255] }, - { assetId: 1, amount: parseEther('100'), asset: assets[1] }, - ]; - const capacityUsedNXM = parseEther('100'); - - const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); - - const expectedRate = BigNumber.from(3333); // (100 / (200 + 100)) * 10000 = 3333 basis points - - expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); - }); - it('should return undefined if utilizationRate cannot be calculated because of missing data', function () { - // missing capacity in NXM (255) - const capacityInAssets = [{ assetId: 1, amount: parseEther('100'), asset: assets[1] }]; + const capacityAvailableNXM = undefined; const capacityUsedNXM = parseEther('50'); - const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); expect(utilizationRate).to.equal(undefined); }); it('should return undefined when there is no capacity available', function () { - const capacityInAssets = [{ assetId: 255, amount: Zero, asset: assets[255] }]; + const capacityAvailableNXM = Zero; const capacityUsedNXM = Zero; - const utilizationRate = getUtilizationRate(capacityInAssets, capacityUsedNXM); + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); expect(utilizationRate.toNumber()).to.equal(0); });