From 31e26a0e2c8c05ac69b078ccf6ad08772e7e0d2b Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:13:16 +0200 Subject: [PATCH 01/28] feat: add calculatePoolUtilizationRate --- src/lib/capacityEngine.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 3e5d4be4..a3ca9ca0 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -64,15 +64,25 @@ function calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod) { } /** - * Calculates the capacity and pricing information for products and pools. + * Calculates the pool-level utilization rate across all products in the pool. * - * @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.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds - * @param {boolean} [options.withPools=false] - Flag indicating whether to include capacityPerPool data field. - * @returns {Array} An array of capacity information objects for each product. + * @param {Array} products - Array of product capacity data for the pool + * @returns {BigNumber} The pool-level utilization rate as a BigNumber, expressed in basis points (0-10,000) + */ +function calculatePoolUtilizationRate(products) { + let totalCapacityAvailableNXM = Zero; + let totalCapacityUsedNXM = Zero; + + products.forEach(product => { + totalCapacityAvailableNXM = totalCapacityAvailableNXM.add( + product.availableCapacity.find(c => c.assetId === 255)?.amount || Zero, + ); + totalCapacityUsedNXM = totalCapacityUsedNXM.add(product.usedCapacity); + }); + + return getUtilizationRate(totalCapacityAvailableNXM, totalCapacityUsedNXM); +} + */ function capacityEngine( store, From 9c445fa56143462ce45a77d32010431b6e06115f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:15:56 +0200 Subject: [PATCH 02/28] feat: add calculateProductCapacity function --- src/lib/capacityEngine.js | 157 +++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 88 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index a3ca9ca0..2062f022 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -83,109 +83,90 @@ function calculatePoolUtilizationRate(products) { return getUtilizationRate(totalCapacityAvailableNXM, totalCapacityUsedNXM); } +/** + * Helper function to calculate capacity for a single product. */ -function capacityEngine( +function calculateProductCapacity( store, - { poolId = null, productIds = [], periodSeconds = SECONDS_PER_DAY.mul(30), withPools = false } = {}, + productId, + { poolId = null, periodSeconds, withPools = false, now, assets, assetRates }, ) { - 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); + const product = selectProduct(store, productId); + if (!product) { + return null; } - for (const productId of productIdsToProcess) { - const product = selectProduct(store, productId); - - if (!product) { - continue; - } - - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, periodSeconds); - const firstUsableTrancheForMaxPeriodIndex = calculateFirstUsableTrancheForMaxPeriodIndex(now, product.gracePeriod); - - // Use productPools from poolId if available; otherwise, select all pools for productId - const productPools = selectProductPools(store, productId, poolId); - - let aggregatedData = {}; - let capacityPerPool = []; - let maxAnnualPrice = Zero; - - if (product.useFixedPrice) { - // Fixed Price - ({ aggregatedData, capacityPerPool } = calculateProductDataForTranche( + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, periodSeconds); + const firstUsableTrancheForMaxPeriodIndex = calculateFirstUsableTrancheForMaxPeriodIndex(now, product.gracePeriod); + + // Use productPools from poolId if available; otherwise, select all pools for productId + const productPools = selectProductPools(store, productId, poolId); + + let aggregatedData = {}; + let capacityPerPool = []; + let maxAnnualPrice = Zero; + + if (product.useFixedPrice) { + // Fixed Price + ({ aggregatedData, capacityPerPool } = calculateProductDataForTranche( + productPools, + firstUsableTrancheIndex, + true, + now, + assets, + assetRates, + )); + + const { capacityAvailableNXM, totalPremium } = aggregatedData; + maxAnnualPrice = capacityAvailableNXM.isZero() ? Zero : WeiPerEther.mul(totalPremium).div(capacityAvailableNXM); + } else { + // Non-fixed Price + // use the first 6 tranches (over 1 year) for calculating the max annual price + for (let i = 0; i <= firstUsableTrancheForMaxPeriodIndex; i++) { + const { aggregatedData: trancheData, capacityPerPool: trancheCapacityPerPool } = calculateProductDataForTranche( productPools, - firstUsableTrancheIndex, - true, + i, + false, now, assets, assetRates, - )); - - const { capacityAvailableNXM, totalPremium } = aggregatedData; - maxAnnualPrice = capacityAvailableNXM.isZero() ? Zero : WeiPerEther.mul(totalPremium).div(capacityAvailableNXM); - } else { - // Non-fixed Price - // use the first 6 tranches (over 1 year) for calculating the max annual price - for (let i = 0; i <= firstUsableTrancheForMaxPeriodIndex; i++) { - const { aggregatedData: trancheData, capacityPerPool: trancheCapacityPerPool } = calculateProductDataForTranche( - productPools, - i, - false, - now, - assets, - assetRates, - ); - - if (i === firstUsableTrancheIndex) { - aggregatedData = trancheData; - capacityPerPool = trancheCapacityPerPool; - } - - const { capacityAvailableNXM, totalPremium } = trancheData; - - const maxTrancheAnnualPrice = capacityAvailableNXM.isZero() - ? Zero - : WeiPerEther.mul(totalPremium).div(capacityAvailableNXM); - - maxAnnualPrice = bnMax(maxAnnualPrice, maxTrancheAnnualPrice); + ); + + if (i === firstUsableTrancheIndex) { + aggregatedData = trancheData; + capacityPerPool = trancheCapacityPerPool; } - } - const { capacityAvailableNXM, capacityUsedNXM, minPrice } = aggregatedData; - // The available capacity of a product across all pools - const capacityInAssets = Object.keys(assets).map(assetId => ({ - assetId: Number(assetId), - amount: capacityAvailableNXM.mul(assetRates[assetId]).div(WeiPerEther), - asset: assets[assetId], - })); - - const capacityData = { - productId: Number(productId), - availableCapacity: capacityInAssets, - usedCapacity: capacityUsedNXM, - utilizationRate: getUtilizationRate(capacityAvailableNXM, capacityUsedNXM), - minAnnualPrice: minPrice, - maxAnnualPrice, - }; - - if (withPools) { - capacityData.capacityPerPool = capacityPerPool; + const { capacityAvailableNXM, totalPremium } = trancheData; + const maxTrancheAnnualPrice = capacityAvailableNXM.isZero() + ? Zero + : WeiPerEther.mul(totalPremium).div(capacityAvailableNXM); + maxAnnualPrice = bnMax(maxAnnualPrice, maxTrancheAnnualPrice); } + } - capacities.push(capacityData); + const { capacityAvailableNXM, capacityUsedNXM, minPrice } = aggregatedData; + // The available capacity of a product across all pools + const capacityInAssets = Object.keys(assets).map(assetId => ({ + assetId: Number(assetId), + amount: capacityAvailableNXM.mul(assetRates[assetId]).div(WeiPerEther), + asset: assets[assetId], + })); + + const capacityData = { + productId: Number(productId), + availableCapacity: capacityInAssets, + usedCapacity: capacityUsedNXM, + minAnnualPrice: minPrice, + maxAnnualPrice, + }; + + if (withPools) { + capacityData.capacityPerPool = capacityPerPool; } - return capacities; + return capacityData; +} } module.exports = { From f169d9ded40b8c87661ebea4cd3e68b3ea078bdf Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:33:34 +0200 Subject: [PATCH 03/28] feat: add getAllProductCapacities API function --- src/lib/capacityEngine.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 2062f022..dfce2e32 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -167,6 +167,34 @@ function calculateProductCapacity( return capacityData; } + +/* API SERVICES */ + +/** + * Gets capacity data for all products. + * GET /capacity + * + * @param {Object} store - The Redux store containing application state. + * @param {Object} [options={}] - Optional parameters. + * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @returns {Array} Array of product capacity data. + */ +function getAllProductCapacities(store, { periodSeconds = SECONDS_PER_DAY.mul(30) } = {}) { + const { assets, assetRates, products } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + + return Object.keys(products) + .map(productId => calculateProductCapacity(store, productId, { periodSeconds, now, assets, assetRates })) + .filter(Boolean); // remove any nulls (i.e. productId did not match any products) +} + + withPools, + now, + assets, + assetRates, + }); +} + } module.exports = { From 308fd81239671438d3fb4b73310b97223b106117 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:36:21 +0200 Subject: [PATCH 04/28] feat: add getProductCapacity API function --- src/lib/capacityEngine.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index dfce2e32..b7b4baf2 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -188,6 +188,23 @@ function getAllProductCapacities(store, { periodSeconds = SECONDS_PER_DAY.mul(30 .filter(Boolean); // remove any nulls (i.e. productId did not match any products) } +/** + * Gets capacity data for a single product across all pools. + * GET /capacity/:productId + * + * @param {Object} store - The Redux store containing application state. + * @param {string|number} productId - The product ID. + * @param {Object} [options={}] - Optional parameters. + * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @param {boolean} [options.withPools=false] - Include per-pool capacity breakdown. + * @returns {Object|null} Product capacity data or null if product not found. + */ +function getProductCapacity(store, productId, { periodSeconds = SECONDS_PER_DAY.mul(30), withPools = false } = {}) { + const { assets, assetRates } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + + return calculateProductCapacity(store, productId, { + periodSeconds, withPools, now, assets, From bbcf3fb3696272540d022f8228b1482f9c9afe04 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:37:54 +0200 Subject: [PATCH 05/28] feat: add getPoolCapacity API function --- src/lib/capacityEngine.js | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index b7b4baf2..2ab47ed5 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -212,9 +212,54 @@ function getProductCapacity(store, productId, { periodSeconds = SECONDS_PER_DAY. }); } +/** + * Gets capacity data for a pool, including all its products. + * GET /capacity/pools/:poolId + * + * @param {Object} store - The Redux store containing application state. + * @param {string|number} poolId - The pool ID. + * @param {Object} [options={}] - Optional parameters. + * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @returns {Object|null} Pool capacity data or null if pool not found. + */ +function getPoolCapacity(store, poolId, { periodSeconds = SECONDS_PER_DAY.mul(30) } = {}) { + const { assets, assetRates } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + const productIds = getProductsInPool(store, poolId); + + if (productIds.length === 0) { + return null; + } + + const productsCapacity = productIds + .map(productId => + calculateProductCapacity(store, productId, { + poolId, + periodSeconds, + now, + assets, + assetRates, + }), + ) + .filter(Boolean); // remove any nulls (i.e. productId did not match any products) + + return { + poolId: Number(poolId), + utilizationRate: calculatePoolUtilizationRate(productsCapacity), + productsCapacity, + }; +} + } module.exports = { + getAllProductCapacities, + getProductCapacity, + getPoolCapacity, + getProductCapacityInPool, + // Keep these exports for testing purposes + calculateProductCapacity, + calculatePoolUtilizationRate, getUtilizationRate, calculateFirstUsableTrancheForMaxPeriodIndex, getProductsInPool, From 0c5e8a73e6b427b540c980c7eebffbca238cf664 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:38:18 +0200 Subject: [PATCH 06/28] feat: add getProductCapacityInPool API function --- src/lib/capacityEngine.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 2ab47ed5..66496e13 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -250,6 +250,28 @@ function getPoolCapacity(store, poolId, { periodSeconds = SECONDS_PER_DAY.mul(30 }; } +/** + * Gets capacity data for a specific product in a specific pool. + * GET /capacity/pools/:poolId/products/:productId + * + * @param {Object} store - The Redux store containing application state. + * @param {string|number} poolId - The pool ID. + * @param {string|number} productId - The product ID. + * @param {Object} [options={}] - Optional parameters. + * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @returns {Object|null} Product capacity data for the specific pool or null if not found. + */ +function getProductCapacityInPool(store, poolId, productId, { periodSeconds = SECONDS_PER_DAY.mul(30) } = {}) { + const { assets, assetRates } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + + return calculateProductCapacity(store, productId, { + poolId, + periodSeconds, + now, + assets, + assetRates, + }); } module.exports = { @@ -263,5 +285,4 @@ module.exports = { getUtilizationRate, calculateFirstUsableTrancheForMaxPeriodIndex, getProductsInPool, - capacityEngine, }; From d21f034ec321463c4a33f181d25cf670bd64d402 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:42:38 +0200 Subject: [PATCH 07/28] refactor: move utilizationRate from product to pool level * change the response of /capacity/pools/:poolId from array to object --- src/routes/capacity.js | 68 +++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 6a8e8548..d77e876c 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -1,7 +1,12 @@ const { ethers, BigNumber } = require('ethers'); const express = require('express'); -const { capacityEngine } = require('../lib/capacityEngine'); +const { + getAllProductCapacities, + getProductCapacity, + getPoolCapacity, + getProductCapacityInPool, +} = require('../lib/capacityEngine'); const { SECONDS_PER_DAY } = require('../lib/constants'); const { asyncRoute } = require('../lib/helpers'); @@ -16,7 +21,6 @@ const formatCapacityResult = capacity => ({ asset, })), allocatedNxm: capacity.usedCapacity.toString(), - utilizationRate: capacity.utilizationRate.toNumber(), minAnnualPrice: formatUnits(capacity.minAnnualPrice), maxAnnualPrice: formatUnits(capacity.maxAnnualPrice), capacityPerPool: capacity.capacityPerPool?.map(c => ({ @@ -212,7 +216,7 @@ router.get( * get: * tags: * - Capacity - * description: Get capacity data for all products in a specific pool + * description: Gets capacity data for a pool, including all its products * parameters: * - in: path * name: poolId @@ -235,9 +239,40 @@ router.get( * content: * application/json: * schema: - * type: array - * items: - * $ref: '#/components/schemas/CapacityResult' + * type: object + * properties: + * poolId: + * type: integer + * description: The pool id + * utilizationRate: + * type: integer + * description: The pool-level utilization rate in basis points (0-10,000) + * productsCapacity: + * type: array + * items: + * $ref: '#/components/schemas/ProductCapacity' + * example: + * poolId: 1 + * utilizationRate: 5000 + * productCapacity: [ + * { + * productId: 1, + * availableCapacity: [ + * { + * assetId: 1, + * amount: "1000000000000000000", + * asset: { + * id: 1, + * symbol: "ETH", + * decimals: 18 + * } + * } + * ], + * allocatedNxm: "500000000000000000", + * minAnnualPrice: "0.025", + * maxAnnualPrice: "0.1" + * } + * ] * 400: * description: Invalid pool id or period * 404: @@ -261,13 +296,18 @@ router.get( try { const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const capacities = capacityEngine(store, { poolId, periodSeconds }); + const poolCapacity = getPoolCapacity(store, poolId, { periodSeconds }); - if (capacities.length === 0) { + if (poolCapacity === null) { return res.status(404).send({ error: 'Pool not found', response: null }); } - const response = capacities.map(capacity => formatCapacityResult(capacity)); + + const response = { + poolId: poolCapacity.poolId, + utilizationRate: poolCapacity.utilizationRate.toNumber(), + productsCapacity: poolCapacity.productsCapacity.map(productCapacity => formatCapacityResult(productCapacity)), + }; console.log(JSON.stringify(response, null, 2)); res.json(response); @@ -405,15 +445,7 @@ router.get( * maxAnnualPrice: * type: string * description: The maximal annual price is a percentage value between 0-1. - * PoolCapacity: - * allOf: - * - $ref: '#/components/schemas/BaseCapacityFields' - * - type: object - * properties: - * poolId: - * type: integer - * description: The pool id - * CapacityResult: + * ProductCapacity: * allOf: * - $ref: '#/components/schemas/BaseCapacityFields' * - type: object From 0d845bd8fd97958f283793740d489b23a3d59efc Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:44:35 +0200 Subject: [PATCH 08/28] fix: selectProductPools fixes --- src/store/selectors.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/store/selectors.js b/src/store/selectors.js index 00270748..d7d0d660 100644 --- a/src/store/selectors.js +++ b/src/store/selectors.js @@ -21,9 +21,10 @@ const selectProductPools = (store, productId, poolId = null) => { const { poolProducts, productPoolIds } = store.getState(); const poolIds = productPoolIds[productId] || []; - if (poolId) { + if (poolId !== null && poolId !== undefined) { + // if (poolId != null) { const key = `${productId}_${poolId}`; - return poolIds.includes(poolId) ? [poolProducts[key]] : []; + return poolIds.includes(Number(poolId)) ? [poolProducts[key]] : []; } // List of product data across all pools From fef4f26f29b107308220c77bc00c1a0442ab7b7f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:47:05 +0200 Subject: [PATCH 09/28] refactor: use getProductCapacity & getAllProductCapacities API functions * fix @openapi docs --- src/routes/capacity.js | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index d77e876c..aca748d6 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -69,7 +69,7 @@ router.get( try { const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const capacities = capacityEngine(store, { periodSeconds }); + const capacities = getAllProductCapacities(store, { periodSeconds }); const response = capacities.map(capacity => formatCapacityResult(capacity)); console.log(JSON.stringify(capacities, null, 2)); @@ -193,7 +193,7 @@ router.get( try { const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const [capacity] = capacityEngine(store, { productIds: [productId], periodSeconds, withPools }); + const capacity = ccc(store, productId, { periodSeconds, withPools }); if (!capacity) { return res.status(400).send({ error: 'Invalid Product Id', response: null }); @@ -380,7 +380,7 @@ router.get( try { const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const [capacity] = capacityEngine(store, { poolId, productIds: [productId], periodSeconds }); + const capacity = getProductCapacityInPool(store, poolId, productId, { periodSeconds }); if (!capacity) { return res.status(404).send({ error: 'Product not found in the specified pool', response: null }); @@ -453,20 +453,41 @@ router.get( * productId: * type: integer * description: The product id - * utilizationRate: - * type: number - * format: integer - * description: The percentage of used capacity to total capacity, expressed as basis points (0-10,000). + * PoolCapacity: + * type: object + * properties: + * poolId: + * type: integer + * description: The pool id + * utilizationRate: + * type: integer + * description: The pool-level utilization rate in basis points (0-10,000) + * productsCapacity: + * type: array + * items: + * $ref: '#/components/schemas/ProductCapacity' + * required: + * - poolId + * - utilizationRate + * - productsCapacity * CapacityResultWithPools: * allOf: - * - $ref: '#/components/schemas/CapacityResult' + * - $ref: '#/components/schemas/ProductCapacity' * - type: object * properties: * capacityPerPool: * type: array - * description: The capacity per pool. + * description: The capacity per pool. Only present when withPools=true. * items: * $ref: '#/components/schemas/PoolCapacity' + * CapacityResult: + * allOf: + * - $ref: '#/components/schemas/BaseCapacityFields' + * - type: object + * properties: + * productId: + * type: integer + * description: The product id */ module.exports = router; From af947b15bb3561abe3b03d95c9268222abf7ce40 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:48:27 +0200 Subject: [PATCH 10/28] test: fix product 4 store data - make fixed price --- test/mocks/store.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/mocks/store.js b/test/mocks/store.js index 25fa4da5..9c2710ac 100644 --- a/test/mocks/store.js +++ b/test/mocks/store.js @@ -378,7 +378,7 @@ const store = { lastEffectiveWeight: BigNumber.from(0), targetWeight: BigNumber.from(40), targetPrice: BigNumber.from(200), - bumpedPrice: BigNumber.from(100), + bumpedPrice: BigNumber.from(200), bumpedPriceUpdateTime: BigNumber.from(1678700054), }, '4_18': { @@ -406,8 +406,8 @@ const store = { ], lastEffectiveWeight: BigNumber.from(50), targetWeight: BigNumber.from(50), - targetPrice: BigNumber.from(800), - bumpedPrice: BigNumber.from(2596), + targetPrice: BigNumber.from(200), + bumpedPrice: BigNumber.from(200), bumpedPriceUpdateTime: BigNumber.from(1712042675), }, '4_22': { @@ -436,8 +436,8 @@ const store = { ], lastEffectiveWeight: BigNumber.from(50), targetWeight: BigNumber.from(50), - targetPrice: BigNumber.from(775), - bumpedPrice: BigNumber.from(1369), + targetPrice: BigNumber.from(200), + bumpedPrice: BigNumber.from(200), bumpedPriceUpdateTime: BigNumber.from(1712177207), }, }, From 4b5935328beb4f92dcd9bad233881719ff2b6fee Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:49:18 +0200 Subject: [PATCH 11/28] test: fix poolProductCapacities expected test response --- test/unit/responses.js | 216 ++++++++++++++++++++--------------------- 1 file changed, 106 insertions(+), 110 deletions(-) diff --git a/test/unit/responses.js b/test/unit/responses.js index 8b75eee4..72c21c61 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -41,7 +41,6 @@ const capacities = [ asset: assets[255], }, ], - utilizationRate: 4405, }, { productId: 1, @@ -75,7 +74,6 @@ const capacities = [ asset: assets[255], }, ], - utilizationRate: 0, }, { productId: 2, @@ -109,7 +107,6 @@ const capacities = [ asset: assets[255], }, ], - utilizationRate: 0, }, { productId: 3, @@ -143,7 +140,6 @@ const capacities = [ allocatedNxm: '32725200000000000000000', minAnnualPrice: '0.0775', maxAnnualPrice: '0.104190714614767679', - utilizationRate: 3407, }, { productId: 4, @@ -175,9 +171,8 @@ const capacities = [ }, ], allocatedNxm: '20004610000000000000000', - maxAnnualPrice: '0.077089706487431343', + maxAnnualPrice: '0.02', minAnnualPrice: '0.02', - utilizationRate: 8467, }, ]; @@ -303,110 +298,111 @@ const productCapacityPerPools = { // 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: 7, - amount: '30013555', - asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, - }, - { - assetId: 255, - amount: '364800000000000000000', - asset: { id: 255, symbol: 'NXM', decimals: 18 }, - }, - ], - allocatedNxm: '0', - minAnnualPrice: '0.02', - maxAnnualPrice: '0.03', - utilizationRate: 0, - }, - { - 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: 7, - amount: '30013555', - asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, - }, - { - assetId: 255, - amount: '364800000000000000000', - asset: { id: 255, symbol: 'NXM', decimals: 18 }, - }, - ], - allocatedNxm: '0', - minAnnualPrice: '0.02', - maxAnnualPrice: '0.03', - utilizationRate: 0, - }, - { - 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: 7, - amount: '30013555', - asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, - }, - { - assetId: 255, - amount: '364800000000000000000', - asset: { id: 255, symbol: 'NXM', decimals: 18 }, - }, - ], - allocatedNxm: '0', - minAnnualPrice: '0.02', - maxAnnualPrice: '0.02', - utilizationRate: 0, - }, - ], + 2: { + poolId: 2, + utilizationRate: 0, + productsCapacity: [ + { + 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: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, + { + 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: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, + { + 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: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.02', + }, + ], + }, }; const ethQuote = { From f0a775db72d8845f6c8862bde04a322f558b7171 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:50:10 +0200 Subject: [PATCH 12/28] test: fix unit tests due to change in mock data --- test/unit/quoteEngine.js | 8 ++++---- test/unit/routes/capacity.js | 2 +- test/unit/routes/quote.js | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/test/unit/quoteEngine.js b/test/unit/quoteEngine.js index aaa4b051..f8a57a6d 100644 --- a/test/unit/quoteEngine.js +++ b/test/unit/quoteEngine.js @@ -105,14 +105,14 @@ describe('Quote Engine tests', () => { expect(quote1.coverAmountInAsset.toString()).to.be.equal('1818518381969826928603'); expect(quote2.poolId).to.be.equal(18); // capacity filled - expect(quote2.premiumInNxm.toString()).to.be.equal('10863722958904109589'); - expect(quote2.premiumInAsset.toString()).to.be.equal('312054347596076424628'); + expect(quote2.premiumInNxm.toString()).to.be.equal('2715930739726027397'); + expect(quote2.premiumInAsset.toString()).to.be.equal('78013586899019106150'); expect(quote2.coverAmountInNxm.toString()).to.be.equal('1652191200000000000000'); expect(quote2.coverAmountInAsset.toString()).to.be.equal('47458265363569956245810'); expect(quote3.poolId).to.be.equal(22); // capacity filled - expect(quote3.premiumInNxm.toString()).to.be.equal('11691817952054794520'); - expect(quote3.premiumInAsset.toString()).to.be.equal('335840911724482901321'); + expect(quote3.premiumInNxm.toString()).to.be.equal('3017243342465753424'); + expect(quote3.premiumInAsset.toString()).to.be.equal('86668622380511716455'); expect(quote3.coverAmountInNxm.toString()).to.be.equal('1835489700000000000000'); expect(quote3.coverAmountInAsset.toString()).to.be.equal('52723411948144627521703'); }); diff --git a/test/unit/routes/capacity.js b/test/unit/routes/capacity.js index 598e2cc8..deb3538e 100644 --- a/test/unit/routes/capacity.js +++ b/test/unit/routes/capacity.js @@ -105,7 +105,7 @@ describe('Capacity Routes', () => { 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]); + expect(response).to.be.deep.equal(poolProductCapacities[poolId].productsCapacity[productId]); }); it('should return 400 Invalid productId', async function () { diff --git a/test/unit/routes/quote.js b/test/unit/routes/quote.js index 3f0f063e..6c0f0b80 100644 --- a/test/unit/routes/quote.js +++ b/test/unit/routes/quote.js @@ -50,7 +50,6 @@ describe('GET /quote', () => { }); it('should successfully get a quote for coverAsset 6 - USDC', async function () { - console.log('amount: ', parseUnits('1', 6).toString()); const { body: response } = await server .get('/v2/quote') .query({ From 93ec6a12a4c7338cc06803e8756e9d10be0a134d Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 10:51:32 +0200 Subject: [PATCH 13/28] test: add new test utils functions and remove unused ones --- test/unit/utils.js | 153 +++++++++++++++++++++------------------------ 1 file changed, 71 insertions(+), 82 deletions(-) diff --git a/test/unit/utils.js b/test/unit/utils.js index de479492..da54d9b0 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -1,101 +1,90 @@ +const { expect } = require('chai'); const { BigNumber, ethers } = require('ethers'); -const { MaxUint256, WeiPerEther } = ethers.constants; -const { calculateFixedPricePremiumPerYear, calculatePremiumPerYear } = require('../../src/lib/quoteEngine'); +const { Zero } = ethers.constants; +const { NXM_PER_ALLOCATION_UNIT } = require('../../src/lib/constants'); +const { calculateFirstUsableTrancheIndex, calculateAvailableCapacity } = require('../../src/lib/helpers'); -const MIN_UNIT_SIZE = WeiPerEther; -const UNIT_DIVISOR = 100; -const getCombinations = (size, a) => { - if (size === 1) { - return a.map(i => [i]); - } - const combinations = []; +const getCurrentTimestamp = () => BigNumber.from(Math.floor(Date.now() / 1000)); - for (let i = 0; i < a.length; i++) { - const smallerCombinations = getCombinations(size - 1, a.slice(i + 1)); +const verifyCapacityCalculation = (response, poolProduct, storeProduct, now, periodSeconds) => { + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, storeProduct.gracePeriod, periodSeconds); - for (const smallCombination of smallerCombinations) { - combinations.push([a[i], ...smallCombination]); - } - } - return combinations; -}; -const getAmountSplits = (splitCount, amountInUnits) => { - if (splitCount === 1) { - return [[amountInUnits]]; - } + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); - const splits = []; - for (let i = 0; i <= amountInUnits; i++) { - const remainderAmount = amountInUnits - i; - const restOfSplits = getAmountSplits(splitCount - 1, remainderAmount); - for (const split of restOfSplits) { - splits.push([i, ...split]); - } - } - return splits; -}; - -const calculateCost = (combination, amountSplit, UNIT_SIZE, useFixedPrice) => { - let totalPremium = BigNumber.from(0); - for (let i = 0; i < combination.length; i++) { - const pool = combination[i]; + const nxmCapacity = response.availableCapacity.find(c => c.assetId === 255); - const amount = amountSplit[i]; - - const amountInWei = BigNumber.from(amount).mul(UNIT_SIZE); + return { availableCapacity, nxmCapacity }; +}; - const premium = useFixedPrice - ? calculateFixedPricePremiumPerYear(amountInWei, pool.basePrice) - : calculatePremiumPerYear(amountInWei, pool.basePrice, pool.initialCapacityUsed, pool.totalCapacity); +const verifyUsedCapacity = (response, poolProduct) => { + let totalUsedCapacity = Zero; + poolProduct.allocations.forEach(allocation => { + totalUsedCapacity = totalUsedCapacity.add(allocation); + }); + expect(response.usedCapacity.toString()).to.equal(totalUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); + return totalUsedCapacity; +}; - totalPremium = totalPremium.add(premium); +const verifyPriceCalculations = (response, storeProduct) => { + expect(response.minAnnualPrice).to.be.instanceOf(BigNumber); + expect(response.maxAnnualPrice).to.be.instanceOf(BigNumber); + expect(response.minAnnualPrice.gt(Zero)).to.equal(true); + expect(response.maxAnnualPrice.gt(Zero)).to.equal(true); + + if (storeProduct.useFixedPrice) { + expect(response.minAnnualPrice.toString()).to.equal(response.maxAnnualPrice.toString()); + } else { + expect(response.minAnnualPrice.toString()).to.not.equal(response.maxAnnualPrice.toString()); + expect(response.maxAnnualPrice.gte(response.minAnnualPrice)).to.equal(true); } - - return totalPremium; }; -/** - * Computes the optimal price by trying all combinations of pools and amount splits - * for each particular combination of pools. - * - * @param coverAmount - * @param pools - * @param useFixedPrice - * @returns {{lowestCostAllocation: {}, lowestCost: *}} - */ -const calculateOptimalPoolAllocationBruteForce = (coverAmount, pools, useFixedPrice) => { - // set UNIT_SIZE to be a minimum of 1. - const UNIT_SIZE = coverAmount.div(UNIT_DIVISOR).gt(MIN_UNIT_SIZE) ? coverAmount.div(UNIT_DIVISOR) : MIN_UNIT_SIZE; - - const amountInUnits = coverAmount.div(UNIT_SIZE); - - let lowestCost = MaxUint256; - let lowestCostAllocation; - for (const splitCount of [1, 2, 3, 4, 5]) { - const combinations = getCombinations(splitCount, pools); - const amountSplits = getAmountSplits(splitCount, amountInUnits); - - for (const combination of combinations) { - for (const amountSplit of amountSplits) { - const cost = calculateCost(combination, amountSplit, UNIT_SIZE); +const calculateExpectedAvailableNXM = (poolIds, productId, poolProducts, firstUsableTrancheIndex) => { + return poolIds.reduce((total, poolId) => { + const poolProduct = poolProducts[`${productId}_${poolId}`]; + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); + return total.add(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT)); + }, Zero); +}; - if (cost.lt(lowestCost)) { - lowestCost = cost; +const calculateExpectedUsedCapacity = poolProduct => { + return poolProduct.allocations.reduce((sum, alloc) => sum.add(alloc), Zero).mul(NXM_PER_ALLOCATION_UNIT); +}; - lowestCostAllocation = {}; - for (let i = 0; i < combination.length; i++) { - const pool = combination[i]; - lowestCostAllocation[pool.poolId] = BigNumber.from(amountSplit[i]).mul(UNIT_SIZE); - } - } - } - } - } +const calculateExpectedUsedCapacityAcrossPools = (poolIds, productId, poolProducts) => { + return poolIds.reduce((total, poolId) => { + const poolProduct = poolProducts[`${productId}_${poolId}`]; + return total.add(calculateExpectedUsedCapacity(poolProduct)); + }, Zero); +}; - return lowestCostAllocation; +const verifyCapacityResponse = ( + response, + expectedKeys = ['productId', 'availableCapacity', 'usedCapacity', 'minAnnualPrice', 'maxAnnualPrice'], +) => { + expect(response).to.have.all.keys(expectedKeys); + expect(response.availableCapacity).to.be.an('array'); + expect(response.usedCapacity).to.be.instanceOf(BigNumber); + expect(response.minAnnualPrice).to.be.instanceOf(BigNumber); + expect(response.maxAnnualPrice).to.be.instanceOf(BigNumber); }; module.exports = { - calculateOptimalPoolAllocationBruteForce, + getCurrentTimestamp, + verifyCapacityCalculation, + verifyUsedCapacity, + verifyPriceCalculations, + calculateExpectedAvailableNXM, + calculateExpectedUsedCapacity, + calculateExpectedUsedCapacityAcrossPools, + verifyCapacityResponse, }; From 42af0546acf6f73f615dbbba285f9fd5e4d2d9a5 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 11:37:42 +0200 Subject: [PATCH 14/28] test: add edge cases unit tests --- test/unit/helpers.js | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/unit/helpers.js b/test/unit/helpers.js index d4ab91ad..b61a0657 100644 --- a/test/unit/helpers.js +++ b/test/unit/helpers.js @@ -2,6 +2,7 @@ const { expect } = require('chai'); const ethers = require('ethers'); const { + BUCKET_DURATION, NXM_PER_ALLOCATION_UNIT, PRICE_CHANGE_PER_DAY, SECONDS_PER_DAY, @@ -245,6 +246,96 @@ describe('helpers', () => { assertAvailableCapacity(poolCapacity, availableInNXM); }); }); + + it('should handle empty pools array', function () { + const { aggregatedData, capacityPerPool } = calculateProductDataForTranche( + [], + 0, + true, + BigNumber.from(1000), + mockStore.assets, + mockStore.assetRates, + ); + + expect(aggregatedData.capacityAvailableNXM).to.deep.equal(Zero); + expect(aggregatedData.capacityUsedNXM).to.deep.equal(Zero); + expect(aggregatedData.totalPremium).to.deep.equal(Zero); + expect(capacityPerPool).to.have.lengthOf(0); + }); + + it('should handle tranche index out of bounds', function () { + const product0Pool1 = [mockStore.poolProducts['0_1']]; + const outOfBoundsIndex = product0Pool1[0].trancheCapacities.length + 1; + + const { aggregatedData, capacityPerPool } = calculateProductDataForTranche( + product0Pool1, + outOfBoundsIndex, + false, + BigNumber.from(1000), + mockStore.assets, + mockStore.assetRates, + ); + + expect(aggregatedData.capacityAvailableNXM).to.deep.equal(Zero); + expect(aggregatedData.capacityUsedNXM).to.deep.equal(Zero); + expect(capacityPerPool).to.have.lengthOf(1); + expect(capacityPerPool[0].availableCapacity).to.deep.equal([]); + }); + + it('should handle negative tranche index', function () { + const product0Pool1 = [mockStore.poolProducts['0_1']]; + const poolProduct = mockStore.poolProducts['0_1']; + const { assets, assetRates } = mockStore; + + // Get the total capacity and used capacity + const total = poolProduct.trancheCapacities.reduce((total, capacity) => total.add(capacity), Zero); + const used = poolProduct.allocations.reduce((total, allocation) => total.add(allocation), Zero); + + // Calculate available capacity (total - used) + const availableCapacity = total.sub(used); + + // Convert to NXM + const expectedCapacityNXM = availableCapacity.mul(NXM_PER_ALLOCATION_UNIT); + + // Calculate expected available capacity per asset + const expectedAvailableCapacity = Object.keys(assets).map(assetId => ({ + assetId: Number(assetId), + amount: expectedCapacityNXM.mul(assetRates[assetId]).div(WeiPerEther), + asset: assets[assetId], + })); + + const { aggregatedData, capacityPerPool } = calculateProductDataForTranche( + product0Pool1, + -1, + false, + BigNumber.from(1000), + mockStore.assets, + mockStore.assetRates, + ); + + expect(aggregatedData.capacityAvailableNXM.toString()).to.equal(expectedCapacityNXM.toString()); + expect(aggregatedData.capacityUsedNXM).to.deep.equal(Zero); + expect(capacityPerPool).to.have.lengthOf(1); + expect(capacityPerPool[0].availableCapacity).to.deep.equal(expectedAvailableCapacity); + }); + + it('should handle mismatched lengths between allocations and trancheCapacities', () => { + const malformedPool = { + ...mockStore.poolProducts['0_1'], + allocations: [Zero], // Shorter than trancheCapacities + }; + + expect(() => + calculateProductDataForTranche( + [malformedPool], + 0, + false, + BigNumber.from(1000), + mockStore.assets, + mockStore.assetRates, + ), + ).to.throw('Pool data integrity error: allocations length must match trancheCapacities length'); + }); }); describe('calculateAvailableCapacity', () => { From 1bad08b496d514bf88d88ca724d9036ae3db7bfd Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 11:43:25 +0200 Subject: [PATCH 15/28] test: add helpers unit tests * divCeil * calculateBucketId * calculateTrancheId * bnMax * bnMin --- test/unit/helpers.js | 79 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/test/unit/helpers.js b/test/unit/helpers.js index b61a0657..f26cc61e 100644 --- a/test/unit/helpers.js +++ b/test/unit/helpers.js @@ -18,6 +18,11 @@ const { calculateBasePrice, calculatePremiumPerYear, calculateFixedPricePremiumPerYear, + calculateBucketId, + calculateTrancheId, + divCeil, + bnMax, + bnMin, } = require('../../src/lib/helpers'); const mockStore = require('../mocks/store'); @@ -539,4 +544,78 @@ describe('helpers', () => { expect(result.gt(basePremium.mul(2))).to.equal(true); // At least 2x base premium }); }); + + describe('divCeil', () => { + it('should round up division result when there is a remainder', () => { + const a = BigNumber.from('10'); + const b = BigNumber.from('3'); + expect(divCeil(a, b).toString()).to.equal('4'); + }); + + it('should return exact result when division is clean', () => { + const a = BigNumber.from('10'); + const b = BigNumber.from('2'); + expect(divCeil(a, b).toString()).to.equal('5'); + }); + + it('should handle zero dividend', () => { + const a = Zero; + const b = BigNumber.from('3'); + expect(divCeil(a, b).toString()).to.equal('0'); + }); + }); + + describe('calculateBucketId', () => { + it('should calculate correct bucket ID', () => { + const time = BigNumber.from(BUCKET_DURATION * 2 + 100); + expect(calculateBucketId(time)).to.equal(2); + }); + + it('should handle BigNumber and number inputs consistently', () => { + const timeNum = BUCKET_DURATION * 2 + 100; + const timeBN = BigNumber.from(timeNum); + expect(calculateBucketId(timeNum)).to.equal(calculateBucketId(timeBN)); + }); + }); + + describe('calculateTrancheId', () => { + it('should calculate correct tranche ID', () => { + const time = BigNumber.from(TRANCHE_DURATION * 3 + 100); + expect(calculateTrancheId(time)).to.equal(3); + }); + + it('should handle BigNumber and number inputs consistently', () => { + const timeNum = TRANCHE_DURATION * 2 + 100; + const timeBN = BigNumber.from(timeNum); + expect(calculateTrancheId(timeNum)).to.equal(calculateTrancheId(timeBN)); + }); + }); + + describe('bnMax', () => { + const a = BigNumber.from('100'); + const b = BigNumber.from('200'); + + it('should return larger number', () => { + expect(bnMax(a, b).toString()).to.equal(b.toString()); + expect(bnMax(b, a).toString()).to.equal(b.toString()); + }); + + it('should handle equal numbers', () => { + expect(bnMax(a, a).toString()).to.equal(a.toString()); + }); + }); + + describe('bnMin', () => { + const a = BigNumber.from('100'); + const b = BigNumber.from('200'); + + it('should return smaller number', () => { + expect(bnMin(a, b).toString()).to.equal(a.toString()); + expect(bnMin(b, a).toString()).to.equal(a.toString()); + }); + + it('should handle equal numbers', () => { + expect(bnMin(a, a).toString()).to.equal(a.toString()); + }); + }); }); From 962ba4c4e0484b38a201fd8ded929d3d9a10ab21 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 11:47:56 +0200 Subject: [PATCH 16/28] feat: add allocations and trancheCapacities validation --- src/lib/helpers.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/helpers.js b/src/lib/helpers.js index c21f2354..dc963705 100644 --- a/src/lib/helpers.js +++ b/src/lib/helpers.js @@ -93,6 +93,15 @@ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, u const capacityPerPool = productPools.map(pool => { const { allocations, trancheCapacities, targetPrice, bumpedPrice, bumpedPriceUpdateTime, poolId } = pool; + // Validate data integrity + if (!allocations || !trancheCapacities) { + throw new Error('Pool data integrity error: missing allocations or trancheCapacities'); + } + + if (allocations.length !== trancheCapacities.length) { + throw new Error('Pool data integrity error: allocations length must match trancheCapacities length'); + } + // calculating the capacity in allocation points const used = allocations.reduce((total, allocation) => total.add(allocation), Zero); const total = trancheCapacities.reduce((total, capacity) => total.add(capacity), Zero); @@ -166,6 +175,14 @@ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, u /* Price Calculations */ const calculateBasePrice = (targetPrice, bumpedPrice, bumpedPriceUpdateTime, now) => { + if (!targetPrice) { + throw new Error('Target price is required'); + } + // If bumped price data is incomplete, return target price + if (!bumpedPrice || !bumpedPriceUpdateTime) { + return targetPrice; + } + const elapsed = now.sub(bumpedPriceUpdateTime); const priceDrop = elapsed.mul(PRICE_CHANGE_PER_DAY).div(3600 * 24); return bnMax(targetPrice, bumpedPrice.sub(priceDrop)); From 3e506de2d4eddd58f16bca89580362571a110b9e Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 11:48:15 +0200 Subject: [PATCH 17/28] fix: fix linting issues --- src/routes/capacity.js | 3 +-- src/store/selectors.js | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index aca748d6..ec6f1e64 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -193,7 +193,7 @@ router.get( try { const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const capacity = ccc(store, productId, { periodSeconds, withPools }); + const capacity = getProductCapacity(store, productId, { periodSeconds, withPools }); if (!capacity) { return res.status(400).send({ error: 'Invalid Product Id', response: null }); @@ -302,7 +302,6 @@ router.get( return res.status(404).send({ error: 'Pool not found', response: null }); } - const response = { poolId: poolCapacity.poolId, utilizationRate: poolCapacity.utilizationRate.toNumber(), diff --git a/src/store/selectors.js b/src/store/selectors.js index d7d0d660..b2b42f5e 100644 --- a/src/store/selectors.js +++ b/src/store/selectors.js @@ -22,7 +22,6 @@ const selectProductPools = (store, productId, poolId = null) => { const poolIds = productPoolIds[productId] || []; if (poolId !== null && poolId !== undefined) { - // if (poolId != null) { const key = `${productId}_${poolId}`; return poolIds.includes(Number(poolId)) ? [poolProducts[key]] : []; } From 8faed27709f4feb3e6ea2ccfaca486938346e2dd Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 11:48:32 +0200 Subject: [PATCH 18/28] test: add capacityEngine unit tests --- test/unit/capacityEngine.js | 940 ++++++++++++++++++++++++++---------- 1 file changed, 698 insertions(+), 242 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index d7dfa9a0..e752c554 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -2,362 +2,818 @@ const { expect } = require('chai'); const ethers = require('ethers'); const sinon = require('sinon'); -const { capacities, poolProductCapacities } = require('./responses'); +const { poolProductCapacities } = require('./responses'); const { - capacityEngine, + getCurrentTimestamp, + verifyPriceCalculations, + verifyCapacityResponse, + calculateExpectedUsedCapacity, +} = require('./utils'); +const { + getAllProductCapacities, + getProductCapacity, + getPoolCapacity, + getProductCapacityInPool, getUtilizationRate, calculateFirstUsableTrancheForMaxPeriodIndex, getProductsInPool, + calculatePoolUtilizationRate, + calculateProductCapacity, } = require('../../src/lib/capacityEngine'); -const { calculateTrancheId, bnMax } = require('../../src/lib/helpers'); -const { selectAsset } = require('../../src/store/selectors'); +const { MAX_COVER_PERIOD, SECONDS_PER_DAY, NXM_PER_ALLOCATION_UNIT } = require('../../src/lib/constants'); +const { + calculateTrancheId, + calculateFirstUsableTrancheIndex, + calculateAvailableCapacity, +} = require('../../src/lib/helpers'); const mockStore = require('../mocks/store'); const { BigNumber } = ethers; const { parseEther } = ethers.utils; -const { Zero } = ethers.constants; +const { Zero, WeiPerEther } = ethers.constants; describe('Capacity Engine tests', function () { - describe('capacityEngine', function () { - const store = { getState: () => null }; + const store = { getState: () => null }; + + beforeEach(function () { + sinon.stub(store, 'getState').callsFake(() => mockStore); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('calculateProductCapacity', function () { + it('should calculate product capacity correctly', function () { + const productId = '0'; + const now = getCurrentTimestamp(); + const { assets, assetRates, poolProducts } = store.getState(); + + const response = calculateProductCapacity(store, productId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets, + assetRates, + }); - beforeEach(function () { - sinon.stub(store, 'getState').callsFake(() => mockStore); + expect(response.productId).to.equal(0); + expect(response.availableCapacity).to.be.an('array'); + + // Get capacities from both pools + const pool1Capacity = calculateAvailableCapacity( + poolProducts['0_1'].trancheCapacities, + poolProducts['0_1'].allocations, + 0, + ); + const pool2Capacity = calculateAvailableCapacity( + poolProducts['0_2'].trancheCapacities, + poolProducts['0_2'].allocations, + 0, + ); + + // Verify total NXM capacity across both pools + const totalCapacity = pool1Capacity.add(pool2Capacity).mul(NXM_PER_ALLOCATION_UNIT); + const nxmCapacity = response.availableCapacity.find(c => c.assetId === 255); + expect(nxmCapacity.amount.toString()).to.equal(totalCapacity.toString()); }); - afterEach(function () { - sinon.restore(); + it('should handle fixed price products correctly', function () { + const fixedPriceProductId = '2'; + const now = BigNumber.from(Date.now()).div(1000); + const response = calculateProductCapacity(store, fixedPriceProductId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); + + expect(response.minAnnualPrice.toString()).to.deep.equal(response.maxAnnualPrice.toString()); }); - it('should return capacity for all products when no productIds or poolId are provided', function () { - const response = capacityEngine(store, { period: 30 }); + it('should handle non-fixed price products correctly', function () { + const nonFixedPriceProductId = '0'; + const now = BigNumber.from(Date.now()).div(1000); + const response = calculateProductCapacity(store, nonFixedPriceProductId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); + + expect(response.minAnnualPrice.toString()).to.not.deep.equal(response.maxAnnualPrice.toString()); + }); - expect(response).to.have.lengthOf(Object.keys(mockStore.products).length); + it('should include capacityPerPool when withPools is true', function () { + const productId = '0'; + const now = BigNumber.from(Date.now()).div(1000); + const response = calculateProductCapacity(store, productId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + withPools: true, + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); - response.forEach((product, i) => { - expect(product.productId).to.be.equal(capacities[i].productId); - expect(product.utilizationRate.toNumber()).to.be.equal(capacities[i].utilizationRate); + const { poolProducts, assets, assetRates } = store.getState(); - 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)); + response.capacityPerPool.forEach(poolCapacity => { + const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; + + poolCapacity.availableCapacity.forEach(capacity => { + expect(capacity.asset).to.deep.equal(assets[capacity.assetId]); + + if (capacity.assetId !== 255) { + const nxmCapacity = poolCapacity.availableCapacity.find(c => c.assetId === 255).amount; + const expectedAmount = nxmCapacity.mul(assetRates[capacity.assetId]).div(WeiPerEther); + expect(capacity.amount.toString()).to.equal(expectedAmount.toString()); + } }); + + const expectedAllocatedNxm = poolProduct.allocations + .reduce((sum, alloc) => sum.add(alloc), Zero) + .mul(NXM_PER_ALLOCATION_UNIT); + expect(poolCapacity.allocatedNxm.toString()).to.equal(expectedAllocatedNxm.toString()); }); }); - it('should return capacity for 1 product across all pools if productId is provided and poolId is not', function () { + it('should filter by poolId when provided', function () { const productId = '0'; - const [product] = capacityEngine(store, { productIds: [productId] }); - - const expectedCapacity = capacities[Number(productId)]; + const poolId = 2; + const now = BigNumber.from(Date.now()).div(1000); + const response = calculateProductCapacity(store, productId, { + poolId, + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); - expect(product.productId).to.be.equal(expectedCapacity.productId); - expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); + const expectedCapacity = poolProductCapacities[poolId].productsCapacity.find( + p => p.productId === Number(productId), + ); + const expectedAvailableCapacity = expectedCapacity.availableCapacity.map(cap => ({ + ...cap, + amount: BigNumber.from(cap.amount), + })); - 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)); - }); + expect(response.availableCapacity).to.deep.equal(expectedAvailableCapacity); }); - it('should return undefined for non-existing product', function () { + it('should return null for non-existing product', function () { const nonExistingProductId = '999'; - const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); - expect(product).to.be.equal(undefined); + const now = BigNumber.from(Date.now()).div(1000); + const response = calculateProductCapacity(store, nonExistingProductId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); + + expect(response).to.equal(null); }); - it('should return capacity for a specific product and pool if both productId and poolId are provided', function () { + it('should handle zero capacity correctly', function () { const productId = '0'; - const poolId = 2; - const [product] = capacityEngine(store, { poolId, productIds: [productId] }); + const now = BigNumber.from(Date.now()).div(1000); + // Mock store to return zero capacity with all required fields + const zeroCapacityStore = { + getState: () => ({ + ...mockStore, + poolProducts: { + '0_1': { + productId: 0, + poolId: 1, + trancheCapacities: [Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero], // 8 tranches of zero capacity + allocations: [Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero], // 8 tranches of zero allocations + lastEffectiveWeight: Zero, + targetWeight: Zero, + targetPrice: Zero, + bumpedPrice: Zero, + bumpedPriceUpdateTime: Zero, + }, + }, + // Keep the product pool IDs mapping + productPoolIds: { + 0: [1], // Only pool 1 for product 0 + }, + }), + }; - const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === Number(productId)); + const response = calculateProductCapacity(zeroCapacityStore, productId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); - expect(product.productId).to.equal(Number(productId)); - expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); - 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); + expect(response.availableCapacity[0].amount.toString()).to.equal(Zero.toString()); + expect(response.maxAnnualPrice.toString()).to.equal(Zero.toString()); + }); - 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 calculate capacity across multiple tranches for non-fixed price products', function () { + const productId = '1'; // Non-fixed price product + const now = BigNumber.from(Date.now()).div(1000); + const response = calculateProductCapacity(store, productId, { + periodSeconds: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, }); + + // Should have different min and max prices + expect(response.minAnnualPrice).to.not.deep.equal(response.maxAnnualPrice); + expect(response.maxAnnualPrice).to.not.deep.equal(Zero); }); + }); - 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 }); + describe('getAllProductCapacities', function () { + it('should return capacity for all products across all pools', function () { + const response = getAllProductCapacities(store); + const { products, productPoolIds, poolProducts, assets } = store.getState(); - expect(response.length).to.be.greaterThan(0); + // Should return all products from store + expect(response).to.have.lengthOf(Object.keys(products).length); + // Check each product's capacity data 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.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); - - 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)); - }); + const productId = product.productId; + const storeProduct = products[productId]; + const poolIds = productPoolIds[productId]; + + // Check product exists in store + expect(storeProduct).to.not.equal(undefined); + + // Verify available capacity calculation for each pool + const now = BigNumber.from(Date.now()).div(1000); + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + storeProduct.gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + + // Calculate expected NXM using the same logic as the function + const expectedAvailableNXM = poolIds.reduce((total, poolId) => { + const poolProduct = poolProducts[`${productId}_${poolId}`]; + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); + const availableInNXM = availableCapacity.mul(NXM_PER_ALLOCATION_UNIT); + return total.add(availableInNXM); + }, Zero); + + // Check NXM capacity + const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255); + expect(nxmCapacity.amount.toString()).to.equal(expectedAvailableNXM.toString()); + expect(nxmCapacity.asset).to.deep.equal(assets[255]); + + // Check used capacity + const expectedUsedCapacity = poolIds.reduce((total, poolId) => { + const poolProduct = poolProducts[`${productId}_${poolId}`]; + const usedCapacity = poolProduct.allocations.reduce((sum, alloc) => sum.add(alloc), Zero); + const usedCapacityInNXM = usedCapacity.mul(NXM_PER_ALLOCATION_UNIT); + return total.add(usedCapacityInNXM); + }, Zero); + + expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); + + // Check price calculations based on product type + if (storeProduct.useFixedPrice) { + expect(product.minAnnualPrice.toString()).to.equal(product.maxAnnualPrice.toString()); + } else { + expect(product.minAnnualPrice.toString()).to.not.equal(product.maxAnnualPrice.toString()); + } }); }); - 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 filter out null responses', function () { + // Create a mock store where the product doesn't exist + const nullProductStore = { + getState: () => ({ + ...mockStore, + products: {}, + productPoolIds: {}, + poolProducts: {}, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }), + }; - // Get capacity for product 0 across all pools - const [allPoolsProduct] = capacityEngine(store, { productIds: [productId] }); + const response = getAllProductCapacities(nullProductStore); + expect(response).to.have.lengthOf(0); + }); + }); - const initObject = { - productId: Number(productId), - usedCapacity: BigNumber.from(0), - minAnnualPrice: BigNumber.from(0), - maxAnnualPrice: BigNumber.from(0), - availableCapacity: [], - }; + describe('getProductCapacity', function () { + it('should return detailed capacity for a single product', function () { + const productId = 3; + const response = getProductCapacity(store, 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 { productPoolIds, poolProducts } = store.getState(); + const poolIds = productPoolIds[productId]; - if (!product) { - return acc; - } + // Check basic product info + expect(response.productId).to.equal(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) }; - } - acc.availableCapacity[index].amount = acc.availableCapacity[index].amount.add(capacity.amount); + // Check NXM capacity calculation + let totalAvailableNXM = Zero; + poolIds.forEach(poolId => { + const poolProduct = poolProducts[`${productId}_${poolId}`]; + const lastTranche = poolProduct.trancheCapacities[poolProduct.trancheCapacities.length - 1]; + totalAvailableNXM = totalAvailableNXM.add(lastTranche); + }); + + // Check used capacity + let totalUsedCapacity = Zero; + poolIds.forEach(poolId => { + const poolProduct = poolProducts[`${productId}_${poolId}`]; + poolProduct.allocations.forEach(allocation => { + totalUsedCapacity = totalUsedCapacity.add(allocation.mul(NXM_PER_ALLOCATION_UNIT)); }); + }); + expect(response.usedCapacity.toString()).to.equal(totalUsedCapacity.toString()); + }); - return acc; - }, initObject); - - // Assert that all fields match - 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()); - 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 include detailed pool breakdown when withPools is true', function () { + const productId = '3'; + const response = getProductCapacity(store, productId, { withPools: true }); + const { productPoolIds, poolProducts, products } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + + expect(response.capacityPerPool).to.have.lengthOf(productPoolIds[productId].length); + + response.capacityPerPool.forEach(poolCapacity => { + const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; + expect(poolProduct).to.not.equal(undefined); + + // Calculate first usable tranche index + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + products[productId].gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + + // Calculate available capacity considering all usable tranches + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); + + // Check pool-specific capacity + const nxmCapacity = poolCapacity.availableCapacity.find(c => c.assetId === 255); + expect(nxmCapacity.amount.toString()).to.equal(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); + + // Check pool-specific used capacity + let poolUsedCapacity = Zero; + poolProduct.allocations.forEach(allocation => { + poolUsedCapacity = poolUsedCapacity.add(allocation); + }); + expect(poolCapacity.allocatedNxm.toString()).to.equal(poolUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); }); }); + }); - it('should handle products with fixed price correctly', function () { - const fixedPricedProductId = '2'; - const [product] = capacityEngine(store, { productIds: [fixedPricedProductId] }); + describe('getPoolCapacity', function () { + it('should return detailed pool capacity with correct utilization rate', function () { + const poolId = 4; + const response = getPoolCapacity(store, poolId); + + const { poolProducts, products } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + + // Check products in pool + const productsInPool = Object.entries(poolProducts) + .filter(([key]) => key.endsWith(`_${poolId}`)) + .map(([key]) => Number(key.split('_')[0])); + + expect(response.productsCapacity).to.have.lengthOf(productsInPool.length); + + // Calculate expected utilization rate + let totalAvailableNXM = Zero; + let totalUsedNXM = Zero; + + response.productsCapacity.forEach(product => { + const poolProduct = poolProducts[`${product.productId}_${poolId}`]; + const storeProduct = products[product.productId]; + + // Calculate first usable tranche index + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + storeProduct.gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + + // Calculate available capacity + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); + + const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255); + expect(nxmCapacity.amount.toString()).to.equal(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); + totalAvailableNXM = totalAvailableNXM.add(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT)); + + // Check used capacity + let productUsedCapacity = Zero; + poolProduct.allocations.forEach(allocation => { + productUsedCapacity = productUsedCapacity.add(allocation); + }); + expect(product.usedCapacity.toString()).to.equal(productUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); + totalUsedNXM = totalUsedNXM.add(productUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT)); + }); - expect(product.productId).to.equal(Number(fixedPricedProductId)); - expect(product.minAnnualPrice).to.deep.equal(product.maxAnnualPrice); + // Verify pool utilization rate + const expectedUtilizationRate = totalUsedNXM.mul(10000).div(totalAvailableNXM.add(totalUsedNXM)); + expect(response.utilizationRate.toString()).to.equal(expectedUtilizationRate.toString()); }); + }); - it('should handle products without fixed price correctly', function () { - const nonFixedPricedProductId = '0'; - const [product] = capacityEngine(store, { productIds: [nonFixedPricedProductId] }); + describe('getProductCapacityInPool', function () { + it('should return detailed capacity for a specific product in a specific pool', function () { + const poolId = 4; + const productId = '3'; + const response = getProductCapacityInPool(store, poolId, productId); - expect(product.productId).to.equal(Number(nonFixedPricedProductId)); - expect(product.minAnnualPrice).to.not.deep.equal(product.maxAnnualPrice); - }); + verifyCapacityResponse(response); - it('should not include capacityPerPool when withPools is false', function () { - const productId = '0'; - const [productWithoutPools] = capacityEngine(store, { productIds: [productId], withPools: false }); - expect(productWithoutPools).to.not.have.property('capacityPerPool'); + const { poolProducts, products } = store.getState(); + const poolProduct = poolProducts[`${productId}_${poolId}`]; + + // Verify used capacity + const expectedUsedCapacity = calculateExpectedUsedCapacity(poolProduct); + expect(response.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); + + // Verify price calculations + verifyPriceCalculations(response, products[productId]); }); + }); - it('should include capacityPerPool if query param withPool=true', function () { - const productId = '0'; + describe('getUtilizationRate', function () { + it('should calculate utilization rate correctly', function () { + const availableNXM = parseEther('100'); + const usedNXM = parseEther('50'); + // Expected: (50 / (100 + 50)) * 10000 = 3333 basis points + const rate = getUtilizationRate(availableNXM, usedNXM); + expect(rate.toNumber()).to.equal(3333); + }); - const [productWithPools] = capacityEngine(store, { productIds: [productId], withPools: true }); - expect(productWithPools).to.have.property('capacityPerPool'); + it('should return 0 when no capacity is used', function () { + const availableNXM = parseEther('100'); + const usedNXM = Zero; + const rate = getUtilizationRate(availableNXM, usedNXM); + expect(rate.toNumber()).to.equal(0); + }); - // Sum up values from capacityPerPool - const initCapacity = { usedCapacity: Zero, availableCapacity: {}, minAnnualPrice: Zero, maxAnnualPrice: Zero }; - const summedCapacity = productWithPools.capacityPerPool.reduce((acc, pool) => { - acc.usedCapacity = acc.usedCapacity.add(pool.allocatedNxm); - acc.maxAnnualPrice = bnMax(acc.maxAnnualPrice, pool.maxAnnualPrice); + it('should return 0 when total capacity is zero', function () { + const availableNXM = Zero; + const usedNXM = Zero; + const rate = getUtilizationRate(availableNXM, usedNXM); + expect(rate.toNumber()).to.equal(0); + }); - // skip poolId 3 as there is 0 available capacity - if (pool.poolId !== 3 && (acc.minAnnualPrice.isZero() || pool.minAnnualPrice.lt(acc.minAnnualPrice))) { - acc.minAnnualPrice = pool.minAnnualPrice; - } + it('should return undefined when inputs are missing', function () { + expect(getUtilizationRate(null, parseEther('50'))).to.equal(undefined); + expect(getUtilizationRate(parseEther('100'), null)).to.equal(undefined); + expect(getUtilizationRate(null, null)).to.equal(undefined); + }); - pool.availableCapacity.forEach(asset => { - acc.availableCapacity[asset.assetId] = (acc.availableCapacity[asset.assetId] ?? Zero).add(asset.amount); - }); + it('should handle very large numbers correctly', function () { + const largeNumber = parseEther('1000000'); // 1M ETH + const rate = getUtilizationRate(largeNumber, largeNumber); + expect(rate.toNumber()).to.equal(5000); // Should be 50% + }); - return acc; - }, initCapacity); + it('should handle very small numbers correctly', function () { + const smallNumber = BigNumber.from(1); + const rate = getUtilizationRate(smallNumber, smallNumber); + expect(rate.toNumber()).to.equal(5000); // Should be 50% + }); + }); - // Compare summed values with root-level (across all pools) values - expect(summedCapacity.usedCapacity.toString()).to.equal(productWithPools.usedCapacity.toString()); - expect(summedCapacity.minAnnualPrice.toString()).to.equal(productWithPools.minAnnualPrice.toString()); - expect(summedCapacity.maxAnnualPrice.toString()).to.equal(productWithPools.maxAnnualPrice.toString()); + describe('getProductsInPool', function () { + it('should return all products in a specific pool', function () { + const poolId = 1; + const { productPoolIds } = store.getState(); + const products = getProductsInPool(store, poolId); + + // Check against mock store data + const expectedProducts = Object.keys(productPoolIds).filter(productId => + productPoolIds[productId].includes(poolId), + ); + + expect(products).to.have.members(expectedProducts); + expect(products).to.have.lengthOf(expectedProducts.length); + }); - productWithPools.availableCapacity.forEach(asset => { - expect(summedCapacity.availableCapacity[asset.assetId].toString()).to.equal(asset.amount.toString()); - }); + it('should return empty array for pool with no products', function () { + const nonExistentPoolId = 999; + const products = getProductsInPool(store, nonExistentPoolId); + expect(products).to.be.an('array'); + expect(products).to.have.lengthOf(0); }); - it('should have root-level (across all pools) prices within range of pool prices', function () { - const productId = '0'; - const [productWithPools] = capacityEngine(store, { productIds: [productId], withPools: true }); + it('should handle string pool ids', function () { + const poolId = '1'; + const { productPoolIds } = store.getState(); + const products = getProductsInPool(store, poolId); - const poolPrices = productWithPools.capacityPerPool - .filter(pool => pool.poolId !== 3) // skip poolId 3 as there is 0 available capacity - .map(pool => ({ - min: pool.minAnnualPrice, - max: pool.maxAnnualPrice, - })); + const expectedProducts = Object.keys(productPoolIds).filter(productId => + productPoolIds[productId].includes(Number(poolId)), + ); + + expect(products).to.have.members(expectedProducts); + }); - expect(productWithPools.minAnnualPrice.toString()).to.equal(Math.min(...poolPrices.map(p => p.min)).toString()); - expect(productWithPools.maxAnnualPrice.toString()).to.equal(Math.max(...poolPrices.map(p => p.max)).toString()); + it('should handle invalid pool id', function () { + const products = getProductsInPool(store, -1); + expect(products).to.be.an('array'); + expect(products).to.have.lengthOf(0); + }); + + it('should handle string vs number pool ids consistently', function () { + const numericResult = getProductsInPool(store, 1); + const stringResult = getProductsInPool(store, '1'); + expect(numericResult).to.deep.equal(stringResult); }); }); - describe('getUtilizationRate tests', function () { - it('should calculate utilization rate correctly when there is available capacity', function () { - const capacityAvailableNXM = parseEther('100'); - const capacityUsedNXM = parseEther('50'); + describe('calculateFirstUsableTrancheForMaxPeriodIndex', function () { + it('should calculate correct tranche index for max period', function () { + const now = BigNumber.from(1678700054); // From mock store + const gracePeriod = BigNumber.from(30); // 30 seconds grace period - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); - const expectedRate = BigNumber.from(3333); // (50 / (100 + 50)) * 10000 = 3333 basis points + // Calculate expected result + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; - expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); + expect(result).to.equal(expected); }); - it('should return 1 when ALL capacity is used (no available capacity)', function () { - const capacityAvailableNXM = Zero; - const capacityUsedNXM = parseEther('150'); + it('should handle zero grace period', function () { + const now = BigNumber.from(1678700054); + const gracePeriod = Zero; + + const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; - expect(utilizationRate.toNumber()).to.equal(10000); + expect(result).to.equal(expected); }); - it('should return undefined if utilizationRate cannot be calculated because of missing data', function () { - const capacityAvailableNXM = undefined; - const capacityUsedNXM = parseEther('50'); + it('should handle large grace period', function () { + const now = BigNumber.from(1678700054); + const gracePeriod = BigNumber.from(3024000); // Large grace period from mock store - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); - expect(utilizationRate).to.equal(undefined); - }); + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; - it('should return undefined when there is no capacity available', function () { - const capacityAvailableNXM = Zero; - const capacityUsedNXM = Zero; + expect(result).to.equal(expected); + }); + }); - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + describe('calculatePoolUtilizationRate', function () { + it('should calculate utilization rate correctly for multiple products', function () { + const products = [ + { + availableCapacity: [ + { assetId: 255, amount: parseEther('100') }, + { assetId: 1, amount: parseEther('50') }, + ], + usedCapacity: parseEther('50'), + }, + { + availableCapacity: [ + { assetId: 255, amount: parseEther('200') }, + { assetId: 1, amount: parseEther('100') }, + ], + usedCapacity: parseEther('100'), + }, + ]; + + const utilizationRate = calculatePoolUtilizationRate(products); + // Total available: 300 NXM + // Total used: 150 NXM + // Expected rate: (150 / (300 + 150)) * 10000 = 3333 basis points + expect(utilizationRate.toNumber()).to.equal(3333); + }); + it('should handle empty products array', function () { + const products = []; + const utilizationRate = calculatePoolUtilizationRate(products); expect(utilizationRate.toNumber()).to.equal(0); }); - }); - describe('getProductsInPool', function () { - let mockStore; + it('should handle products with no NXM capacity', function () { + const products = [ + { + availableCapacity: [{ assetId: 1, amount: parseEther('50') }], + usedCapacity: parseEther('25'), + }, + ]; - beforeEach(function () { - mockStore = { - getState: sinon.stub(), - }; + const utilizationRate = calculatePoolUtilizationRate(products); + expect(utilizationRate.toNumber()).to.equal(10000); // 100% utilization when no available NXM }); - afterEach(function () { - sinon.restore(); - }); + it('should handle products with zero used capacity', function () { + const products = [ + { + availableCapacity: [{ assetId: 255, amount: parseEther('100') }], + usedCapacity: Zero, + }, + ]; - it('should return correct product IDs for a given pool', function () { - mockStore.getState.returns({ - products: { 1: {}, 2: {}, 3: {} }, - productPoolIds: { 1: [1, 2], 2: [2], 3: [1, 2] }, - poolProducts: { '1_2': { poolId: 2 }, '2_2': { poolId: 2 }, '3_2': { poolId: 2 } }, - }); + const utilizationRate = calculatePoolUtilizationRate(products); + expect(utilizationRate.toNumber()).to.equal(0); + }); - const poolId = 2; - const result = getProductsInPool(mockStore, poolId); + it('should handle products with zero total capacity', function () { + const products = [ + { + availableCapacity: [{ assetId: 255, amount: Zero }], + usedCapacity: Zero, + }, + ]; - expect(result).to.deep.equal(['1', '2', '3']); + const utilizationRate = calculatePoolUtilizationRate(products); + expect(utilizationRate.toNumber()).to.equal(0); }); - it('should return an empty array for a pool with no products', function () { - mockStore.getState.returns({ - products: { 1: {}, 2: {}, 3: {} }, - productPoolIds: { 1: [1], 2: [1], 3: [1] }, - poolProducts: { '1_1': { poolId: 1 }, '2_1': { poolId: 1 }, '3_1': { poolId: 1 } }, - }); + it('should handle products with missing NXM capacity', function () { + const products = [ + { + availableCapacity: [{ assetId: 1, amount: parseEther('50') }], // No NXM (255) + usedCapacity: parseEther('25'), + }, + ]; + const rate = calculatePoolUtilizationRate(products); + expect(rate.toNumber()).to.equal(10000); // Should be 100% when no available NXM + }); - const poolId = 2; - const result = getProductsInPool(mockStore, poolId); + it('should aggregate capacity across multiple products correctly', function () { + const products = [ + { + availableCapacity: [{ assetId: 255, amount: parseEther('100') }], + usedCapacity: parseEther('50'), + }, + { + availableCapacity: [{ assetId: 255, amount: parseEther('200') }], + usedCapacity: parseEther('100'), + }, + ]; + const rate = calculatePoolUtilizationRate(products); + // Total available: 300, Total used: 150 + // Expected: (150 / (300 + 150)) * 10000 = 3333 + expect(rate.toNumber()).to.equal(3333); + }); + }); - expect(result).to.deep.equal([]); + describe('API Services', function () { + it('should handle custom period seconds in getAllProductCapacities', function () { + const response = getAllProductCapacities(store, { + periodSeconds: SECONDS_PER_DAY.mul(7), // 1 week + }); + expect(response).to.be.an('array'); + expect(response.length).to.be.greaterThan(0); }); - it('should handle undefined productPools', function () { - mockStore.getState.returns({ - products: { 1: {}, 2: {}, 3: {} }, - productPoolIds: {}, - poolProducts: {}, + it('should handle invalid period seconds gracefully', function () { + const response = getProductCapacity(store, '0', { + periodSeconds: Zero, }); + expect(response).to.not.equal(null); + }); - const poolId = 2; - const result = getProductsInPool(mockStore, poolId); + it('should return consistent data structure across all capacity endpoints', function () { + const poolId = '1'; + const productId = '0'; + const { assets, assetRates, poolProducts: storePoolProducts, products, productPoolIds } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + + // Get responses from all endpoints + const singleProduct = getProductCapacity(store, productId); + const poolCapacityResponse = getPoolCapacity(store, poolId); + const poolProduct = getProductCapacityInPool(store, poolId, productId); + + // Helper to verify product capacity structure + const verifyProductCapacity = (product, expectedPoolProduct, isSinglePool = false) => { + // Verify product ID + expect(product.productId).to.equal(Number(productId)); + + // Calculate and verify available capacity for each asset + product.availableCapacity.forEach(capacity => { + const { assetId, amount, asset } = capacity; + + // Verify asset info + expect(asset).to.deep.equal(assets[assetId]); + + // Calculate expected amount + let expectedAmount; + if (assetId === 255) { + if (isSinglePool) { + // For single pool responses, use direct capacity calculation + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + products[productId].gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + expectedAmount = calculateAvailableCapacity( + expectedPoolProduct.trancheCapacities, + expectedPoolProduct.allocations, + firstUsableTrancheIndex, + ).mul(NXM_PER_ALLOCATION_UNIT); + } else { + // For multi-pool responses, sum capacities across all pools + expectedAmount = productPoolIds[productId].reduce((total, pid) => { + const poolProduct = storePoolProducts[`${productId}_${pid}`]; + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + products[productId].gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + const poolCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ).mul(NXM_PER_ALLOCATION_UNIT); + return total.add(poolCapacity); + }, Zero); + } + } else { + // For other assets, convert from NXM using asset rate + const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255).amount; + expectedAmount = nxmCapacity.mul(assetRates[assetId]).div(WeiPerEther); + } + expect(amount.toString()).to.equal(expectedAmount.toString()); + }); - expect(result).to.deep.equal([]); - }); - }); + // Calculate and verify used capacity + let expectedUsedCapacity; + if (isSinglePool) { + expectedUsedCapacity = expectedPoolProduct.allocations + .reduce((sum, alloc) => sum.add(alloc), Zero) + .mul(NXM_PER_ALLOCATION_UNIT); + } else { + expectedUsedCapacity = productPoolIds[productId].reduce((total, pid) => { + const poolProduct = storePoolProducts[`${productId}_${pid}`]; + const poolUsed = poolProduct.allocations + .reduce((sum, alloc) => sum.add(alloc), Zero) + .mul(NXM_PER_ALLOCATION_UNIT); + return total.add(poolUsed); + }, Zero); + } + expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); - describe('calculateFirstUsableTrancheForMaxPeriodIndex', function () { - const SECONDS_PER_DAY = 24 * 60 * 60; - const MAX_COVER_PERIOD = BigNumber.from(365 * SECONDS_PER_DAY); - const now = BigNumber.from(Math.floor(Date.now() / 1000)); + // Verify price calculations based on product type + if (products[productId].useFixedPrice) { + expect(product.minAnnualPrice.toString()).to.equal(product.maxAnnualPrice.toString()); + if (isSinglePool) { + expect(product.minAnnualPrice.toString()).to.equal(expectedPoolProduct.targetPrice.toString()); + } + } else { + expect(product.minAnnualPrice.toString()).to.not.equal(product.maxAnnualPrice.toString()); + expect(BigNumber.from(product.minAnnualPrice).gt(Zero)).to.equal(true); + expect(BigNumber.from(product.maxAnnualPrice).gt(Zero)).to.equal(true); + } + }; - it('should calculate index correctly for minimum grace period', function () { - const gracePeriod = BigNumber.from(35 * SECONDS_PER_DAY); + // Verify single product response (multi-pool) + verifyProductCapacity(singleProduct, storePoolProducts[`${productId}_${poolId}`], false); - const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); + // Verify pool product response (single-pool) + verifyProductCapacity(poolProduct, storePoolProducts[`${productId}_${poolId}`], true); - const firstActiveTrancheId = calculateTrancheId(now); - const expectedTrancheId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - expect(result).to.equal(expectedTrancheId - firstActiveTrancheId); - }); + // Verify product in pool products response (single-pool) + const productInPool = poolCapacityResponse.productsCapacity.find(p => p.productId === Number(productId)); + verifyProductCapacity(productInPool, storePoolProducts[`${productId}_${poolId}`], true); - it('should calculate index correctly for maximum grace period', function () { - const gracePeriod = BigNumber.from(365 * SECONDS_PER_DAY); + // Verify pool-level data + expect(poolCapacityResponse.poolId).to.equal(Number(poolId)); - const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); + // Calculate and verify pool utilization rate + const totalAvailableNXM = poolCapacityResponse.productsCapacity.reduce((sum, product) => { + const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255).amount; + return sum.add(nxmCapacity); + }, Zero); - const firstActiveTrancheId = calculateTrancheId(now); - const expectedTrancheId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - expect(result).to.equal(expectedTrancheId - firstActiveTrancheId); + const totalUsedNXM = poolCapacityResponse.productsCapacity.reduce( + (sum, product) => sum.add(product.usedCapacity), + Zero, + ); + + const expectedUtilizationRate = totalUsedNXM.mul(10000).div(totalAvailableNXM.add(totalUsedNXM)); + expect(poolCapacityResponse.utilizationRate.toString()).to.equal(expectedUtilizationRate.toString()); }); }); }); From 25b16396f4d70fdb358a5d0d11e155dc25b02266 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 15:55:06 +0200 Subject: [PATCH 19/28] test: add capacityEngine unit tests --- test/unit/capacityEngine.js | 475 +++++++++++++++++++++--------------- 1 file changed, 283 insertions(+), 192 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index e752c554..67d5aa1d 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -4,10 +4,10 @@ const sinon = require('sinon'); const { poolProductCapacities } = require('./responses'); const { + calculateExpectedUsedCapacity, getCurrentTimestamp, verifyPriceCalculations, verifyCapacityResponse, - calculateExpectedUsedCapacity, } = require('./utils'); const { getAllProductCapacities, @@ -22,9 +22,13 @@ const { } = require('../../src/lib/capacityEngine'); const { MAX_COVER_PERIOD, SECONDS_PER_DAY, NXM_PER_ALLOCATION_UNIT } = require('../../src/lib/constants'); const { - calculateTrancheId, - calculateFirstUsableTrancheIndex, calculateAvailableCapacity, + calculateBasePrice, + calculateFirstUsableTrancheIndex, + calculateFixedPricePremiumPerYear, + calculatePremiumPerYear, + calculateProductDataForTranche, + calculateTrancheId, } = require('../../src/lib/helpers'); const mockStore = require('../mocks/store'); @@ -32,6 +36,33 @@ const { BigNumber } = ethers; const { parseEther } = ethers.utils; const { Zero, WeiPerEther } = ethers.constants; +const verifyPoolCapacity = (poolCapacity, productId, products, poolProducts, now) => { + const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; + expect(poolProduct).to.not.equal(undefined); + + // Calculate first usable tranche index + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + products[productId].gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + + // Calculate available capacity considering all usable tranches + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); + + // Check pool-specific capacity + const nxmCapacityAmount = poolCapacity.availableCapacity.find(c => c.assetId === 255)?.amount || Zero; + expect(nxmCapacityAmount.toString()).to.equal(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); + + // Check pool-specific used capacity + const poolUsedCapacity = poolProduct.allocations.reduce((sum, alloc) => sum.add(alloc), Zero); + expect(poolCapacity.allocatedNxm.toString()).to.equal(poolUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); +}; + describe('Capacity Engine tests', function () { const store = { getState: () => null }; @@ -44,10 +75,53 @@ describe('Capacity Engine tests', function () { }); describe('calculateProductCapacity', function () { + const { assets, assetRates } = mockStore; + const now = getCurrentTimestamp(); + + // Add these new verification functions + const verifyCapacityAsset = (capacity, nxmCapacity, assets, assetRates) => { + expect(capacity.asset).to.deep.equal(assets[capacity.assetId]); + + if (capacity.assetId !== 255) { + const expectedAmount = nxmCapacity.mul(assetRates[capacity.assetId]).div(WeiPerEther); + expect(capacity.amount.toString()).to.equal(expectedAmount.toString()); + } + }; + + const verifyPoolCapacityWithAssets = (poolCapacity, productId, poolProducts) => { + const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; + const nxmCapacity = poolCapacity.availableCapacity.find(c => c.assetId === 255)?.amount || Zero; + + poolCapacity.availableCapacity.forEach(capacity => + verifyCapacityAsset(capacity, nxmCapacity, assets, assetRates), + ); + + const expectedAllocatedNxm = poolProduct.allocations + .reduce((sum, alloc) => sum.add(alloc), Zero) + .mul(NXM_PER_ALLOCATION_UNIT); + expect(poolCapacity.allocatedNxm.toString()).to.equal(expectedAllocatedNxm.toString()); + }; + + // Common verification functions + const verifyNXMCapacity = (nxmCapacity, expectedAmount) => { + const amount = nxmCapacity?.amount || Zero; + expect(amount.toString()).to.equal(expectedAmount.toString()); + if (nxmCapacity) { + expect(nxmCapacity.asset).to.deep.equal(assets[255]); + } + }; + + const calculatePoolCapacity = (poolProduct, firstUsableTrancheIndex = 0) => { + return calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ).mul(NXM_PER_ALLOCATION_UNIT); + }; + it('should calculate product capacity correctly', function () { const productId = '0'; - const now = getCurrentTimestamp(); - const { assets, assetRates, poolProducts } = store.getState(); + const { poolProducts } = store.getState(); const response = calculateProductCapacity(store, productId, { periodSeconds: SECONDS_PER_DAY.mul(30), @@ -59,87 +133,113 @@ describe('Capacity Engine tests', function () { expect(response.productId).to.equal(0); expect(response.availableCapacity).to.be.an('array'); - // Get capacities from both pools - const pool1Capacity = calculateAvailableCapacity( - poolProducts['0_1'].trancheCapacities, - poolProducts['0_1'].allocations, - 0, - ); - const pool2Capacity = calculateAvailableCapacity( - poolProducts['0_2'].trancheCapacities, - poolProducts['0_2'].allocations, - 0, - ); + const pool1Capacity = calculatePoolCapacity(poolProducts['0_1']); + const pool2Capacity = calculatePoolCapacity(poolProducts['0_2']); + const totalCapacity = pool1Capacity.add(pool2Capacity); - // Verify total NXM capacity across both pools - const totalCapacity = pool1Capacity.add(pool2Capacity).mul(NXM_PER_ALLOCATION_UNIT); const nxmCapacity = response.availableCapacity.find(c => c.assetId === 255); - expect(nxmCapacity.amount.toString()).to.equal(totalCapacity.toString()); + verifyNXMCapacity(nxmCapacity, totalCapacity); }); it('should handle fixed price products correctly', function () { - const fixedPriceProductId = '2'; - const now = BigNumber.from(Date.now()).div(1000); - const response = calculateProductCapacity(store, fixedPriceProductId, { - periodSeconds: SECONDS_PER_DAY.mul(30), + const product2Pool1 = [mockStore.poolProducts['2_1']]; // Product 2 uses fixed price + const firstUsableTrancheIndex = 0; + const [{ allocations, trancheCapacities, targetPrice }] = product2Pool1; + const lastIndex = allocations.length - 1; + + const { aggregatedData, capacityPerPool } = calculateProductDataForTranche( + product2Pool1, + firstUsableTrancheIndex, + mockStore.products['2'].useFixedPrice, now, - assets: mockStore.assets, - assetRates: mockStore.assetRates, - }); + assets, + assetRates, + ); + + const [capacityPool] = capacityPerPool; + + // Calculate expected fixed price + const used = allocations[lastIndex].mul(NXM_PER_ALLOCATION_UNIT); + const availableCapacity = trancheCapacities[lastIndex].sub(allocations[lastIndex]); + const availableInNXM = availableCapacity.mul(NXM_PER_ALLOCATION_UNIT); + const expectedFixedPrice = WeiPerEther.mul( + calculateFixedPricePremiumPerYear(NXM_PER_ALLOCATION_UNIT, targetPrice), + ).div(NXM_PER_ALLOCATION_UNIT); + + expect(aggregatedData.capacityUsedNXM.toString()).to.equal(used.toString()); + expect(aggregatedData.capacityAvailableNXM.toString()).to.equal(availableInNXM.toString()); + expect(aggregatedData.minPrice.toString()).to.equal(expectedFixedPrice.toString()); + expect(aggregatedData.totalPremium.toString()).to.equal( + calculateFixedPricePremiumPerYear(availableInNXM, targetPrice).toString(), + ); - expect(response.minAnnualPrice.toString()).to.deep.equal(response.maxAnnualPrice.toString()); + expect(capacityPerPool).to.have.lengthOf(1); + expect(capacityPool.poolId).to.equal(1); + expect(capacityPool.minAnnualPrice.toString()).to.equal(expectedFixedPrice.toString()); + expect(capacityPool.maxAnnualPrice.toString()).to.equal(expectedFixedPrice.toString()); + expect(capacityPool.allocatedNxm.toString()).to.equal(used.toString()); }); it('should handle non-fixed price products correctly', function () { - const nonFixedPriceProductId = '0'; - const now = BigNumber.from(Date.now()).div(1000); - const response = calculateProductCapacity(store, nonFixedPriceProductId, { + const { poolProducts } = store.getState(); + const productPool = [mockStore.poolProducts['0_1']]; + const [{ allocations, trancheCapacities, targetPrice, bumpedPrice, bumpedPriceUpdateTime }] = productPool; + const lastIndex = allocations.length - 1; + + const response = calculateProductCapacity(store, '0', { periodSeconds: SECONDS_PER_DAY.mul(30), now, - assets: mockStore.assets, - assetRates: mockStore.assetRates, + assets, + assetRates, + withPools: true, }); - expect(response.minAnnualPrice.toString()).to.not.deep.equal(response.maxAnnualPrice.toString()); + // Calculate expected values + const used = allocations[lastIndex].mul(NXM_PER_ALLOCATION_UNIT); + const availableCapacity = trancheCapacities[lastIndex].sub(allocations[lastIndex]); + const availableInNXM = availableCapacity.mul(NXM_PER_ALLOCATION_UNIT); + const total = trancheCapacities[lastIndex].mul(NXM_PER_ALLOCATION_UNIT); + + // Calculate base price + const basePrice = calculateBasePrice(targetPrice, bumpedPrice, bumpedPriceUpdateTime, now); + + // Calculate expected min annual price + const minPremiumPerYear = calculatePremiumPerYear(NXM_PER_ALLOCATION_UNIT, basePrice, used, total); + const expectedMinPrice = WeiPerEther.mul(minPremiumPerYear).div(NXM_PER_ALLOCATION_UNIT); + + // Calculate expected max annual price + const maxPremiumPerYear = calculatePremiumPerYear(availableInNXM, basePrice, used, total); + const expectedMaxPrice = availableInNXM.isZero() ? Zero : WeiPerEther.mul(maxPremiumPerYear).div(availableInNXM); + + expect(response.minAnnualPrice.toString()).to.equal(expectedMinPrice.toString()); + expect(response.maxAnnualPrice.toString()).to.equal(expectedMaxPrice.toString()); + expect(response.minAnnualPrice.toString()).to.not.equal(response.maxAnnualPrice.toString()); + expect(response.minAnnualPrice.lt(response.maxAnnualPrice)).to.equal(true); + + response.capacityPerPool.forEach(poolCapacity => verifyPoolCapacityWithAssets(poolCapacity, '0', poolProducts)); }); it('should include capacityPerPool when withPools is true', function () { + const now = getCurrentTimestamp(); const productId = '0'; - const now = BigNumber.from(Date.now()).div(1000); + const { products, poolProducts } = store.getState(); const response = calculateProductCapacity(store, productId, { periodSeconds: SECONDS_PER_DAY.mul(30), withPools: true, now, - assets: mockStore.assets, - assetRates: mockStore.assetRates, + assets, + assetRates, }); - const { poolProducts, assets, assetRates } = store.getState(); - - response.capacityPerPool.forEach(poolCapacity => { - const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; - - poolCapacity.availableCapacity.forEach(capacity => { - expect(capacity.asset).to.deep.equal(assets[capacity.assetId]); - - if (capacity.assetId !== 255) { - const nxmCapacity = poolCapacity.availableCapacity.find(c => c.assetId === 255).amount; - const expectedAmount = nxmCapacity.mul(assetRates[capacity.assetId]).div(WeiPerEther); - expect(capacity.amount.toString()).to.equal(expectedAmount.toString()); - } - }); - - const expectedAllocatedNxm = poolProduct.allocations - .reduce((sum, alloc) => sum.add(alloc), Zero) - .mul(NXM_PER_ALLOCATION_UNIT); - expect(poolCapacity.allocatedNxm.toString()).to.equal(expectedAllocatedNxm.toString()); - }); + response.capacityPerPool.forEach(poolCapacity => + verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now), + ); }); it('should filter by poolId when provided', function () { const productId = '0'; const poolId = 2; - const now = BigNumber.from(Date.now()).div(1000); + const now = getCurrentTimestamp(); const response = calculateProductCapacity(store, productId, { poolId, periodSeconds: SECONDS_PER_DAY.mul(30), @@ -148,10 +248,10 @@ describe('Capacity Engine tests', function () { assetRates: mockStore.assetRates, }); - const expectedCapacity = poolProductCapacities[poolId].productsCapacity.find( + const { availableCapacity } = poolProductCapacities[poolId].productsCapacity.find( p => p.productId === Number(productId), ); - const expectedAvailableCapacity = expectedCapacity.availableCapacity.map(cap => ({ + const expectedAvailableCapacity = availableCapacity.map(cap => ({ ...cap, amount: BigNumber.from(cap.amount), })); @@ -161,7 +261,7 @@ describe('Capacity Engine tests', function () { it('should return null for non-existing product', function () { const nonExistingProductId = '999'; - const now = BigNumber.from(Date.now()).div(1000); + const now = getCurrentTimestamp(); const response = calculateProductCapacity(store, nonExistingProductId, { periodSeconds: SECONDS_PER_DAY.mul(30), now, @@ -174,59 +274,131 @@ describe('Capacity Engine tests', function () { it('should handle zero capacity correctly', function () { const productId = '0'; - const now = BigNumber.from(Date.now()).div(1000); - // Mock store to return zero capacity with all required fields const zeroCapacityStore = { getState: () => ({ ...mockStore, poolProducts: { '0_1': { - productId: 0, - poolId: 1, - trancheCapacities: [Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero], // 8 tranches of zero capacity - allocations: [Zero, Zero, Zero, Zero, Zero, Zero, Zero, Zero], // 8 tranches of zero allocations - lastEffectiveWeight: Zero, - targetWeight: Zero, + ...mockStore.poolProducts['0_1'], + trancheCapacities: Array(8).fill(Zero), + allocations: Array(8).fill(Zero), targetPrice: Zero, bumpedPrice: Zero, bumpedPriceUpdateTime: Zero, }, }, - // Keep the product pool IDs mapping - productPoolIds: { - 0: [1], // Only pool 1 for product 0 - }, + productPoolIds: { 0: [1] }, // Only pool 1 for product 0 }), }; const response = calculateProductCapacity(zeroCapacityStore, productId, { periodSeconds: SECONDS_PER_DAY.mul(30), now, - assets: mockStore.assets, - assetRates: mockStore.assetRates, + assets, + assetRates, }); - expect(response.availableCapacity[0].amount.toString()).to.equal(Zero.toString()); - expect(response.maxAnnualPrice.toString()).to.equal(Zero.toString()); + const nxmCapacity = response.availableCapacity.find(c => c.assetId === 255); + verifyNXMCapacity(nxmCapacity, Zero); + expect(response.maxAnnualPrice.toString()).to.equal('0'); }); it('should calculate capacity across multiple tranches for non-fixed price products', function () { const productId = '1'; // Non-fixed price product - const now = BigNumber.from(Date.now()).div(1000); + const now = getCurrentTimestamp(); const response = calculateProductCapacity(store, productId, { - periodSeconds: SECONDS_PER_DAY.mul(30), + periodSeconds: SECONDS_PER_DAY.mul(7), // 1 week now, assets: mockStore.assets, assetRates: mockStore.assetRates, }); - // Should have different min and max prices + // Calculate expected values + const pool1Product = mockStore.poolProducts['1_1']; + const pool2Product = mockStore.poolProducts['1_2']; + + // Calculate base prices for each pool + const basePrice1 = calculateBasePrice( + pool1Product.targetPrice, + pool1Product.bumpedPrice, + pool1Product.bumpedPriceUpdateTime, + now, + ); + const basePrice2 = calculateBasePrice( + pool2Product.targetPrice, + pool2Product.bumpedPrice, + pool2Product.bumpedPriceUpdateTime, + now, + ); + + // Get total capacity and used capacity for each pool + const totalCapacity1 = pool1Product.trancheCapacities[7].mul(NXM_PER_ALLOCATION_UNIT); + const totalCapacity2 = pool2Product.trancheCapacities[8].mul(NXM_PER_ALLOCATION_UNIT); + const usedCapacity1 = pool1Product.allocations[7].mul(NXM_PER_ALLOCATION_UNIT); + const usedCapacity2 = pool2Product.allocations[8].mul(NXM_PER_ALLOCATION_UNIT); + + // Calculate min premium (for 1 unit) + const minPremium1 = calculatePremiumPerYear(NXM_PER_ALLOCATION_UNIT, basePrice1, usedCapacity1, totalCapacity1); + const minPremium2 = calculatePremiumPerYear(NXM_PER_ALLOCATION_UNIT, basePrice2, usedCapacity2, totalCapacity2); + + // Expected min price is the minimum of the two pools + const expectedMinPrice = WeiPerEther.mul(minPremium1.lt(minPremium2) ? minPremium1 : minPremium2).div( + NXM_PER_ALLOCATION_UNIT, + ); + + // Calculate max premium (for all available capacity) + const availableCapacity1 = totalCapacity1.sub(usedCapacity1); + const availableCapacity2 = totalCapacity2.sub(usedCapacity2); + const maxPremium1 = calculatePremiumPerYear(availableCapacity1, basePrice1, usedCapacity1, totalCapacity1); + const maxPremium2 = calculatePremiumPerYear(availableCapacity2, basePrice2, usedCapacity2, totalCapacity2); + + // Expected max price is the maximum premium per unit + const maxPrice1 = availableCapacity1.isZero() ? Zero : WeiPerEther.mul(maxPremium1).div(availableCapacity1); + const maxPrice2 = availableCapacity2.isZero() ? Zero : WeiPerEther.mul(maxPremium2).div(availableCapacity2); + const expectedMaxPrice = maxPrice1.gt(maxPrice2) ? maxPrice1 : maxPrice2; + + // Verify prices + expect(response.minAnnualPrice.toString()).to.equal(expectedMinPrice.toString()); + expect(response.maxAnnualPrice.toString()).to.equal(expectedMaxPrice.toString()); expect(response.minAnnualPrice).to.not.deep.equal(response.maxAnnualPrice); expect(response.maxAnnualPrice).to.not.deep.equal(Zero); }); }); describe('getAllProductCapacities', function () { + const now = getCurrentTimestamp(); + + const verifyProductCapacity = (product, storeProduct, poolIds, poolProducts, assets) => { + expect(storeProduct).to.not.equal(undefined); + + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + storeProduct.gracePeriod, + SECONDS_PER_DAY.mul(30), + ); + + const expectedAvailableNXM = poolIds.reduce((total, poolId) => { + const poolProduct = poolProducts[`${product.productId}_${poolId}`]; + const availableCapacity = calculateAvailableCapacity( + poolProduct.trancheCapacities, + poolProduct.allocations, + firstUsableTrancheIndex, + ); + return total.add(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT)); + }, Zero); + + const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255); + expect(nxmCapacity.amount.toString()).to.equal(expectedAvailableNXM.toString()); + expect(nxmCapacity.asset).to.deep.equal(assets[255]); + + const expectedUsedCapacity = poolIds.reduce((total, poolId) => { + const poolProduct = poolProducts[`${product.productId}_${poolId}`]; + const usedCapacity = poolProduct.allocations.reduce((sum, alloc) => sum.add(alloc), Zero); + return total.add(usedCapacity.mul(NXM_PER_ALLOCATION_UNIT)); + }, Zero); + + expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); + }; it('should return capacity for all products across all pools', function () { const response = getAllProductCapacities(store); const { products, productPoolIds, poolProducts, assets } = store.getState(); @@ -236,54 +408,8 @@ describe('Capacity Engine tests', function () { // Check each product's capacity data response.forEach(product => { - const productId = product.productId; - const storeProduct = products[productId]; - const poolIds = productPoolIds[productId]; - - // Check product exists in store - expect(storeProduct).to.not.equal(undefined); - - // Verify available capacity calculation for each pool - const now = BigNumber.from(Date.now()).div(1000); - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( - now, - storeProduct.gracePeriod, - SECONDS_PER_DAY.mul(30), - ); - - // Calculate expected NXM using the same logic as the function - const expectedAvailableNXM = poolIds.reduce((total, poolId) => { - const poolProduct = poolProducts[`${productId}_${poolId}`]; - const availableCapacity = calculateAvailableCapacity( - poolProduct.trancheCapacities, - poolProduct.allocations, - firstUsableTrancheIndex, - ); - const availableInNXM = availableCapacity.mul(NXM_PER_ALLOCATION_UNIT); - return total.add(availableInNXM); - }, Zero); - - // Check NXM capacity - const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255); - expect(nxmCapacity.amount.toString()).to.equal(expectedAvailableNXM.toString()); - expect(nxmCapacity.asset).to.deep.equal(assets[255]); - - // Check used capacity - const expectedUsedCapacity = poolIds.reduce((total, poolId) => { - const poolProduct = poolProducts[`${productId}_${poolId}`]; - const usedCapacity = poolProduct.allocations.reduce((sum, alloc) => sum.add(alloc), Zero); - const usedCapacityInNXM = usedCapacity.mul(NXM_PER_ALLOCATION_UNIT); - return total.add(usedCapacityInNXM); - }, Zero); - - expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); - - // Check price calculations based on product type - if (storeProduct.useFixedPrice) { - expect(product.minAnnualPrice.toString()).to.equal(product.maxAnnualPrice.toString()); - } else { - expect(product.minAnnualPrice.toString()).to.not.equal(product.maxAnnualPrice.toString()); - } + const { productId } = product; + verifyProductCapacity(product, products[productId], productPoolIds[productId], poolProducts, assets); }); }); @@ -332,46 +458,21 @@ describe('Capacity Engine tests', function () { totalUsedCapacity = totalUsedCapacity.add(allocation.mul(NXM_PER_ALLOCATION_UNIT)); }); }); + expect(response.usedCapacity.toString()).to.equal(totalUsedCapacity.toString()); }); it('should include detailed pool breakdown when withPools is true', function () { + const now = getCurrentTimestamp(); const productId = '3'; const response = getProductCapacity(store, productId, { withPools: true }); const { productPoolIds, poolProducts, products } = store.getState(); - const now = BigNumber.from(Date.now()).div(1000); expect(response.capacityPerPool).to.have.lengthOf(productPoolIds[productId].length); - response.capacityPerPool.forEach(poolCapacity => { - const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; - expect(poolProduct).to.not.equal(undefined); - - // Calculate first usable tranche index - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( - now, - products[productId].gracePeriod, - SECONDS_PER_DAY.mul(30), - ); - - // Calculate available capacity considering all usable tranches - const availableCapacity = calculateAvailableCapacity( - poolProduct.trancheCapacities, - poolProduct.allocations, - firstUsableTrancheIndex, - ); - - // Check pool-specific capacity - const nxmCapacity = poolCapacity.availableCapacity.find(c => c.assetId === 255); - expect(nxmCapacity.amount.toString()).to.equal(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); - - // Check pool-specific used capacity - let poolUsedCapacity = Zero; - poolProduct.allocations.forEach(allocation => { - poolUsedCapacity = poolUsedCapacity.add(allocation); - }); - expect(poolCapacity.allocatedNxm.toString()).to.equal(poolUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); - }); + response.capacityPerPool.forEach(poolCapacity => + verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now), + ); }); }); @@ -381,7 +482,7 @@ describe('Capacity Engine tests', function () { const response = getPoolCapacity(store, poolId); const { poolProducts, products } = store.getState(); - const now = BigNumber.from(Date.now()).div(1000); + const now = getCurrentTimestamp(); // Check products in pool const productsInPool = Object.entries(poolProducts) @@ -583,24 +684,29 @@ describe('Capacity Engine tests', function () { }); describe('calculatePoolUtilizationRate', function () { - it('should calculate utilization rate correctly for multiple products', function () { - const products = [ + let defaultProducts; + + before(function () { + defaultProducts = [ { availableCapacity: [ - { assetId: 255, amount: parseEther('100') }, - { assetId: 1, amount: parseEther('50') }, + { assetId: 255, amount: parseEther('100') }, // NXM capacity + { assetId: 1, amount: parseEther('50') }, // Other asset capacity ], - usedCapacity: parseEther('50'), + usedCapacity: parseEther('50'), // Used capacity }, { availableCapacity: [ - { assetId: 255, amount: parseEther('200') }, - { assetId: 1, amount: parseEther('100') }, + { assetId: 255, amount: parseEther('200') }, // NXM capacity + { assetId: 1, amount: parseEther('100') }, // Other asset capacity ], - usedCapacity: parseEther('100'), + usedCapacity: parseEther('100'), // Used capacity }, ]; + }); + it('should calculate utilization rate correctly for multiple products', function () { + const products = [...defaultProducts]; const utilizationRate = calculatePoolUtilizationRate(products); // Total available: 300 NXM // Total used: 150 NXM @@ -621,31 +727,25 @@ describe('Capacity Engine tests', function () { usedCapacity: parseEther('25'), }, ]; - const utilizationRate = calculatePoolUtilizationRate(products); expect(utilizationRate.toNumber()).to.equal(10000); // 100% utilization when no available NXM }); it('should handle products with zero used capacity', function () { - const products = [ - { - availableCapacity: [{ assetId: 255, amount: parseEther('100') }], - usedCapacity: Zero, - }, - ]; - + const products = defaultProducts.map(product => ({ + ...product, + usedCapacity: Zero, + })); const utilizationRate = calculatePoolUtilizationRate(products); expect(utilizationRate.toNumber()).to.equal(0); }); it('should handle products with zero total capacity', function () { - const products = [ - { - availableCapacity: [{ assetId: 255, amount: Zero }], - usedCapacity: Zero, - }, - ]; - + const products = defaultProducts.map(product => ({ + ...product, + availableCapacity: [{ assetId: 255, amount: Zero }], + usedCapacity: Zero, + })); const utilizationRate = calculatePoolUtilizationRate(products); expect(utilizationRate.toNumber()).to.equal(0); }); @@ -662,16 +762,7 @@ describe('Capacity Engine tests', function () { }); it('should aggregate capacity across multiple products correctly', function () { - const products = [ - { - availableCapacity: [{ assetId: 255, amount: parseEther('100') }], - usedCapacity: parseEther('50'), - }, - { - availableCapacity: [{ assetId: 255, amount: parseEther('200') }], - usedCapacity: parseEther('100'), - }, - ]; + const products = [...defaultProducts]; const rate = calculatePoolUtilizationRate(products); // Total available: 300, Total used: 150 // Expected: (150 / (300 + 150)) * 10000 = 3333 @@ -699,7 +790,7 @@ describe('Capacity Engine tests', function () { const poolId = '1'; const productId = '0'; const { assets, assetRates, poolProducts: storePoolProducts, products, productPoolIds } = store.getState(); - const now = BigNumber.from(Date.now()).div(1000); + const now = getCurrentTimestamp(); // Get responses from all endpoints const singleProduct = getProductCapacity(store, productId); From 88ed6ed266ed5800e47177c20b08256cdc38980f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 27 Nov 2024 17:11:45 +0200 Subject: [PATCH 20/28] docs: fix openapi docs --- src/routes/capacity.js | 2 +- src/routes/pricing.js | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index ec6f1e64..0dfa7993 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -254,7 +254,7 @@ router.get( * example: * poolId: 1 * utilizationRate: 5000 - * productCapacity: [ + * productsCapacity: [ * { * productId: 1, * availableCapacity: [ diff --git a/src/routes/pricing.js b/src/routes/pricing.js index e1b33109..36d02194 100644 --- a/src/routes/pricing.js +++ b/src/routes/pricing.js @@ -94,7 +94,7 @@ router.get( * type: integer * description: The pool id * targetPrice: - * type: string + * type: integer * description: The target price as a percentage expressed as basis points (0-10,000) * PricingResult: * type: object @@ -108,9 +108,8 @@ router.get( * items: * $ref: '#/components/schemas/PoolPricing' * weightedAveragePrice: - * type: string - * description: The weighted average price across all pools as a percentage expressed as basis points 0-10,000 - * The weight is based on the available capacity of the pool. + * type: integer + * description: The weighted average price across all pools as a percentage expressed as basis (0-10,000) */ module.exports = router; From 4396b13b56b3f898d80eab028b5b727367a2f2ff Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 28 Nov 2024 15:03:43 +0200 Subject: [PATCH 21/28] test: add more capacityEngine unit tests * add more test cases for getProductsInPool * add more test cases for API Services --- test/unit/capacityEngine.js | 61 +++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index 67d5aa1d..89fb3483 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -63,7 +63,7 @@ const verifyPoolCapacity = (poolCapacity, productId, products, poolProducts, now expect(poolCapacity.allocatedNxm.toString()).to.equal(poolUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); }; -describe('Capacity Engine tests', function () { +describe('capacityEngine', function () { const store = { getState: () => null }; beforeEach(function () { @@ -639,6 +639,21 @@ describe('Capacity Engine tests', function () { const stringResult = getProductsInPool(store, '1'); expect(numericResult).to.deep.equal(stringResult); }); + + it('should handle undefined productPools', function () { + const emptyStore = { + getState: () => ({ + products: { 1: {}, 2: {}, 3: {} }, + productPoolIds: {}, + poolProducts: {}, + }), + }; + + const poolId = 2; + const result = getProductsInPool(emptyStore, poolId); + + expect(result).to.deep.equal([]); + }); }); describe('calculateFirstUsableTrancheForMaxPeriodIndex', function () { @@ -793,14 +808,20 @@ describe('Capacity Engine tests', function () { const now = getCurrentTimestamp(); // Get responses from all endpoints + const allProducts = getAllProductCapacities(store); const singleProduct = getProductCapacity(store, productId); const poolCapacityResponse = getPoolCapacity(store, poolId); const poolProduct = getProductCapacityInPool(store, poolId, productId); // Helper to verify product capacity structure - const verifyProductCapacity = (product, expectedPoolProduct, isSinglePool = false) => { + const verifyProductCapacity = ( + product, + expectedPoolProduct, + isSinglePool = false, + expectedProductId = productId, + ) => { // Verify product ID - expect(product.productId).to.equal(Number(productId)); + expect(product.productId).to.equal(Number(expectedProductId)); // Calculate and verify available capacity for each asset product.availableCapacity.forEach(capacity => { @@ -816,7 +837,7 @@ describe('Capacity Engine tests', function () { // For single pool responses, use direct capacity calculation const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( now, - products[productId].gracePeriod, + products[expectedProductId].gracePeriod, SECONDS_PER_DAY.mul(30), ); expectedAmount = calculateAvailableCapacity( @@ -826,11 +847,11 @@ describe('Capacity Engine tests', function () { ).mul(NXM_PER_ALLOCATION_UNIT); } else { // For multi-pool responses, sum capacities across all pools - expectedAmount = productPoolIds[productId].reduce((total, pid) => { - const poolProduct = storePoolProducts[`${productId}_${pid}`]; + expectedAmount = productPoolIds[expectedProductId].reduce((total, pid) => { + const poolProduct = storePoolProducts[`${expectedProductId}_${pid}`]; const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( now, - products[productId].gracePeriod, + products[expectedProductId].gracePeriod, SECONDS_PER_DAY.mul(30), ); const poolCapacity = calculateAvailableCapacity( @@ -846,6 +867,7 @@ describe('Capacity Engine tests', function () { const nxmCapacity = product.availableCapacity.find(c => c.assetId === 255).amount; expectedAmount = nxmCapacity.mul(assetRates[assetId]).div(WeiPerEther); } + expect(amount.toString()).to.equal(expectedAmount.toString()); }); @@ -856,8 +878,8 @@ describe('Capacity Engine tests', function () { .reduce((sum, alloc) => sum.add(alloc), Zero) .mul(NXM_PER_ALLOCATION_UNIT); } else { - expectedUsedCapacity = productPoolIds[productId].reduce((total, pid) => { - const poolProduct = storePoolProducts[`${productId}_${pid}`]; + expectedUsedCapacity = productPoolIds[expectedProductId].reduce((total, pid) => { + const poolProduct = storePoolProducts[`${expectedProductId}_${pid}`]; const poolUsed = poolProduct.allocations .reduce((sum, alloc) => sum.add(alloc), Zero) .mul(NXM_PER_ALLOCATION_UNIT); @@ -867,7 +889,7 @@ describe('Capacity Engine tests', function () { expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); // Verify price calculations based on product type - if (products[productId].useFixedPrice) { + if (products[expectedProductId].useFixedPrice) { expect(product.minAnnualPrice.toString()).to.equal(product.maxAnnualPrice.toString()); if (isSinglePool) { expect(product.minAnnualPrice.toString()).to.equal(expectedPoolProduct.targetPrice.toString()); @@ -879,6 +901,25 @@ describe('Capacity Engine tests', function () { } }; + // Verify all products response + const productFromAll = allProducts.find(p => p.productId === Number(productId)); + expect(productFromAll).to.not.equal(undefined); + verifyProductCapacity(productFromAll, storePoolProducts[`${productId}_${poolId}`], false); + + // Verify that all products are included + const expectedProductIds = Object.keys(products).map(Number); + const actualProductIds = allProducts.map(p => p.productId); + expect(actualProductIds.sort()).to.deep.equal(expectedProductIds.sort()); + + // Verify each product in allProducts has consistent structure + allProducts.forEach(product => { + const currentProductId = product.productId.toString(); + const productPoolProduct = storePoolProducts[`${currentProductId}_${poolId}`]; + if (productPoolProduct) { + verifyProductCapacity(product, productPoolProduct, false, currentProductId); + } + }); + // Verify single product response (multi-pool) verifyProductCapacity(singleProduct, storePoolProducts[`${productId}_${poolId}`], false); From 0c9106435e3c5f20f3fe7dcd4be48e20d04ff8e5 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 10:49:22 +0200 Subject: [PATCH 22/28] refactor: rename periodSeconds to period * drop object wrapped period parameter * drop withPools query param --- src/lib/quoteEngine.js | 8 ++++---- src/routes/capacity.js | 17 ++++++++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/lib/quoteEngine.js b/src/lib/quoteEngine.js index 8999b01c..3fc535b8 100644 --- a/src/lib/quoteEngine.js +++ b/src/lib/quoteEngine.js @@ -210,11 +210,11 @@ const customAllocationPriorityFixedPrice = (amountToAllocate, poolsData, customP * @param {object} store - The application state store. * @param {number} productId - The ID of the product to quote. * @param {BigNumber} amount - The amount of coverage requested. - * @param {number} periodSeconds - The cover period in seconds. + * @param {number} period - The cover period in seconds. * @param {string} coverAsset - The assetId of the asset to be covered. * @returns {Array} - An array of objects containing pool allocations and premiums. */ -const quoteEngine = (store, productId, amount, periodSeconds, coverAsset) => { +const quoteEngine = (store, productId, amount, period, coverAsset) => { const product = selectProduct(store, productId); if (!product) { @@ -234,7 +234,7 @@ const quoteEngine = (store, productId, amount, periodSeconds, coverAsset) => { const assetRates = store.getState().assetRates; const now = BigNumber.from(Date.now()).div(1000); - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, periodSeconds); + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, period); const coverAmountInNxm = amount.mul(WeiPerEther).div(assetRate); // rounding up to nearest allocation unit @@ -301,7 +301,7 @@ const quoteEngine = (store, productId, amount, periodSeconds, coverAsset) => { ? calculateFixedPricePremiumPerYear(amountToAllocate, pool.basePrice) : calculatePremiumPerYear(amountToAllocate, pool.basePrice, pool.initialCapacityUsed, pool.totalCapacity); - const premiumInNxm = premiumPerYear.mul(periodSeconds).div(ONE_YEAR); + const premiumInNxm = premiumPerYear.mul(period).div(ONE_YEAR); const premiumInAsset = premiumInNxm.mul(assetRate).div(WeiPerEther); const capacityInNxm = pool.totalCapacity.sub(pool.initialCapacityUsed); diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 0dfa7993..78f63039 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -67,9 +67,9 @@ router.get( } try { - const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); + const period = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const capacities = getAllProductCapacities(store, { periodSeconds }); + const capacities = getAllProductCapacities(store, period); const response = capacities.map(capacity => formatCapacityResult(capacity)); console.log(JSON.stringify(capacities, null, 2)); @@ -181,7 +181,6 @@ router.get( asyncRoute(async (req, res) => { const productId = Number(req.params.productId); const periodQuery = Number(req.query.period) || 30; - const withPools = req.query.withPools === 'true'; 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 }); @@ -191,9 +190,9 @@ router.get( } try { - const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); + const period = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const capacity = getProductCapacity(store, productId, { periodSeconds, withPools }); + const capacity = getProductCapacity(store, productId, period); if (!capacity) { return res.status(400).send({ error: 'Invalid Product Id', response: null }); @@ -294,9 +293,9 @@ router.get( } try { - const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); + const period = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const poolCapacity = getPoolCapacity(store, poolId, { periodSeconds }); + const poolCapacity = getPoolCapacity(store, poolId, period); if (poolCapacity === null) { return res.status(404).send({ error: 'Pool not found', response: null }); @@ -377,9 +376,9 @@ router.get( return res.status(400).send({ error: 'Invalid productId: must be an integer', response: null }); } try { - const periodSeconds = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); + const period = BigNumber.from(periodQuery).mul(SECONDS_PER_DAY); const store = req.app.get('store'); - const capacity = getProductCapacityInPool(store, poolId, productId, { periodSeconds }); + const capacity = getProductCapacityInPool(store, poolId, productId, period); if (!capacity) { return res.status(404).send({ error: 'Product not found in the specified pool', response: null }); From dba124d3bdeb47c778f96f0a8f5f149420e63764 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 10:58:23 +0200 Subject: [PATCH 23/28] docs: update @openapi docs for capacity endpoints --- src/routes/capacity.js | 137 +++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 89 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 78f63039..f873d1c3 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -96,80 +96,48 @@ router.get( * schema: * type: integer * description: The product id - * - in: query - * name: withPools - * required: false - * schema: - * type: boolean - * default: false - * description: When true, includes `capacityPerPool` field in the response * responses: * 200: - * description: Returns capacity data for a product. If withPools=true, includes capacityPerPool data. + * description: Returns capacity data for a product, including capacityPerPool data. * content: * application/json: * schema: - * oneOf: - * - $ref: '#/components/schemas/CapacityResult' - * - $ref: '#/components/schemas/CapacityResultWithPools' - * examples: - * withoutPools: - * summary: Response when withPools=false - * value: - * productId: 1 - * availableCapacity: [ - * { - * assetId: 1, - * amount: "1000000000000000000", - * asset: { - * id: 1, - * symbol: "ETH", - * decimals: 18 - * } - * } - * ] - * allocatedNxm: "500000000000000000" - * utilizationRate: 5000 - * minAnnualPrice: "0.025" - * maxAnnualPrice: "0.1" - * withPools: - * summary: Response when withPools=true - * value: - * productId: 1 + * $ref: '#/components/schemas/CapacityResult' + * example: + * productId: 1 + * availableCapacity: [ + * { + * assetId: 1, + * amount: "1000000000000000000", + * asset: { + * id: 1, + * symbol: "ETH", + * decimals: 18 + * } + * } + * ] + * allocatedNxm: "500000000000000000" + * minAnnualPrice: "0.025" + * maxAnnualPrice: "0.1" + * capacityPerPool: [ + * { + * poolId: 1, * availableCapacity: [ * { * assetId: 1, - * amount: "1000000000000000000", + * amount: "500000000000000000", * asset: { * id: 1, * symbol: "ETH", * decimals: 18 * } * } - * ] - * allocatedNxm: "500000000000000000" - * utilizationRate: 5000 - * minAnnualPrice: "0.025" + * ], + * allocatedNxm: "250000000000000000", + * minAnnualPrice: "0.025", * maxAnnualPrice: "0.1" - * capacityPerPool: [ - * { - * poolId: 1, - * availableCapacity: [ - * { - * assetId: 1, - * amount: "500000000000000000", - * asset: { - * id: 1, - * symbol: "ETH", - * decimals: 18 - * } - * } - * ], - * allocatedNxm: "250000000000000000", - * minAnnualPrice: "0.025", - * maxAnnualPrice: "0.1" - * } - * ] + * } + * ] * 400: * description: Invalid productId or period * 500: @@ -451,41 +419,32 @@ router.get( * productId: * type: integer * description: The product id - * PoolCapacity: - * type: object - * properties: - * poolId: - * type: integer - * description: The pool id - * utilizationRate: - * type: integer - * description: The pool-level utilization rate in basis points (0-10,000) - * productsCapacity: - * type: array - * items: - * $ref: '#/components/schemas/ProductCapacity' - * required: - * - poolId - * - utilizationRate - * - productsCapacity - * CapacityResultWithPools: - * allOf: - * - $ref: '#/components/schemas/ProductCapacity' - * - type: object - * properties: * capacityPerPool: * type: array - * description: The capacity per pool. Only present when withPools=true. + * description: The capacity per pool breakdown * items: - * $ref: '#/components/schemas/PoolCapacity' + * type: object + * properties: + * poolId: + * type: integer + * description: The pool id + * availableCapacity: + * type: array + * items: + * $ref: '#/components/schemas/AvailableCapacity' + * allocatedNxm: + * type: string + * format: integer + * description: The used capacity amount for active covers in this pool + * minAnnualPrice: + * type: string + * description: The minimal annual price for this pool + * maxAnnualPrice: + * type: string + * description: The maximal annual price for this pool * CapacityResult: * allOf: - * - $ref: '#/components/schemas/BaseCapacityFields' - * - type: object - * properties: - * productId: - * type: integer - * description: The product id + * - $ref: '#/components/schemas/ProductCapacity' */ module.exports = router; From c450de5410a1d2544d3181f90911f8d5e6c7c05c Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 11:22:03 +0200 Subject: [PATCH 24/28] refactor: rename function to selectProductsInPool and move to selectors --- src/lib/capacityEngine.js | 17 +---------------- src/store/selectors.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 66496e13..f058f2ef 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -7,7 +7,7 @@ const { calculateFirstUsableTrancheIndex, calculateProductDataForTranche, } = require('./helpers'); -const { selectProduct, selectProductPools } = require('../store/selectors'); +const { selectProduct, selectProductPools, selectProductsInPool } = require('../store/selectors'); const { WeiPerEther, Zero } = ethers.constants; @@ -34,21 +34,6 @@ function getUtilizationRate(capacityAvailableNXM, capacityUsedNXM) { return capacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); } -/** - * 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 the index of the first usable tranche for the maximum cover period. * This is used to determine the maximum price a user would get when buying cover. diff --git a/src/store/selectors.js b/src/store/selectors.js index b2b42f5e..974a3353 100644 --- a/src/store/selectors.js +++ b/src/store/selectors.js @@ -40,10 +40,26 @@ const selectProductPriorityPoolsFixedPrice = (store, productId) => { return productPriorityPoolsFixedPrice[productId]; }; +/** + * 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 selectProductsInPool(store, poolId) { + const { products } = store.getState(); + return Object.keys(products).filter(productId => { + const productPools = selectProductPools(store, productId, poolId); + return productPools?.length > 0; + }); +} + module.exports = { selectAssetRate, selectAsset, selectProduct, selectProductPools, selectProductPriorityPoolsFixedPrice, + selectProductsInPool, }; From 653c5b11c3e71a395292e2db7d2933f71ea94be3 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 11:23:34 +0200 Subject: [PATCH 25/28] refactor: clean up capacityEngine --- src/lib/capacityEngine.js | 87 +++++++++++++++------------------------ 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index f058f2ef..0a4e9e64 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -1,6 +1,6 @@ const { ethers, BigNumber } = require('ethers'); -const { MAX_COVER_PERIOD, SECONDS_PER_DAY } = require('./constants'); +const { MAX_COVER_PERIOD } = require('./constants'); const { bnMax, calculateTrancheId, @@ -13,27 +13,6 @@ const { WeiPerEther, Zero } = ethers.constants; const BASIS_POINTS = 10000; -/** - * Calculates the utilization rate of the capacity. - * - * @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(capacityAvailableNXM, capacityUsedNXM) { - if (!capacityAvailableNXM || !capacityUsedNXM) { - return undefined; - } - - const totalCapacity = capacityAvailableNXM.add(capacityUsedNXM); - if (totalCapacity.isZero()) { - return BigNumber.from(0); - } - - return capacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); -} - /** * Calculates the index of the first usable tranche for the maximum cover period. * This is used to determine the maximum price a user would get when buying cover. @@ -42,10 +21,10 @@ function getUtilizationRate(capacityAvailableNXM, capacityUsedNXM) { * @param {BigNumber} gracePeriod - The product's grace period in seconds. * @returns {number} The index difference between the first usable tranche for max period and the first active tranche. */ -function calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod) { +function calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod) { const firstActiveTrancheId = calculateTrancheId(now); - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - return firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + return firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; } /** @@ -65,7 +44,12 @@ function calculatePoolUtilizationRate(products) { totalCapacityUsedNXM = totalCapacityUsedNXM.add(product.usedCapacity); }); - return getUtilizationRate(totalCapacityAvailableNXM, totalCapacityUsedNXM); + const totalCapacity = totalCapacityAvailableNXM.add(totalCapacityUsedNXM); + if (totalCapacity.isZero()) { + return BigNumber.from(0); + } + + return totalCapacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); } /** @@ -74,15 +58,15 @@ function calculatePoolUtilizationRate(products) { function calculateProductCapacity( store, productId, - { poolId = null, periodSeconds, withPools = false, now, assets, assetRates }, + { poolId = null, period, now, assets, assetRates, withPools = true }, ) { const product = selectProduct(store, productId); if (!product) { return null; } - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, periodSeconds); - const firstUsableTrancheForMaxPeriodIndex = calculateFirstUsableTrancheForMaxPeriodIndex(now, product.gracePeriod); + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, period); + const firstUsableTrancheForMaxPeriodIndex = calculateFirstUsableTrancheIndexForMaxPeriod(now, product.gracePeriod); // Use productPools from poolId if available; otherwise, select all pools for productId const productPools = selectProductPools(store, productId, poolId); @@ -160,16 +144,15 @@ function calculateProductCapacity( * GET /capacity * * @param {Object} store - The Redux store containing application state. - * @param {Object} [options={}] - Optional parameters. - * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @param {number} period - The coverage period in seconds. * @returns {Array} Array of product capacity data. */ -function getAllProductCapacities(store, { periodSeconds = SECONDS_PER_DAY.mul(30) } = {}) { +function getAllProductCapacities(store, period) { const { assets, assetRates, products } = store.getState(); const now = BigNumber.from(Date.now()).div(1000); return Object.keys(products) - .map(productId => calculateProductCapacity(store, productId, { periodSeconds, now, assets, assetRates })) + .map(productId => calculateProductCapacity(store, productId, { period, now, assets, assetRates, withPools: false })) .filter(Boolean); // remove any nulls (i.e. productId did not match any products) } @@ -179,18 +162,15 @@ function getAllProductCapacities(store, { periodSeconds = SECONDS_PER_DAY.mul(30 * * @param {Object} store - The Redux store containing application state. * @param {string|number} productId - The product ID. - * @param {Object} [options={}] - Optional parameters. - * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. - * @param {boolean} [options.withPools=false] - Include per-pool capacity breakdown. + * @param {number} period - The coverage period in seconds. * @returns {Object|null} Product capacity data or null if product not found. */ -function getProductCapacity(store, productId, { periodSeconds = SECONDS_PER_DAY.mul(30), withPools = false } = {}) { +function getProductCapacity(store, productId, period) { const { assets, assetRates } = store.getState(); const now = BigNumber.from(Date.now()).div(1000); return calculateProductCapacity(store, productId, { - periodSeconds, - withPools, + period, now, assets, assetRates, @@ -203,14 +183,13 @@ function getProductCapacity(store, productId, { periodSeconds = SECONDS_PER_DAY. * * @param {Object} store - The Redux store containing application state. * @param {string|number} poolId - The pool ID. - * @param {Object} [options={}] - Optional parameters. - * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @param {number} period - The coverage period in seconds. * @returns {Object|null} Pool capacity data or null if pool not found. */ -function getPoolCapacity(store, poolId, { periodSeconds = SECONDS_PER_DAY.mul(30) } = {}) { +function getPoolCapacity(store, poolId, period) { const { assets, assetRates } = store.getState(); const now = BigNumber.from(Date.now()).div(1000); - const productIds = getProductsInPool(store, poolId); + const productIds = selectProductsInPool(store, poolId); if (productIds.length === 0) { return null; @@ -220,10 +199,11 @@ function getPoolCapacity(store, poolId, { periodSeconds = SECONDS_PER_DAY.mul(30 .map(productId => calculateProductCapacity(store, productId, { poolId, - periodSeconds, + period, now, assets, assetRates, + withPools: false, }), ) .filter(Boolean); // remove any nulls (i.e. productId did not match any products) @@ -242,21 +222,23 @@ function getPoolCapacity(store, poolId, { periodSeconds = SECONDS_PER_DAY.mul(30 * @param {Object} store - The Redux store containing application state. * @param {string|number} poolId - The pool ID. * @param {string|number} productId - The product ID. - * @param {Object} [options={}] - Optional parameters. - * @param {number} [options.periodSeconds=30*SECONDS_PER_DAY] - The coverage period in seconds. + * @param {number} period - The coverage period in seconds. * @returns {Object|null} Product capacity data for the specific pool or null if not found. */ -function getProductCapacityInPool(store, poolId, productId, { periodSeconds = SECONDS_PER_DAY.mul(30) } = {}) { +function getProductCapacityInPool(store, poolId, productId, period) { const { assets, assetRates } = store.getState(); - const now = BigNumber.from(Date.now()).div(1000); + const now = BigNumber.from(Math.floor(Date.now() / 1000)); - return calculateProductCapacity(store, productId, { + const poolProductCapacity = calculateProductCapacity(store, productId, { poolId, - periodSeconds, + period, now, assets, assetRates, + withPools: false, }); + + return poolProductCapacity; } module.exports = { @@ -267,7 +249,6 @@ module.exports = { // Keep these exports for testing purposes calculateProductCapacity, calculatePoolUtilizationRate, - getUtilizationRate, - calculateFirstUsableTrancheForMaxPeriodIndex, - getProductsInPool, + calculateFirstUsableTrancheIndexForMaxPeriod, + getProductsInPool: selectProductsInPool, }; From fae51c750a79b8763a0c181bf4b6bc6686dd9623 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 11:23:55 +0200 Subject: [PATCH 26/28] test: fix mock store product.gracePeriod values --- test/mocks/store.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/mocks/store.js b/test/mocks/store.js index 9c2710ac..31ea42ad 100644 --- a/test/mocks/store.js +++ b/test/mocks/store.js @@ -453,28 +453,28 @@ const store = { productType: 0, // Protocol Cover capacityReductionRatio: 0, useFixedPrice: false, - gracePeriod: 30, + gracePeriod: 35 * 24 * 60 * 60, id: 0, }, 1: { productType: 0, // Protocol Cover capacityReductionRatio: 0, useFixedPrice: false, - gracePeriod: 30, + gracePeriod: 35 * 24 * 60 * 60, id: 1, }, 2: { productType: 0, // Protocol Cover capacityReductionRatio: 0, useFixedPrice: true, - gracePeriod: 30, + gracePeriod: 35 * 24 * 60 * 60, id: 2, }, 3: { productType: 11, // Bundled Protocol Cover capacityReductionRatio: 0, useFixedPrice: false, - gracePeriod: 3024000, + gracePeriod: 35 * 24 * 60 * 60, isDeprecated: false, id: 3, }, @@ -482,7 +482,7 @@ const store = { productType: 8, // Native protocol cover capacityReductionRatio: 0, useFixedPrice: true, - gracePeriod: 3024000, + gracePeriod: 35 * 24 * 60 * 60, isDeprecated: false, id: 4, }, From dbb46a2e9a7f1636ad913eceee6bad2508524087 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 11:25:29 +0200 Subject: [PATCH 27/28] test: fix and clean up unit tests --- src/lib/capacityEngine.js | 1 - test/unit/capacityEngine.js | 267 +++++++++-------------------------- test/unit/routes/capacity.js | 7 - test/unit/selectors.js | 70 ++++++++- test/unit/utils.js | 4 +- 5 files changed, 140 insertions(+), 209 deletions(-) diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 0a4e9e64..e00304f4 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -250,5 +250,4 @@ module.exports = { calculateProductCapacity, calculatePoolUtilizationRate, calculateFirstUsableTrancheIndexForMaxPeriod, - getProductsInPool: selectProductsInPool, }; diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index 89fb3483..735771ba 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -14,9 +14,7 @@ const { getProductCapacity, getPoolCapacity, getProductCapacityInPool, - getUtilizationRate, - calculateFirstUsableTrancheForMaxPeriodIndex, - getProductsInPool, + calculateFirstUsableTrancheIndexForMaxPeriod, calculatePoolUtilizationRate, calculateProductCapacity, } = require('../../src/lib/capacityEngine'); @@ -36,7 +34,7 @@ const { BigNumber } = ethers; const { parseEther } = ethers.utils; const { Zero, WeiPerEther } = ethers.constants; -const verifyPoolCapacity = (poolCapacity, productId, products, poolProducts, now) => { +const verifyPoolCapacity = (poolCapacity, productId, products, poolProducts, now, assets, assetRates) => { const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; expect(poolProduct).to.not.equal(undefined); @@ -54,13 +52,24 @@ const verifyPoolCapacity = (poolCapacity, productId, products, poolProducts, now firstUsableTrancheIndex, ); - // Check pool-specific capacity + // Check pool-specific NXM capacity const nxmCapacityAmount = poolCapacity.availableCapacity.find(c => c.assetId === 255)?.amount || Zero; expect(nxmCapacityAmount.toString()).to.equal(availableCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); // Check pool-specific used capacity - const poolUsedCapacity = poolProduct.allocations.reduce((sum, alloc) => sum.add(alloc), Zero); - expect(poolCapacity.allocatedNxm.toString()).to.equal(poolUsedCapacity.mul(NXM_PER_ALLOCATION_UNIT).toString()); + const expectedAllocatedNxm = poolProduct.allocations + .reduce((sum, alloc) => sum.add(alloc), Zero) + .mul(NXM_PER_ALLOCATION_UNIT); + expect(poolCapacity.allocatedNxm.toString()).to.equal(expectedAllocatedNxm.toString()); + + // Verify other asset conversions + poolCapacity.availableCapacity + .filter(capacity => capacity.assetId !== 255) + .forEach(capacity => { + expect(capacity.asset).to.deep.equal(assets[capacity.assetId]); + const expectedAmount = nxmCapacityAmount.mul(assetRates[capacity.assetId]).div(WeiPerEther); + expect(capacity.amount.toString()).to.equal(expectedAmount.toString()); + }); }; describe('capacityEngine', function () { @@ -78,30 +87,6 @@ describe('capacityEngine', function () { const { assets, assetRates } = mockStore; const now = getCurrentTimestamp(); - // Add these new verification functions - const verifyCapacityAsset = (capacity, nxmCapacity, assets, assetRates) => { - expect(capacity.asset).to.deep.equal(assets[capacity.assetId]); - - if (capacity.assetId !== 255) { - const expectedAmount = nxmCapacity.mul(assetRates[capacity.assetId]).div(WeiPerEther); - expect(capacity.amount.toString()).to.equal(expectedAmount.toString()); - } - }; - - const verifyPoolCapacityWithAssets = (poolCapacity, productId, poolProducts) => { - const poolProduct = poolProducts[`${productId}_${poolCapacity.poolId}`]; - const nxmCapacity = poolCapacity.availableCapacity.find(c => c.assetId === 255)?.amount || Zero; - - poolCapacity.availableCapacity.forEach(capacity => - verifyCapacityAsset(capacity, nxmCapacity, assets, assetRates), - ); - - const expectedAllocatedNxm = poolProduct.allocations - .reduce((sum, alloc) => sum.add(alloc), Zero) - .mul(NXM_PER_ALLOCATION_UNIT); - expect(poolCapacity.allocatedNxm.toString()).to.equal(expectedAllocatedNxm.toString()); - }; - // Common verification functions const verifyNXMCapacity = (nxmCapacity, expectedAmount) => { const amount = nxmCapacity?.amount || Zero; @@ -124,7 +109,7 @@ describe('capacityEngine', function () { const { poolProducts } = store.getState(); const response = calculateProductCapacity(store, productId, { - periodSeconds: SECONDS_PER_DAY.mul(30), + period: SECONDS_PER_DAY.mul(30), now, assets, assetRates, @@ -181,17 +166,17 @@ describe('capacityEngine', function () { }); it('should handle non-fixed price products correctly', function () { - const { poolProducts } = store.getState(); + const { poolProducts, products } = store.getState(); + const productId = '0'; const productPool = [mockStore.poolProducts['0_1']]; const [{ allocations, trancheCapacities, targetPrice, bumpedPrice, bumpedPriceUpdateTime }] = productPool; const lastIndex = allocations.length - 1; - const response = calculateProductCapacity(store, '0', { - periodSeconds: SECONDS_PER_DAY.mul(30), + const response = calculateProductCapacity(store, productId, { + period: SECONDS_PER_DAY.mul(30), now, assets, assetRates, - withPools: true, }); // Calculate expected values @@ -216,24 +201,23 @@ describe('capacityEngine', function () { expect(response.minAnnualPrice.toString()).to.not.equal(response.maxAnnualPrice.toString()); expect(response.minAnnualPrice.lt(response.maxAnnualPrice)).to.equal(true); - response.capacityPerPool.forEach(poolCapacity => verifyPoolCapacityWithAssets(poolCapacity, '0', poolProducts)); + response.capacityPerPool.forEach(poolCapacity => + verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now, assets, assetRates), + ); }); - it('should include capacityPerPool when withPools is true', function () { + it('should not include capacityPerPool when withPools is false', function () { const now = getCurrentTimestamp(); const productId = '0'; - const { products, poolProducts } = store.getState(); const response = calculateProductCapacity(store, productId, { - periodSeconds: SECONDS_PER_DAY.mul(30), - withPools: true, + period: SECONDS_PER_DAY.mul(30), now, assets, assetRates, + withPools: false, }); - response.capacityPerPool.forEach(poolCapacity => - verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now), - ); + expect(response.capacityPerPool).to.be.equal(undefined); }); it('should filter by poolId when provided', function () { @@ -242,7 +226,7 @@ describe('capacityEngine', function () { const now = getCurrentTimestamp(); const response = calculateProductCapacity(store, productId, { poolId, - periodSeconds: SECONDS_PER_DAY.mul(30), + period: SECONDS_PER_DAY.mul(30), now, assets: mockStore.assets, assetRates: mockStore.assetRates, @@ -263,7 +247,7 @@ describe('capacityEngine', function () { const nonExistingProductId = '999'; const now = getCurrentTimestamp(); const response = calculateProductCapacity(store, nonExistingProductId, { - periodSeconds: SECONDS_PER_DAY.mul(30), + period: SECONDS_PER_DAY.mul(30), now, assets: mockStore.assets, assetRates: mockStore.assetRates, @@ -292,7 +276,7 @@ describe('capacityEngine', function () { }; const response = calculateProductCapacity(zeroCapacityStore, productId, { - periodSeconds: SECONDS_PER_DAY.mul(30), + period: SECONDS_PER_DAY.mul(30), now, assets, assetRates, @@ -307,7 +291,7 @@ describe('capacityEngine', function () { const productId = '1'; // Non-fixed price product const now = getCurrentTimestamp(); const response = calculateProductCapacity(store, productId, { - periodSeconds: SECONDS_PER_DAY.mul(7), // 1 week + period: SECONDS_PER_DAY.mul(7), // 1 week now, assets: mockStore.assets, assetRates: mockStore.assetRates, @@ -399,8 +383,10 @@ describe('capacityEngine', function () { expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); }; + it('should return capacity for all products across all pools', function () { - const response = getAllProductCapacities(store); + const period = SECONDS_PER_DAY.mul(30); + const response = getAllProductCapacities(store, period); const { products, productPoolIds, poolProducts, assets } = store.getState(); // Should return all products from store @@ -432,11 +418,19 @@ describe('capacityEngine', function () { }); describe('getProductCapacity', function () { + it('should handle invalid period seconds gracefully getProductCapacity', function () { + const invalidPeriod = Zero; + const response = getProductCapacity(store, '0', invalidPeriod); + expect(response).to.not.equal(null); + }); + it('should return detailed capacity for a single product', function () { const productId = 3; - const response = getProductCapacity(store, productId); + const now = getCurrentTimestamp(); + const period = SECONDS_PER_DAY.mul(30); + const response = getProductCapacity(store, productId, period); - const { productPoolIds, poolProducts } = store.getState(); + const { assets, assetRates, productPoolIds, poolProducts, products } = store.getState(); const poolIds = productPoolIds[productId]; // Check basic product info @@ -460,18 +454,11 @@ describe('capacityEngine', function () { }); expect(response.usedCapacity.toString()).to.equal(totalUsedCapacity.toString()); - }); - - it('should include detailed pool breakdown when withPools is true', function () { - const now = getCurrentTimestamp(); - const productId = '3'; - const response = getProductCapacity(store, productId, { withPools: true }); - const { productPoolIds, poolProducts, products } = store.getState(); expect(response.capacityPerPool).to.have.lengthOf(productPoolIds[productId].length); response.capacityPerPool.forEach(poolCapacity => - verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now), + verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now, assets, assetRates), ); }); }); @@ -479,7 +466,8 @@ describe('capacityEngine', function () { describe('getPoolCapacity', function () { it('should return detailed pool capacity with correct utilization rate', function () { const poolId = 4; - const response = getPoolCapacity(store, poolId); + const period = SECONDS_PER_DAY.mul(30); + const response = getPoolCapacity(store, poolId, period); const { poolProducts, products } = store.getState(); const now = getCurrentTimestamp(); @@ -536,7 +524,8 @@ describe('capacityEngine', function () { it('should return detailed capacity for a specific product in a specific pool', function () { const poolId = 4; const productId = '3'; - const response = getProductCapacityInPool(store, poolId, productId); + const period = SECONDS_PER_DAY.mul(30); + const response = getProductCapacityInPool(store, poolId, productId, period); verifyCapacityResponse(response); @@ -552,121 +541,17 @@ describe('capacityEngine', function () { }); }); - describe('getUtilizationRate', function () { - it('should calculate utilization rate correctly', function () { - const availableNXM = parseEther('100'); - const usedNXM = parseEther('50'); - // Expected: (50 / (100 + 50)) * 10000 = 3333 basis points - const rate = getUtilizationRate(availableNXM, usedNXM); - expect(rate.toNumber()).to.equal(3333); - }); - - it('should return 0 when no capacity is used', function () { - const availableNXM = parseEther('100'); - const usedNXM = Zero; - const rate = getUtilizationRate(availableNXM, usedNXM); - expect(rate.toNumber()).to.equal(0); - }); - - it('should return 0 when total capacity is zero', function () { - const availableNXM = Zero; - const usedNXM = Zero; - const rate = getUtilizationRate(availableNXM, usedNXM); - expect(rate.toNumber()).to.equal(0); - }); - - it('should return undefined when inputs are missing', function () { - expect(getUtilizationRate(null, parseEther('50'))).to.equal(undefined); - expect(getUtilizationRate(parseEther('100'), null)).to.equal(undefined); - expect(getUtilizationRate(null, null)).to.equal(undefined); - }); - - it('should handle very large numbers correctly', function () { - const largeNumber = parseEther('1000000'); // 1M ETH - const rate = getUtilizationRate(largeNumber, largeNumber); - expect(rate.toNumber()).to.equal(5000); // Should be 50% - }); - - it('should handle very small numbers correctly', function () { - const smallNumber = BigNumber.from(1); - const rate = getUtilizationRate(smallNumber, smallNumber); - expect(rate.toNumber()).to.equal(5000); // Should be 50% - }); - }); - - describe('getProductsInPool', function () { - it('should return all products in a specific pool', function () { - const poolId = 1; - const { productPoolIds } = store.getState(); - const products = getProductsInPool(store, poolId); - - // Check against mock store data - const expectedProducts = Object.keys(productPoolIds).filter(productId => - productPoolIds[productId].includes(poolId), - ); - - expect(products).to.have.members(expectedProducts); - expect(products).to.have.lengthOf(expectedProducts.length); - }); - - it('should return empty array for pool with no products', function () { - const nonExistentPoolId = 999; - const products = getProductsInPool(store, nonExistentPoolId); - expect(products).to.be.an('array'); - expect(products).to.have.lengthOf(0); - }); - - it('should handle string pool ids', function () { - const poolId = '1'; - const { productPoolIds } = store.getState(); - const products = getProductsInPool(store, poolId); - - const expectedProducts = Object.keys(productPoolIds).filter(productId => - productPoolIds[productId].includes(Number(poolId)), - ); - - expect(products).to.have.members(expectedProducts); - }); - - it('should handle invalid pool id', function () { - const products = getProductsInPool(store, -1); - expect(products).to.be.an('array'); - expect(products).to.have.lengthOf(0); - }); - - it('should handle string vs number pool ids consistently', function () { - const numericResult = getProductsInPool(store, 1); - const stringResult = getProductsInPool(store, '1'); - expect(numericResult).to.deep.equal(stringResult); - }); - - it('should handle undefined productPools', function () { - const emptyStore = { - getState: () => ({ - products: { 1: {}, 2: {}, 3: {} }, - productPoolIds: {}, - poolProducts: {}, - }), - }; - - const poolId = 2; - const result = getProductsInPool(emptyStore, poolId); - - expect(result).to.deep.equal([]); - }); - }); - - describe('calculateFirstUsableTrancheForMaxPeriodIndex', function () { + describe('calculateFirstUsableTrancheIndexForMaxPeriod', function () { it('should calculate correct tranche index for max period', function () { - const now = BigNumber.from(1678700054); // From mock store - const gracePeriod = BigNumber.from(30); // 30 seconds grace period + const now = getCurrentTimestamp(); + const gracePeriod = SECONDS_PER_DAY.mul(35); - const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); + const result = calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod); // Calculate expected result const firstActiveTrancheId = calculateTrancheId(now); - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - const expected = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; expect(result).to.equal(expected); }); @@ -675,24 +560,24 @@ describe('capacityEngine', function () { const now = BigNumber.from(1678700054); const gracePeriod = Zero; - const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); + const result = calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod); const firstActiveTrancheId = calculateTrancheId(now); - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - const expected = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; expect(result).to.equal(expected); }); it('should handle large grace period', function () { - const now = BigNumber.from(1678700054); - const gracePeriod = BigNumber.from(3024000); // Large grace period from mock store + const now = getCurrentTimestamp(); + const gracePeriod = SECONDS_PER_DAY.mul(365); - const result = calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod); + const result = calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod); const firstActiveTrancheId = calculateTrancheId(now); - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - const expected = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; expect(result).to.equal(expected); }); @@ -786,32 +671,18 @@ describe('capacityEngine', function () { }); describe('API Services', function () { - it('should handle custom period seconds in getAllProductCapacities', function () { - const response = getAllProductCapacities(store, { - periodSeconds: SECONDS_PER_DAY.mul(7), // 1 week - }); - expect(response).to.be.an('array'); - expect(response.length).to.be.greaterThan(0); - }); - - it('should handle invalid period seconds gracefully', function () { - const response = getProductCapacity(store, '0', { - periodSeconds: Zero, - }); - expect(response).to.not.equal(null); - }); - it('should return consistent data structure across all capacity endpoints', function () { const poolId = '1'; const productId = '0'; const { assets, assetRates, poolProducts: storePoolProducts, products, productPoolIds } = store.getState(); const now = getCurrentTimestamp(); + const period = SECONDS_PER_DAY.mul(30); // Get responses from all endpoints - const allProducts = getAllProductCapacities(store); - const singleProduct = getProductCapacity(store, productId); - const poolCapacityResponse = getPoolCapacity(store, poolId); - const poolProduct = getProductCapacityInPool(store, poolId, productId); + const allProducts = getAllProductCapacities(store, period); + const singleProduct = getProductCapacity(store, productId, period); + const poolCapacityResponse = getPoolCapacity(store, poolId, period); + const poolProduct = getProductCapacityInPool(store, poolId, productId, period); // Helper to verify product capacity structure const verifyProductCapacity = ( diff --git a/test/unit/routes/capacity.js b/test/unit/routes/capacity.js index deb3538e..541a1904 100644 --- a/test/unit/routes/capacity.js +++ b/test/unit/routes/capacity.js @@ -39,13 +39,6 @@ describe('Capacity Routes', () => { 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[productId]); - }); - - it('should have capacityPerPool field if queryParam withPools=true', async function () { - const productId = 0; - const url = `/v2/capacity/${productId}?withPools=true`; - const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); const expectedCapacity = capacities[productId]; expectedCapacity.capacityPerPool = productCapacityPerPools[productId]; diff --git a/test/unit/selectors.js b/test/unit/selectors.js index d1ab6c6a..3ad7fdd5 100644 --- a/test/unit/selectors.js +++ b/test/unit/selectors.js @@ -1,6 +1,6 @@ const { expect } = require('chai'); -const { selectProductPools } = require('../../src/store/selectors'); +const { selectProductPools, selectProductsInPool } = require('../../src/store/selectors'); const mockStore = require('../mocks/store'); describe('selectProductPools', function () { @@ -51,3 +51,71 @@ describe('selectProductPools', function () { expect(result.map(pool => pool.poolId)).to.deep.equal(expectedPoolIds); }); }); + +describe('selectProductsInPool', function () { + let store; + + before(function () { + store = { getState: () => mockStore }; + }); + + it('should return all products in a specific pool', function () { + const poolId = 1; + const { productPoolIds } = store.getState(); + const products = selectProductsInPool(store, poolId); + + // Check against mock store data + const expectedProducts = Object.keys(productPoolIds).filter(productId => + productPoolIds[productId].includes(poolId), + ); + + expect(products).to.have.members(expectedProducts); + expect(products).to.have.lengthOf(expectedProducts.length); + }); + + it('should return empty array for pool with no products', function () { + const nonExistentPoolId = 999; + const products = selectProductsInPool(store, nonExistentPoolId); + expect(products).to.be.an('array'); + expect(products).to.have.lengthOf(0); + }); + + it('should handle string pool ids', function () { + const poolId = '1'; + const { productPoolIds } = store.getState(); + const products = selectProductsInPool(store, poolId); + + const expectedProducts = Object.keys(productPoolIds).filter(productId => + productPoolIds[productId].includes(Number(poolId)), + ); + + expect(products).to.have.members(expectedProducts); + }); + + it('should handle invalid pool id', function () { + const products = selectProductsInPool(store, -1); + expect(products).to.be.an('array'); + expect(products).to.have.lengthOf(0); + }); + + it('should handle string vs number pool ids consistently', function () { + const numericResult = selectProductsInPool(store, 1); + const stringResult = selectProductsInPool(store, '1'); + expect(numericResult).to.deep.equal(stringResult); + }); + + it('should handle undefined productPools', function () { + const emptyStore = { + getState: () => ({ + products: { 1: {}, 2: {}, 3: {} }, + productPoolIds: {}, + poolProducts: {}, + }), + }; + + const poolId = 2; + const result = selectProductsInPool(emptyStore, poolId); + + expect(result).to.deep.equal([]); + }); +}); diff --git a/test/unit/utils.js b/test/unit/utils.js index da54d9b0..a3d55fff 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -7,8 +7,8 @@ const { calculateFirstUsableTrancheIndex, calculateAvailableCapacity } = require const getCurrentTimestamp = () => BigNumber.from(Math.floor(Date.now() / 1000)); -const verifyCapacityCalculation = (response, poolProduct, storeProduct, now, periodSeconds) => { - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, storeProduct.gracePeriod, periodSeconds); +const verifyCapacityCalculation = (response, poolProduct, storeProduct, now, period) => { + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, storeProduct.gracePeriod, period); const availableCapacity = calculateAvailableCapacity( poolProduct.trancheCapacities, From 4f21e6d2df5dc6640eb1facd0e22437d24007227 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 3 Dec 2024 13:13:06 +0200 Subject: [PATCH 28/28] docs: fix and update @openapi docs --- src/routes/capacity.js | 139 +++++++++++++++++++++-------------------- 1 file changed, 72 insertions(+), 67 deletions(-) diff --git a/src/routes/capacity.js b/src/routes/capacity.js index f873d1c3..c8b6b12e 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -43,6 +43,15 @@ const formatCapacityResult = capacity => ({ * tags: * - Capacity * description: Get capacity data for all products + * parameters: + * - in: query + * name: period + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: Coverage period in days * responses: * 200: * description: Returns capacity for all products @@ -90,19 +99,27 @@ router.get( * - Capacity * description: Get capacity data for a product * parameters: - * - in: path - * name: productId - * required: true - * schema: - * type: integer - * description: The product id + * - in: path + * name: productId + * required: true + * schema: + * type: integer + * description: The product id + * - in: query + * name: period + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: Coverage period in days * responses: * 200: * description: Returns capacity data for a product, including capacityPerPool data. * content: * application/json: * schema: - * $ref: '#/components/schemas/CapacityResult' + * $ref: '#/components/schemas/CapacityResultWithPools' * example: * productId: 1 * availableCapacity: [ @@ -185,21 +202,20 @@ router.get( * - Capacity * description: Gets capacity data for a pool, including all its products * 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 + * - in: path + * name: poolId + * required: true + * schema: + * type: integer + * description: The pool id + * - in: query + * name: period + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: Coverage period in days * responses: * 200: * description: Returns capacity for all products in the specified pool @@ -217,7 +233,7 @@ router.get( * productsCapacity: * type: array * items: - * $ref: '#/components/schemas/ProductCapacity' + * $ref: '#/components/schemas/CapacityResult' * example: * poolId: 1 * utilizationRate: 5000 @@ -292,27 +308,26 @@ router.get( * - 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 + * - 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 + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: Coverage period in days * responses: * 200: * description: Returns capacity for the specified product in the specified pool @@ -411,7 +426,7 @@ router.get( * maxAnnualPrice: * type: string * description: The maximal annual price is a percentage value between 0-1. - * ProductCapacity: + * CapacityResult: * allOf: * - $ref: '#/components/schemas/BaseCapacityFields' * - type: object @@ -419,32 +434,22 @@ router.get( * productId: * type: integer * description: The product id + * CapacityResultWithPools: + * allOf: + * - $ref: '#/components/schemas/CapacityResult' + * - type: object + * properties: * capacityPerPool: * type: array * description: The capacity per pool breakdown * items: - * type: object - * properties: - * poolId: - * type: integer - * description: The pool id - * availableCapacity: - * type: array - * items: - * $ref: '#/components/schemas/AvailableCapacity' - * allocatedNxm: - * type: string - * format: integer - * description: The used capacity amount for active covers in this pool - * minAnnualPrice: - * type: string - * description: The minimal annual price for this pool - * maxAnnualPrice: - * type: string - * description: The maximal annual price for this pool - * CapacityResult: - * allOf: - * - $ref: '#/components/schemas/ProductCapacity' + * allOf: + * - $ref: '#/components/schemas/BaseCapacityFields' + * - type: object + * properties: + * poolId: + * type: integer + * description: The pool id */ module.exports = router;