diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 3e5d4be4..e00304f4 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -1,186 +1,253 @@ const { ethers, BigNumber } = require('ethers'); -const { MAX_COVER_PERIOD, SECONDS_PER_DAY } = require('./constants'); +const { MAX_COVER_PERIOD } = require('./constants'); const { bnMax, calculateTrancheId, calculateFirstUsableTrancheIndex, calculateProductDataForTranche, } = require('./helpers'); -const { selectProduct, selectProductPools } = require('../store/selectors'); +const { selectProduct, selectProductPools, selectProductsInPool } = require('../store/selectors'); const { WeiPerEther, Zero } = ethers.constants; const BASIS_POINTS = 10000; /** - * Calculates the utilization rate of the capacity. + * 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. * - * @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. + * @param {BigNumber} now - The current timestamp in seconds. + * @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 getUtilizationRate(capacityAvailableNXM, capacityUsedNXM) { - if (!capacityAvailableNXM || !capacityUsedNXM) { - return undefined; - } +function calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod) { + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + return firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; +} + +/** + * Calculates the pool-level utilization rate across all products in the pool. + * + * @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); + }); - const totalCapacity = capacityAvailableNXM.add(capacityUsedNXM); + const totalCapacity = totalCapacityAvailableNXM.add(totalCapacityUsedNXM); if (totalCapacity.isZero()) { return BigNumber.from(0); } - return capacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); + return totalCapacityUsedNXM.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. + * Helper function to calculate capacity for a single product. */ -function getProductsInPool(store, poolId) { - const { products } = store.getState(); - return Object.keys(products).filter(productId => { - const productPools = selectProductPools(store, productId, poolId); - return productPools?.length > 0; - }); +function calculateProductCapacity( + store, + productId, + { poolId = null, period, now, assets, assetRates, withPools = true }, +) { + const product = selectProduct(store, productId); + if (!product) { + return null; + } + + 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); + + 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, + 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); + } + } + + 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 capacityData; } +/* API SERVICES */ + /** - * 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. + * Gets capacity data for all products. + * GET /capacity * - * @param {BigNumber} now - The current timestamp in seconds. - * @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. + * @param {Object} store - The Redux store containing application state. + * @param {number} period - The coverage period in seconds. + * @returns {Array} Array of product capacity data. */ -function calculateFirstUsableTrancheForMaxPeriodIndex(now, gracePeriod) { - const firstActiveTrancheId = calculateTrancheId(now); - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); - return firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; +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, { period, now, assets, assetRates, withPools: false })) + .filter(Boolean); // remove any nulls (i.e. productId did not match any products) } /** - * Calculates the capacity and pricing information for products and pools. + * Gets capacity data for a single product across all pools. + * GET /capacity/:productId * * @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 {string|number} productId - The product ID. + * @param {number} period - The coverage period in seconds. + * @returns {Object|null} Product capacity data or null if product not found. */ -function capacityEngine( - store, - { poolId = null, productIds = [], periodSeconds = SECONDS_PER_DAY.mul(30), withPools = false } = {}, -) { - const { assets, assetRates, products } = store.getState(); +function getProductCapacity(store, productId, period) { + const { assets, assetRates } = store.getState(); const now = BigNumber.from(Date.now()).div(1000); - const capacities = []; - - let productIdsToProcess; - if (productIds.length > 0) { - productIdsToProcess = [...productIds]; - } else if (poolId !== null) { - // If only poolId is provided, get all products in that pool - productIdsToProcess = getProductsInPool(store, poolId); - } else { - // If neither productIds nor poolId is provided, process all products - productIdsToProcess = Object.keys(products); - } - - for (const productId of productIdsToProcess) { - const product = selectProduct(store, productId); - if (!product) { - continue; - } - - const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, product.gracePeriod, periodSeconds); - const firstUsableTrancheForMaxPeriodIndex = calculateFirstUsableTrancheForMaxPeriodIndex(now, product.gracePeriod); + return calculateProductCapacity(store, productId, { + period, + now, + assets, + assetRates, + }); +} - // Use productPools from poolId if available; otherwise, select all pools for productId - const productPools = selectProductPools(store, productId, poolId); +/** + * 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 {number} period - The coverage period in seconds. + * @returns {Object|null} Pool capacity data or null if pool not found. + */ +function getPoolCapacity(store, poolId, period) { + const { assets, assetRates } = store.getState(); + const now = BigNumber.from(Date.now()).div(1000); + const productIds = selectProductsInPool(store, poolId); - let aggregatedData = {}; - let capacityPerPool = []; - let maxAnnualPrice = Zero; + if (productIds.length === 0) { + return null; + } - if (product.useFixedPrice) { - // Fixed Price - ({ aggregatedData, capacityPerPool } = calculateProductDataForTranche( - productPools, - firstUsableTrancheIndex, - true, + const productsCapacity = productIds + .map(productId => + calculateProductCapacity(store, productId, { + poolId, + period, 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); - } - } - - 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; - } + withPools: false, + }), + ) + .filter(Boolean); // remove any nulls (i.e. productId did not match any products) + + return { + poolId: Number(poolId), + utilizationRate: calculatePoolUtilizationRate(productsCapacity), + productsCapacity, + }; +} - capacities.push(capacityData); - } +/** + * 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 {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, period) { + const { assets, assetRates } = store.getState(); + const now = BigNumber.from(Math.floor(Date.now() / 1000)); + + const poolProductCapacity = calculateProductCapacity(store, productId, { + poolId, + period, + now, + assets, + assetRates, + withPools: false, + }); - return capacities; + return poolProductCapacity; } module.exports = { - getUtilizationRate, - calculateFirstUsableTrancheForMaxPeriodIndex, - getProductsInPool, - capacityEngine, + getAllProductCapacities, + getProductCapacity, + getPoolCapacity, + getProductCapacityInPool, + // Keep these exports for testing purposes + calculateProductCapacity, + calculatePoolUtilizationRate, + calculateFirstUsableTrancheIndexForMaxPeriod, }; 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)); 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 6a8e8548..c8b6b12e 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 => ({ @@ -39,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 @@ -63,9 +76,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 = capacityEngine(store, { periodSeconds }); + const capacities = getAllProductCapacities(store, period); const response = capacities.map(capacity => formatCapacityResult(capacity)); console.log(JSON.stringify(capacities, null, 2)); @@ -86,86 +99,62 @@ 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: query - * name: withPools - * required: false - * schema: - * type: boolean - * default: false - * description: When true, includes `capacityPerPool` field in the response + * - 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. 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/CapacityResultWithPools' + * 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: @@ -177,7 +166,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 }); @@ -187,9 +175,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] = capacityEngine(store, { productIds: [productId], periodSeconds, withPools }); + const capacity = getProductCapacity(store, productId, period); if (!capacity) { return res.status(400).send({ error: 'Invalid Product Id', response: null }); @@ -212,32 +200,62 @@ 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 - * 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 * 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/CapacityResult' + * example: + * poolId: 1 + * utilizationRate: 5000 + * productsCapacity: [ + * { + * 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: @@ -259,15 +277,19 @@ 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 = capacityEngine(store, { poolId, periodSeconds }); + const poolCapacity = getPoolCapacity(store, poolId, period); - 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); @@ -286,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 @@ -338,9 +359,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] = capacityEngine(store, { poolId, productIds: [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 }); @@ -405,14 +426,6 @@ 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: * allOf: * - $ref: '#/components/schemas/BaseCapacityFields' @@ -421,10 +434,6 @@ 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). * CapacityResultWithPools: * allOf: * - $ref: '#/components/schemas/CapacityResult' @@ -432,9 +441,15 @@ router.get( * properties: * capacityPerPool: * type: array - * description: The capacity per pool. + * description: The capacity per pool breakdown * items: - * $ref: '#/components/schemas/PoolCapacity' + * allOf: + * - $ref: '#/components/schemas/BaseCapacityFields' + * - type: object + * properties: + * poolId: + * type: integer + * description: The pool id */ module.exports = router; 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; diff --git a/src/store/selectors.js b/src/store/selectors.js index 00270748..974a3353 100644 --- a/src/store/selectors.js +++ b/src/store/selectors.js @@ -21,9 +21,9 @@ const selectProductPools = (store, productId, poolId = null) => { const { poolProducts, productPoolIds } = store.getState(); const poolIds = productPoolIds[productId] || []; - if (poolId) { + if (poolId !== null && poolId !== undefined) { const key = `${productId}_${poolId}`; - return poolIds.includes(poolId) ? [poolProducts[key]] : []; + return poolIds.includes(Number(poolId)) ? [poolProducts[key]] : []; } // List of product data across all pools @@ -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, }; diff --git a/test/mocks/store.js b/test/mocks/store.js index 25fa4da5..31ea42ad 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), }, }, @@ -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, }, diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index d7dfa9a0..735771ba 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -2,362 +2,821 @@ const { expect } = require('chai'); const ethers = require('ethers'); const sinon = require('sinon'); -const { capacities, poolProductCapacities } = require('./responses'); +const { poolProductCapacities } = require('./responses'); const { - capacityEngine, - getUtilizationRate, - calculateFirstUsableTrancheForMaxPeriodIndex, - getProductsInPool, + calculateExpectedUsedCapacity, + getCurrentTimestamp, + verifyPriceCalculations, + verifyCapacityResponse, +} = require('./utils'); +const { + getAllProductCapacities, + getProductCapacity, + getPoolCapacity, + getProductCapacityInPool, + calculateFirstUsableTrancheIndexForMaxPeriod, + 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 { + calculateAvailableCapacity, + calculateBasePrice, + calculateFirstUsableTrancheIndex, + calculateFixedPricePremiumPerYear, + calculatePremiumPerYear, + calculateProductDataForTranche, + calculateTrancheId, +} = require('../../src/lib/helpers'); const mockStore = require('../mocks/store'); const { BigNumber } = ethers; const { parseEther } = ethers.utils; -const { Zero } = ethers.constants; - -describe('Capacity Engine tests', function () { - describe('capacityEngine', function () { - const store = { getState: () => null }; - - beforeEach(function () { - sinon.stub(store, 'getState').callsFake(() => mockStore); +const { Zero, WeiPerEther } = ethers.constants; + +const verifyPoolCapacity = (poolCapacity, productId, products, poolProducts, now, assets, assetRates) => { + 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 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 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()); }); +}; - afterEach(function () { - sinon.restore(); - }); +describe('capacityEngine', function () { + const store = { getState: () => null }; - it('should return capacity for all products when no productIds or poolId are provided', function () { - const response = capacityEngine(store, { period: 30 }); + beforeEach(function () { + sinon.stub(store, 'getState').callsFake(() => mockStore); + }); - expect(response).to.have.lengthOf(Object.keys(mockStore.products).length); + afterEach(function () { + sinon.restore(); + }); - response.forEach((product, i) => { - expect(product.productId).to.be.equal(capacities[i].productId); - expect(product.utilizationRate.toNumber()).to.be.equal(capacities[i].utilizationRate); + describe('calculateProductCapacity', function () { + const { assets, assetRates } = mockStore; + const now = getCurrentTimestamp(); + + // 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 { poolProducts } = 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)); - }); + const response = calculateProductCapacity(store, productId, { + period: SECONDS_PER_DAY.mul(30), + now, + assets, + assetRates, }); - }); - it('should return capacity for 1 product across all pools if productId is provided and poolId is not', function () { - const productId = '0'; - const [product] = capacityEngine(store, { productIds: [productId] }); - - const expectedCapacity = capacities[Number(productId)]; + expect(response.productId).to.equal(0); + expect(response.availableCapacity).to.be.an('array'); - expect(product.productId).to.be.equal(expectedCapacity.productId); - expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); + const pool1Capacity = calculatePoolCapacity(poolProducts['0_1']); + const pool2Capacity = calculatePoolCapacity(poolProducts['0_2']); + const totalCapacity = pool1Capacity.add(pool2Capacity); - 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)); - }); + const nxmCapacity = response.availableCapacity.find(c => c.assetId === 255); + verifyNXMCapacity(nxmCapacity, totalCapacity); }); - it('should return undefined for non-existing product', function () { - const nonExistingProductId = '999'; - const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); - expect(product).to.be.equal(undefined); + it('should handle fixed price products correctly', function () { + 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, + 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(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 return capacity for a specific product and pool if both productId and poolId are provided', function () { + it('should handle non-fixed price products correctly', function () { + const { poolProducts, products } = store.getState(); const productId = '0'; - const poolId = 2; - const [product] = capacityEngine(store, { poolId, productIds: [productId] }); + const productPool = [mockStore.poolProducts['0_1']]; + const [{ allocations, trancheCapacities, targetPrice, bumpedPrice, bumpedPriceUpdateTime }] = productPool; + const lastIndex = allocations.length - 1; + + const response = calculateProductCapacity(store, productId, { + period: SECONDS_PER_DAY.mul(30), + now, + assets, + assetRates, + }); - const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === Number(productId)); + // 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); - 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); + // Calculate base price + const basePrice = calculateBasePrice(targetPrice, bumpedPrice, bumpedPriceUpdateTime, now); - 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)); - }); - }); + // 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); - 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 }); + // Calculate expected max annual price + const maxPremiumPerYear = calculatePremiumPerYear(availableInNXM, basePrice, used, total); + const expectedMaxPrice = availableInNXM.isZero() ? Zero : WeiPerEther.mul(maxPremiumPerYear).div(availableInNXM); - expect(response.length).to.be.greaterThan(0); + 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.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)); - }); + response.capacityPerPool.forEach(poolCapacity => + verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now, assets, assetRates), + ); + }); + + it('should not include capacityPerPool when withPools is false', function () { + const now = getCurrentTimestamp(); + const productId = '0'; + const response = calculateProductCapacity(store, productId, { + period: SECONDS_PER_DAY.mul(30), + now, + assets, + assetRates, + withPools: false, }); + + expect(response.capacityPerPool).to.be.equal(undefined); }); - it('should return the same total capacity for a product across all pools as when poolId is not given', function () { + it('should filter by poolId when provided', function () { const productId = '0'; - const poolIds = mockStore.productPoolIds[productId]; + const poolId = 2; + const now = getCurrentTimestamp(); + const response = calculateProductCapacity(store, productId, { + poolId, + period: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); - // Get capacity for product 0 across all pools - const [allPoolsProduct] = capacityEngine(store, { productIds: [productId] }); + const { availableCapacity } = poolProductCapacities[poolId].productsCapacity.find( + p => p.productId === Number(productId), + ); + const expectedAvailableCapacity = availableCapacity.map(cap => ({ + ...cap, + amount: BigNumber.from(cap.amount), + })); - const initObject = { - productId: Number(productId), - usedCapacity: BigNumber.from(0), - minAnnualPrice: BigNumber.from(0), - maxAnnualPrice: BigNumber.from(0), - availableCapacity: [], - }; + expect(response.availableCapacity).to.deep.equal(expectedAvailableCapacity); + }); - // 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] }); + it('should return null for non-existing product', function () { + const nonExistingProductId = '999'; + const now = getCurrentTimestamp(); + const response = calculateProductCapacity(store, nonExistingProductId, { + period: SECONDS_PER_DAY.mul(30), + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); - if (!product) { - return acc; - } + expect(response).to.equal(null); + }); - // 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); - }); + it('should handle zero capacity correctly', function () { + const productId = '0'; + const zeroCapacityStore = { + getState: () => ({ + ...mockStore, + poolProducts: { + '0_1': { + ...mockStore.poolProducts['0_1'], + trancheCapacities: Array(8).fill(Zero), + allocations: Array(8).fill(Zero), + targetPrice: Zero, + bumpedPrice: Zero, + bumpedPriceUpdateTime: Zero, + }, + }, + productPoolIds: { 0: [1] }, // Only pool 1 for product 0 + }), + }; - 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); + const response = calculateProductCapacity(zeroCapacityStore, productId, { + period: SECONDS_PER_DAY.mul(30), + now, + assets, + assetRates, }); + + const nxmCapacity = response.availableCapacity.find(c => c.assetId === 255); + verifyNXMCapacity(nxmCapacity, Zero); + expect(response.maxAnnualPrice.toString()).to.equal('0'); }); - it('should handle products with fixed price correctly', function () { - const fixedPricedProductId = '2'; - const [product] = capacityEngine(store, { productIds: [fixedPricedProductId] }); + it('should calculate capacity across multiple tranches for non-fixed price products', function () { + const productId = '1'; // Non-fixed price product + const now = getCurrentTimestamp(); + const response = calculateProductCapacity(store, productId, { + period: SECONDS_PER_DAY.mul(7), // 1 week + now, + assets: mockStore.assets, + assetRates: mockStore.assetRates, + }); + + // 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); + }); + }); - expect(product.productId).to.equal(Number(fixedPricedProductId)); - expect(product.minAnnualPrice).to.deep.equal(product.maxAnnualPrice); + 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 period = SECONDS_PER_DAY.mul(30); + const response = getAllProductCapacities(store, period); + const { products, productPoolIds, poolProducts, assets } = store.getState(); + + // 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 { productId } = product; + verifyProductCapacity(product, products[productId], productPoolIds[productId], poolProducts, assets); + }); }); - it('should handle products without fixed price correctly', function () { - const nonFixedPricedProductId = '0'; - const [product] = capacityEngine(store, { productIds: [nonFixedPricedProductId] }); + 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, + }), + }; - expect(product.productId).to.equal(Number(nonFixedPricedProductId)); - expect(product.minAnnualPrice).to.not.deep.equal(product.maxAnnualPrice); + const response = getAllProductCapacities(nullProductStore); + expect(response).to.have.lengthOf(0); }); + }); - 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'); + 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 include capacityPerPool if query param withPool=true', function () { - const productId = '0'; + it('should return detailed capacity for a single product', function () { + const productId = 3; + const now = getCurrentTimestamp(); + const period = SECONDS_PER_DAY.mul(30); + const response = getProductCapacity(store, productId, period); - const [productWithPools] = capacityEngine(store, { productIds: [productId], withPools: true }); - expect(productWithPools).to.have.property('capacityPerPool'); + const { assets, assetRates, productPoolIds, poolProducts, products } = store.getState(); + const poolIds = productPoolIds[productId]; - // 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); + // Check basic product info + expect(response.productId).to.equal(productId); - // 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; - } + // 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); + }); - pool.availableCapacity.forEach(asset => { - acc.availableCapacity[asset.assetId] = (acc.availableCapacity[asset.assetId] ?? Zero).add(asset.amount); + // 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)); }); + }); - return acc; - }, initCapacity); + expect(response.usedCapacity.toString()).to.equal(totalUsedCapacity.toString()); - // 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()); + expect(response.capacityPerPool).to.have.lengthOf(productPoolIds[productId].length); - productWithPools.availableCapacity.forEach(asset => { - expect(summedCapacity.availableCapacity[asset.assetId].toString()).to.equal(asset.amount.toString()); - }); + response.capacityPerPool.forEach(poolCapacity => + verifyPoolCapacity(poolCapacity, productId, products, poolProducts, now, assets, assetRates), + ); }); + }); - 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 }); - - 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, - })); + describe('getPoolCapacity', function () { + it('should return detailed pool capacity with correct utilization rate', function () { + const poolId = 4; + const period = SECONDS_PER_DAY.mul(30); + const response = getPoolCapacity(store, poolId, period); + + const { poolProducts, products } = store.getState(); + const now = getCurrentTimestamp(); + + // 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(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()); + // Verify pool utilization rate + const expectedUtilizationRate = totalUsedNXM.mul(10000).div(totalAvailableNXM.add(totalUsedNXM)); + expect(response.utilizationRate.toString()).to.equal(expectedUtilizationRate.toString()); }); }); - 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('getProductCapacityInPool', function () { + it('should return detailed capacity for a specific product in a specific pool', function () { + const poolId = 4; + const productId = '3'; + const period = SECONDS_PER_DAY.mul(30); + const response = getProductCapacityInPool(store, poolId, productId, period); + + verifyCapacityResponse(response); - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + const { poolProducts, products } = store.getState(); + const poolProduct = poolProducts[`${productId}_${poolId}`]; - const expectedRate = BigNumber.from(3333); // (50 / (100 + 50)) * 10000 = 3333 basis points + // Verify used capacity + const expectedUsedCapacity = calculateExpectedUsedCapacity(poolProduct); + expect(response.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); - expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); + // Verify price calculations + verifyPriceCalculations(response, products[productId]); }); + }); + + describe('calculateFirstUsableTrancheIndexForMaxPeriod', function () { + it('should calculate correct tranche index for max period', function () { + const now = getCurrentTimestamp(); + const gracePeriod = SECONDS_PER_DAY.mul(35); - it('should return 1 when ALL capacity is used (no available capacity)', function () { - const capacityAvailableNXM = Zero; - const capacityUsedNXM = parseEther('150'); + const result = calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod); - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + // Calculate expected result + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheIdForMaxPeriod - 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 zero grace period', function () { + const now = BigNumber.from(1678700054); + const gracePeriod = Zero; - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + const result = calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod); - expect(utilizationRate).to.equal(undefined); + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; + + expect(result).to.equal(expected); }); - it('should return undefined when there is no capacity available', function () { - const capacityAvailableNXM = Zero; - const capacityUsedNXM = Zero; + it('should handle large grace period', function () { + const now = getCurrentTimestamp(); + const gracePeriod = SECONDS_PER_DAY.mul(365); - const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + const result = calculateFirstUsableTrancheIndexForMaxPeriod(now, gracePeriod); - expect(utilizationRate.toNumber()).to.equal(0); + const firstActiveTrancheId = calculateTrancheId(now); + const firstUsableTrancheIdForMaxPeriod = calculateTrancheId(now.add(MAX_COVER_PERIOD).add(gracePeriod)); + const expected = firstUsableTrancheIdForMaxPeriod - firstActiveTrancheId; + + expect(result).to.equal(expected); }); }); - describe('getProductsInPool', function () { - let mockStore; - - beforeEach(function () { - mockStore = { - getState: sinon.stub(), - }; + describe('calculatePoolUtilizationRate', function () { + let defaultProducts; + + before(function () { + defaultProducts = [ + { + availableCapacity: [ + { assetId: 255, amount: parseEther('100') }, // NXM capacity + { assetId: 1, amount: parseEther('50') }, // Other asset capacity + ], + usedCapacity: parseEther('50'), // Used capacity + }, + { + availableCapacity: [ + { assetId: 255, amount: parseEther('200') }, // NXM capacity + { assetId: 1, amount: parseEther('100') }, // Other asset capacity + ], + usedCapacity: parseEther('100'), // Used capacity + }, + ]; }); - afterEach(function () { - sinon.restore(); + 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 + // Expected rate: (150 / (300 + 150)) * 10000 = 3333 basis points + expect(utilizationRate.toNumber()).to.equal(3333); }); - 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 } }, - }); + it('should handle empty products array', function () { + const products = []; + const utilizationRate = calculatePoolUtilizationRate(products); + expect(utilizationRate.toNumber()).to.equal(0); + }); - const poolId = 2; - const result = getProductsInPool(mockStore, poolId); + it('should handle products with no NXM capacity', function () { + const products = [ + { + availableCapacity: [{ assetId: 1, amount: parseEther('50') }], + usedCapacity: parseEther('25'), + }, + ]; + const utilizationRate = calculatePoolUtilizationRate(products); + expect(utilizationRate.toNumber()).to.equal(10000); // 100% utilization when no available NXM + }); - expect(result).to.deep.equal(['1', '2', '3']); + it('should handle products with zero used capacity', function () { + const products = defaultProducts.map(product => ({ + ...product, + usedCapacity: Zero, + })); + 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 zero total capacity', function () { + const products = defaultProducts.map(product => ({ + ...product, + availableCapacity: [{ assetId: 255, amount: Zero }], + usedCapacity: Zero, + })); + const utilizationRate = calculatePoolUtilizationRate(products); + expect(utilizationRate.toNumber()).to.equal(0); + }); - const poolId = 2; - const result = getProductsInPool(mockStore, poolId); + 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 + }); - expect(result).to.deep.equal([]); + it('should aggregate capacity across multiple products correctly', function () { + const products = [...defaultProducts]; + const rate = calculatePoolUtilizationRate(products); + // Total available: 300, Total used: 150 + // Expected: (150 / (300 + 150)) * 10000 = 3333 + expect(rate.toNumber()).to.equal(3333); }); + }); - it('should handle undefined productPools', function () { - mockStore.getState.returns({ - products: { 1: {}, 2: {}, 3: {} }, - productPoolIds: {}, - poolProducts: {}, - }); + describe('API Services', function () { + 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, 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 = ( + product, + expectedPoolProduct, + isSinglePool = false, + expectedProductId = productId, + ) => { + // Verify product ID + expect(product.productId).to.equal(Number(expectedProductId)); + + // 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[expectedProductId].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[expectedProductId].reduce((total, pid) => { + const poolProduct = storePoolProducts[`${expectedProductId}_${pid}`]; + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex( + now, + products[expectedProductId].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); + } - const poolId = 2; - const result = getProductsInPool(mockStore, poolId); + 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[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); + return total.add(poolUsed); + }, Zero); + } + expect(product.usedCapacity.toString()).to.equal(expectedUsedCapacity.toString()); + + // Verify price calculations based on product type + if (products[expectedProductId].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); + } + }; - 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 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); + } + }); - 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()); }); }); }); diff --git a/test/unit/helpers.js b/test/unit/helpers.js index d4ab91ad..f26cc61e 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, @@ -17,6 +18,11 @@ const { calculateBasePrice, calculatePremiumPerYear, calculateFixedPricePremiumPerYear, + calculateBucketId, + calculateTrancheId, + divCeil, + bnMax, + bnMin, } = require('../../src/lib/helpers'); const mockStore = require('../mocks/store'); @@ -245,6 +251,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', () => { @@ -448,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()); + }); + }); }); 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/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 = { diff --git a/test/unit/routes/capacity.js b/test/unit/routes/capacity.js index 598e2cc8..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]; @@ -105,7 +98,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({ 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 de479492..a3d55fff 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, period) => { + const firstUsableTrancheIndex = calculateFirstUsableTrancheIndex(now, storeProduct.gracePeriod, period); - 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, };