diff --git a/README.md b/README.md index 0a76d654..0c3b2836 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,39 @@ It has following schema: ] ``` +``` +GET: /storage/v0/stakes' +``` + +Returns JSON that represent all stakes for each account. + +It has following schema: + +```json5 +[ + { + "total": "number", + "symbol": "string", // token symbol + "token": "string", // token address + "account": "string", + } +] +``` + +``` +GET: /storage/v0/avgBillingPrice +``` + +Return min/max average billing price converted to USD. + + +```json5 +{ + min: 'number', + max: 'number' +} +``` + ### Rates API that caches conversion rates currently for RBTC and RIF Token. diff --git a/config/default.json5 b/config/default.json5 index 3091837b..6e35e019 100644 --- a/config/default.json5 +++ b/config/default.json5 @@ -118,7 +118,6 @@ polling: true } } - }, // Settings for RNS service related function diff --git a/src/blockchain/events.ts b/src/blockchain/events.ts index f4479ff1..6458fb8c 100644 --- a/src/blockchain/events.ts +++ b/src/blockchain/events.ts @@ -356,7 +356,7 @@ export class PollingEventsEmitter extends BaseEventsEmitter { return } - this.logger.info(`Checking new events between blocks ${lastFetchedBlockNumber}-${currentBlock}`) + this.logger.info(`Checking new events between blocks ${lastFetchedBlockNumber}-${currentBlock.number}`) const events = await this.contract.getPastEvents('allEvents', { fromBlock: (lastFetchedBlockNumber as number) + 1, // +1 because both fromBlock and toBlock is "or equal" toBlock: currentBlock.number, diff --git a/src/definitions.ts b/src/definitions.ts index 8ad4882f..2c511836 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -4,7 +4,7 @@ import * as Parser from '@oclif/parser' import { EventData } from 'web3-eth-contract' import { Eth } from 'web3-eth' -import type { AgreementService, OfferService, StakeService } from './services/storage' +import type { AvgBillingPriceService, AgreementService, OfferService, StakeService } from './services/storage' import type { RatesService } from './services/rates' import type { RnsBaseService } from './services/rns' import { ConfirmatorService } from './blockchain/confirmator' @@ -28,6 +28,7 @@ export enum ServiceAddresses { RNS_SOLD = '/rns/v0/sold', RNS_OFFERS = '/rns/v0/offers', STORAGE_OFFERS = '/storage/v0/offers', + AVG_BILLING_PRICE = '/storage/v0/avgBillingPrice', STORAGE_AGREEMENTS = '/storage/v0/agreements', STORAGE_STAKES = '/storage/v0/stakes', XR = '/rates/v0/', @@ -39,6 +40,7 @@ export enum ServiceAddresses { // A mapping of service names to types. Will be extended in service files. interface ServiceTypes { [ServiceAddresses.STORAGE_OFFERS]: OfferService & ServiceAddons + [ServiceAddresses.AVG_BILLING_PRICE]: AvgBillingPriceService & ServiceAddons [ServiceAddresses.STORAGE_AGREEMENTS]: AgreementService & ServiceAddons [ServiceAddresses.STORAGE_STAKES]: StakeService & ServiceAddons [ServiceAddresses.XR]: RatesService & ServiceAddons @@ -166,14 +168,15 @@ export interface Config { // Settings for Storage service related function storage?: { + + // Sets if Storage service should be enabled + enabled?: boolean + // Supported tokens and their addresses tokens?: { [key: string]: SupportedTokens } - // Sets if Storage service should be enabled - enabled?: boolean - // Staking contract options staking?: BlockchainServiceOptions diff --git a/src/services/storage/handlers/agreement.ts b/src/services/storage/handlers/agreement.ts index d5c1c0fa..ad980f8a 100644 --- a/src/services/storage/handlers/agreement.ts +++ b/src/services/storage/handlers/agreement.ts @@ -16,14 +16,14 @@ const logger = loggingFactory('storage:handler:request') const handlers = { async NewAgreement (event: EventData, { agreementService }: StorageServices, eth: Eth): Promise { - const { provider: offerId, billingPeriod: period } = event.returnValues - const id = soliditySha3(event.returnValues.agreementCreator, ...event.returnValues.dataReference) + const { provider: offerId, billingPeriod: period, token: tokenAddress } = event.returnValues + const id = soliditySha3(event.returnValues.agreementCreator, ...event.returnValues.dataReference, tokenAddress) const dataReference = decodeByteArray(event.returnValues.dataReference) - const plan = await BillingPlan.findOne({ where: { offerId, period } }) + const plan = await BillingPlan.findOne({ where: { offerId, period, tokenAddress } }) if (!plan) { - throw new EventError(`Price for period ${period} and offer ${offerId} not found when creating new request ${id}`, 'RequestMade') + throw new EventError(`Price for period ${period}, token ${tokenAddress} and offer ${offerId} not found when creating new request ${id}`, 'RequestMade') } const data = { @@ -34,6 +34,7 @@ const handlers = { size: event.returnValues.size, billingPeriod: event.returnValues.billingPeriod, billingPrice: plan.price, + tokenAddress, availableFunds: event.returnValues.availableFunds, lastPayout: await getBlockDate(eth, event.blockNumber) } diff --git a/src/services/storage/handlers/offer.ts b/src/services/storage/handlers/offer.ts index db9d1c7a..112d1a83 100644 --- a/src/services/storage/handlers/offer.ts +++ b/src/services/storage/handlers/offer.ts @@ -8,36 +8,35 @@ import { Handler } from '../../../definitions' import { OfferService, StorageServices } from '../index' import { decodeByteArray, wrapEvent } from '../../../utils' import { EventError } from '../../../errors' +import { getTokenSymbol } from '../utils' const logger = loggingFactory('storage:handler:offer') -function updatePrices (offer: Offer, period: BigNumber, price: BigNumber): Promise { +function updatePrices (offer: Offer, period: BigNumber, price: BigNumber, tokenAddress: string): Promise { const { plans, provider } = offer - const billingPlan = plans && plans.find(value => new BigNumber(value.period).eq(period)) + const billingPlan = plans && plans.find(value => new BigNumber(value.period).eq(period) && tokenAddress === value.tokenAddress) logger.info(`Updating period ${period} to price ${price} (ID: ${provider})`) if (billingPlan) { billingPlan.price = price return billingPlan.save() } else { - const newBillingPlanEntity = new BillingPlan({ period, price, offerId: provider }) + const tokenSymbol = getTokenSymbol(tokenAddress).toLowerCase() + const newBillingPlanEntity = new BillingPlan({ + period, + price, + offerId: provider, + tokenAddress, + rateId: tokenSymbol + }) return newBillingPlanEntity.save() } } -export function calculateAverage (plans: BillingPlan[]): number { - return plans.map(({ price, period }: BillingPlan) => { - const priceMBPPeriod = price - const priceGBPPeriod = priceMBPPeriod.times(1024) - const priceGBPSec = priceGBPPeriod.div(period) - return priceGBPSec.times(3600 * 24) - }).reduce((sum, x) => sum.plus(x)).toNumber() / plans.length -} - const handlers: { [key: string]: Function } = { async TotalCapacitySet (event: EventData, offer: Offer, offerService: OfferService): Promise { offer.totalCapacity = event.returnValues.capacity @@ -71,14 +70,8 @@ const handlers: { [key: string]: Function } = { throw new EventError(`Unknown message flag ${flag}!`, event.event) } }, - async BillingPlanSet ({ returnValues: { period, price } }: EventData, offer: Offer, offerService: OfferService): Promise { - const plan = await updatePrices(offer, period, price) - - const updatedPlans = [...offer.plans || [], plan] - - const newAvgPrice = updatedPlans?.length && calculateAverage(updatedPlans) - offer.averagePrice = newAvgPrice || 0 - offer.save() + async BillingPlanSet ({ returnValues: { period, price, token } }: EventData, offer: Offer, offerService: OfferService): Promise { + await updatePrices(offer, period, price, token) if (offerService.emit) { const freshOffer = await Offer.findByPk(offer.provider) as Offer diff --git a/src/services/storage/handlers/stake.ts b/src/services/storage/handlers/stake.ts index 2f039be3..f712b1b4 100644 --- a/src/services/storage/handlers/stake.ts +++ b/src/services/storage/handlers/stake.ts @@ -1,28 +1,14 @@ import { EventData } from 'web3-eth-contract' import BigNumber from 'bignumber.js' -import config from 'config' import { loggingFactory } from '../../../logger' -import { Handler, SupportedTokens } from '../../../definitions' +import { Handler } from '../../../definitions' import { StorageServices } from '../index' import StakeModel from '../models/stake.model' +import { getTokenSymbol } from '../utils' const logger = loggingFactory('storage:handler:stake') -/** - * Make a call to ERC20 token SC and return token symbol - * Return `rbtc` for ZERO_ADDRESS - * @param tokenContractAddress - * @returns {SupportedTokens} token symbol - */ -function getTokenSymbol (tokenContractAddress: string): SupportedTokens { - if (!config.has(`storage.tokens.${tokenContractAddress}`)) { - throw new Error(`Token at ${tokenContractAddress} not supported`) - } - - return config.get(`storage.tokens.${tokenContractAddress}`) -} - /** * Find or create stake * @param account @@ -35,7 +21,7 @@ async function findOrCreateStake (account: string, token: string): Promise { + offer.plans = offer.plans + .sort((planA, planB) => planA.period.minus(planB.period).toNumber()) + }) + return context +} + +/** + * average price filter query + * @param sequelize + * @param context + * @param averagePrice + */ +function averagePriceFilter ( + sequelize: Sequelize, + context: HookContext, + averagePrice: { min: number | string, max: number | string } +): void { + const minPrice = sequelize.escape(averagePrice.min) + const maxPrice = sequelize.escape(averagePrice.max) + const rawQ = `avgBillingPrice BETWEEN ${minPrice} AND ${maxPrice}` + // We should use Op.and to prevent overwriting the scope values + context.params.sequelize.where[Op.and] = [ + ...context.params.sequelize.where[Op.and] || [], + literal(rawQ) + ] +} + +/** + * total capacity filter query + * @param sequelize + * @param context + * @param totalCapacity + */ +function totalCapacityFilter ( + sequelize: Sequelize, + context: HookContext, + totalCapacity: { min: number | string, max: number | string } +): void { + const minCap = sequelize.escape(totalCapacity.min) + const maxCap = sequelize.escape(totalCapacity.max) + const rawQ = `cast(totalCapacity as integer) BETWEEN ${minCap} AND ${maxCap}` + // We should use Op.and to prevent overwriting the scope values + context.params.sequelize.where[Op.and] = [ + ...context.params.sequelize.where[Op.and] || [], + literal(rawQ) + ] +} + export default { before: { all: [ @@ -26,13 +80,13 @@ export default { } ], find: [ - async (context: HookContext) => { + (context: HookContext) => { if (context.params.query && !context.params.query.$limit) { const { averagePrice, totalCapacity, periods, provider } = context.params.query - const sequelize = context.app.get('sequelize') as Sequelize + const sequelize = context.app.get('sequelize') - const aggregateLiteral = await getStakesAggregateQuery(sequelize, 'usd') context.params.sequelize = { + ...context.params.sequelize, raw: false, nest: true, include: [ @@ -44,8 +98,13 @@ export default { model: Agreement } ], - attributes: [[aggregateLiteral, 'totalStakedUSD']], - order: [literal('totalStakedUSD DESC'), ['plans', 'period', 'ASC']], + attributes: { + include: [ + [getBillingPriceAvgQuery(sequelize, 'usd'), 'avgBillingPrice'], + [getStakesAggregateQuery(sequelize, 'usd'), 'totalStakedUSD'] + ] + }, + order: [literal('totalStakedUSD DESC')], where: {} } @@ -56,16 +115,11 @@ export default { } if (averagePrice) { - context.params.sequelize.where.averagePrice = { - [Op.between]: [averagePrice.min, averagePrice.max] - } + averagePriceFilter(sequelize, context, averagePrice) } if (totalCapacity) { - const minCap = sequelize.escape(totalCapacity.min) - const maxCap = sequelize.escape(totalCapacity.max) - const rawQ = `cast(totalCapacity as integer) BETWEEN ${minCap} AND ${maxCap}` - context.params.sequelize.where.totalCapacity = literal(rawQ) + totalCapacityFilter(sequelize, context, totalCapacity) } if (periods?.length) { @@ -83,7 +137,7 @@ export default { after: { all: [dehydrate(), discard('agreements')], - find: [], + find: [sortBillingPlansHook], get: [], create: [], update: [], diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 5de1a417..87a6fb2e 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -2,6 +2,7 @@ import storageManagerContract from '@rsksmart/rif-marketplace-storage/build/cont import stakingContract from '@rsksmart/rif-marketplace-storage/build/contracts/Staking.json' import config from 'config' import { Service } from 'feathers-sequelize' +import { QueryTypes } from 'sequelize' import { getObject } from 'sequelize-store' import Eth from 'web3-eth' import { EventData } from 'web3-eth-contract' @@ -14,6 +15,7 @@ import { REORG_OUT_OF_RANGE_EVENT_NAME } from '../../blockchain/events' import { Application, CachedService, Logger, ServiceAddresses } from '../../definitions' import { loggingFactory } from '../../logger' import { errorHandler, waitForReadyApp } from '../../utils' +import { getAvgMinMaxBillingPriceQuery } from './utils' import Agreement from './models/agreement.model' import Offer from './models/offer.model' import BillingPlan from './models/billing-plan.model' @@ -37,6 +39,21 @@ export class StakeService extends Service { emit?: Function } +export class AvgBillingPriceService extends Service { + emit?: Function + + async get (): Promise<{ min: number, max: number }> { + if (!config.get('storage.tokens')) { + throw new Error('"storage.tokens" not exist in config') + } + const sequelize = this.Model.sequelize + + const [{ avgPrice: min }] = await sequelize.query(getAvgMinMaxBillingPriceQuery(-1), { type: QueryTypes.SELECT, raw: true }) + const [{ avgPrice: max }] = await sequelize.query(getAvgMinMaxBillingPriceQuery(1), { type: QueryTypes.SELECT, raw: true }) + return { min, max } + } +} + export interface StorageServices { agreementService: AgreementService offerService: OfferService @@ -109,6 +126,9 @@ const storage: CachedService = { const offerService = app.service(ServiceAddresses.STORAGE_OFFERS) offerService.hooks(offerHooks) + // Init AVG Billing plan service + app.use(ServiceAddresses.AVG_BILLING_PRICE, new AvgBillingPriceService({ Model: BillingPlan })) + // Initialize Agreement service app.use(ServiceAddresses.STORAGE_AGREEMENTS, new AgreementService({ Model: Agreement })) const agreementService = app.service(ServiceAddresses.STORAGE_AGREEMENTS) diff --git a/src/services/storage/models/agreement.model.ts b/src/services/storage/models/agreement.model.ts index c6915c79..f66b8e92 100644 --- a/src/services/storage/models/agreement.model.ts +++ b/src/services/storage/models/agreement.model.ts @@ -33,6 +33,9 @@ export default class Agreement extends Model { @Column({ ...BigNumberStringType('billingPrice') }) billingPrice!: BigNumber + @Column + tokenAddress!: string + @Column({ ...BigNumberStringType('availableFunds') }) availableFunds!: BigNumber diff --git a/src/services/storage/models/billing-plan.model.ts b/src/services/storage/models/billing-plan.model.ts index 10e646c1..0edda543 100644 --- a/src/services/storage/models/billing-plan.model.ts +++ b/src/services/storage/models/billing-plan.model.ts @@ -1,8 +1,10 @@ import { Table, Column, Model, ForeignKey, BelongsTo } from 'sequelize-typescript' import BigNumber from 'bignumber.js' -import Offer from './offer.model' import { BigNumberStringType } from '../../../sequelize' +import { SupportedTokens } from '../../../definitions' +import Offer from './offer.model' +import Rate from '../../rates/rates.model' @Table({ freezeTableName: true, tableName: 'storage_billing-plan' }) export default class BillingPlan extends Model { @@ -12,10 +14,17 @@ export default class BillingPlan extends Model { @Column({ ...BigNumberStringType('price') }) price!: BigNumber + @Column + tokenAddress!: string + @ForeignKey(() => Offer) @Column offerId!: string @BelongsTo(() => Offer) offer!: Offer + + @ForeignKey(() => Rate) + @Column + rateId!: SupportedTokens } diff --git a/src/services/storage/models/offer.model.ts b/src/services/storage/models/offer.model.ts index d5279401..b92e9cda 100644 --- a/src/services/storage/models/offer.model.ts +++ b/src/services/storage/models/offer.model.ts @@ -1,17 +1,18 @@ -import config from 'config' -import { Table, DataType, Column, Model, HasMany, Scopes } from 'sequelize-typescript' -import { Op, literal, Sequelize } from 'sequelize' import BigNumber from 'bignumber.js' +import { Table, DataType, Column, Model, HasMany, Scopes } from 'sequelize-typescript' +import { literal, Op, Sequelize } from 'sequelize' +import { Literal } from 'sequelize/types/lib/utils' import BillingPlan from './billing-plan.model' +import { SupportedTokens } from '../../../definitions' import Agreement from './agreement.model' import { BigNumberStringType } from '../../../sequelize' -import Rate from '../../rates/rates.model' +import { WEI } from '../utils' @Scopes(() => ({ active: { where: { - [Op.and]: [literal('cast(totalCapacity as integer) > 0')] + totalCapacity: { [Op.ne]: '0' } // peerId: { [Op.ne]: null } }, include: [ @@ -33,9 +34,6 @@ export default class Offer extends Model { @Column peerId!: string - @Column(DataType.INTEGER) - averagePrice!: number - @HasMany(() => BillingPlan) plans!: BillingPlan[] @@ -53,6 +51,15 @@ export default class Offer extends Model { get availableCapacity (): BigNumber { return this.totalCapacity.minus(this.utilizedCapacity) } + + @Column(DataType.VIRTUAL) + get acceptedCurrencies (): Array { + return Array.from( + new Set( + (this.plans || []).map(plan => plan.rateId) + ) + ) + } } /** @@ -60,25 +67,48 @@ export default class Offer extends Model { * @param sequelize * @param currency */ -export async function getStakesAggregateQuery (sequelize: Sequelize, currency: 'usd' | 'eur' | 'btc' = 'usd') { - if (!config.get('storage.tokens')) { - throw new Error('"storage.tokens" not exist in config') - } +export function getStakesAggregateQuery ( + sequelize: Sequelize, + currency: 'usd' | 'eur' | 'btc' = 'usd' +): Literal { + return literal(` + ( + SELECT + CAST(SUM((cast(total as real) / ${WEI}) * coalesce("rates".${sequelize.escape(currency)}, 0)) as INTEGER) + FROM + storage_stakes + LEFT OUTER JOIN + "rates" AS "rates" ON "storage_stakes"."symbol" = "rates"."token" + WHERE + account = provider + ) + `) +} - const supportedTokens = Object.entries(config.get('storage.tokens')) - const rates = await Rate.findAll() - return literal(`( - SELECT SUM( - case - ${supportedTokens.reduce( - (acc, [tokenAddress, tokenSymbol]) => { - const rate: number = rates.find(r => r.token === tokenSymbol)?.[currency] || 0 - return `${acc} \n when token = ${sequelize.escape(tokenAddress)} then cast(total as integer) * ${sequelize.escape(rate)}` - }, - '' - )} - else 0 - end - ) from storage_stakes where account = provider) +/** + * This function generate nested query for aggregating an avg billing price for offer for specific currency + * @param sequelize + * @param currency + */ +export function getBillingPriceAvgQuery ( + sequelize: Sequelize, + currency: 'usd' | 'eur' | 'btc' = 'usd' +): Literal { + return literal(` + ( + SELECT + CAST( + SUM( + (cast(price as REAL) / ${WEI}) * coalesce("rates".${sequelize.escape(currency)}, 0) * 1024 / period * (3600 * 24) + ) / COUNT("storage_billing-plan"."id") + as INTEGER + ) + FROM + "storage_billing-plan" + LEFT OUTER JOIN + "rates" AS "rates" ON "storage_billing-plan"."rateId" = "rates"."token" + WHERE + offerId = provider + ) `) } diff --git a/src/services/storage/models/stake.model.ts b/src/services/storage/models/stake.model.ts index 5fd10d12..9821f379 100644 --- a/src/services/storage/models/stake.model.ts +++ b/src/services/storage/models/stake.model.ts @@ -1,6 +1,8 @@ -import { Table, Column, Model } from 'sequelize-typescript' +import { Table, Column, Model, ForeignKey } from 'sequelize-typescript' import BigNumber from 'bignumber.js' +import Rate from '../../rates/rates.model' +import { SupportedTokens } from '../../../definitions' import { BigNumberStringType } from '../../../sequelize' @Table({ freezeTableName: true, tableName: 'storage_stakes' }) @@ -8,8 +10,9 @@ export default class StakeModel extends Model { @Column({ ...BigNumberStringType('total') }) total!: BigNumber + @ForeignKey(() => Rate) @Column - symbol?: string + symbol!: SupportedTokens @Column token!: string diff --git a/src/services/storage/utils.ts b/src/services/storage/utils.ts new file mode 100644 index 00000000..1c80899f --- /dev/null +++ b/src/services/storage/utils.ts @@ -0,0 +1,40 @@ +import { SupportedTokens } from '../../definitions' +import config from 'config' + +export const WEI = 1e18 +export type MinMax = 1 | -1 + +/** + * get token symbol by token address from config + * @param tokenContractAddress + * @returns {SupportedTokens} token symbol + */ +export function getTokenSymbol (tokenContractAddress: string): SupportedTokens { + if (!config.has(`storage.tokens.${tokenContractAddress}`)) { + throw new Error(`Token on address ${tokenContractAddress} is not supported`) + } + + return config.get(`storage.tokens.${tokenContractAddress}`) +} + +/** + * Get query for generating average minimum or maximum billing price + * @param minMax + */ +export function getAvgMinMaxBillingPriceQuery (minMax: MinMax): string { + return ` + SELECT + CAST( + SUM( + CAST(price as REAL) / ${WEI} * COALESCE("rates"."usd", 0) * 1024 / period * (3600 * 24) + ) / COUNT(*) + as INTEGER + ) as avgPrice + FROM "storage_billing-plan" + LEFT OUTER JOIN + "rates" AS "rates" ON "storage_billing-plan"."rateId" = "rates"."token" + GROUP BY offerId + ORDER BY avgPrice ${minMax === -1 ? 'DESC' : 'ASC'} + LIMIT 1 + ` +} diff --git a/test/cli.spec.ts b/test/cli.spec.ts index e099a715..f2e87541 100644 --- a/test/cli.spec.ts +++ b/test/cli.spec.ts @@ -6,7 +6,7 @@ import chai from 'chai' import RnsService from '../src/services/rns' import StorageService from '../src/services/storage' -import { unlinkSync, copyFileSync, mkdirSync } from 'fs' +import { unlinkSync, copyFileSync, mkdirSync, existsSync } from 'fs' import { sequelizeFactory } from '../src/sequelize' import { initStore } from '../src/store' import { getEndPromise } from 'sequelize-store' @@ -52,7 +52,10 @@ describe('CLI', function () { // create backups const db = config.get('db') const backupPath = path.resolve(process.cwd(), config.get('dbBackUp.path')) - mkdirSync(path.resolve(config.get('dbBackUp').path)) + + if (!existsSync(path.resolve(config.get('dbBackUp').path))) { + mkdirSync(path.resolve(config.get('dbBackUp').path)) + } copyFileSync(db, path.resolve(backupPath, `0x0123:10-${db}`)) copyFileSync(db, path.resolve(backupPath, `0x0123:20-${db}`)) diff --git a/test/services/storage/models.spec.ts b/test/services/storage/models.spec.ts index 5e49dacc..121b18e0 100644 --- a/test/services/storage/models.spec.ts +++ b/test/services/storage/models.spec.ts @@ -5,8 +5,12 @@ import Sequelize, { literal } from 'sequelize' import Agreement from '../../../src/services/storage/models/agreement.model' import { sequelizeFactory } from '../../../src/sequelize' -import Offer, { getStakesAggregateQuery } from '../../../src/services/storage/models/offer.model' +import Offer, { + getBillingPriceAvgQuery, + getStakesAggregateQuery +} from '../../../src/services/storage/models/offer.model' import StakeModel from '../../../src/services/storage/models/stake.model' +import BillingPlan from '../../../src/services/storage/models/billing-plan.model' import Rate from '../../../src/services/rates/rates.model' chai.use(sinonChai) @@ -210,6 +214,58 @@ describe('Models', () => { beforeEach(async () => { await sequelize.sync({ force: true }) }) + it('should aggregate avg billing price for offers', async () => { + // POPULATE DB + await Offer.bulkCreate([ + { provider: 'abc', totalCapacity: 123, peerId: '1' }, + { provider: 'abc2', totalCapacity: 1234, peerId: '2' }, + { provider: 'abc3', totalCapacity: 1234, peerId: '2' } + ]) + const wei = 1000000000000000000 + const pricePerDayPerGb = wei / 1024 + await BillingPlan.bulkCreate([ + { period: '86400', price: `${pricePerDayPerGb}`, token: '0x0000000000000000000000000000000000000000', offerId: 'abc', rateId: 'rbtc' }, + { period: '604800', price: `${7 * pricePerDayPerGb}`, token: '0x12345', offerId: 'abc', rateId: 'rif' }, + { period: '2592000', price: `${30 * pricePerDayPerGb}`, token: '0x0000000000000000000000000000000000000000', offerId: 'abc', rateId: 'rbtc' }, + { period: '86400', price: `${2 * pricePerDayPerGb}`, token: '0x0000000000000000000000000000000000000000', offerId: 'abc2', rateId: 'rbtc' }, + { period: '604800', price: `${2 * 7 * pricePerDayPerGb}`, token: '0x12345', offerId: 'abc2', rateId: 'rif' }, + { period: '2592000', price: `${2 * 30 * pricePerDayPerGb}`, token: '0x0000000000000000000000000000000000000000', offerId: 'abc2', rateId: 'rbtc' }, + { period: '86400', price: `${3 * pricePerDayPerGb}`, token: '0x0000000000000000000000000000000000000000', offerId: 'abc3', rateId: 'rbtc' }, + { period: '604800', price: `${3 * 7 * pricePerDayPerGb}`, token: '0x12345', offerId: 'abc3', rateId: 'rif' }, + { period: '2592000', price: `${3 * 30 * pricePerDayPerGb}`, token: '0x0000000000000000000000000000000000000000', offerId: 'abc3', rateId: 'rbtc' } + ]) + await Rate.bulkCreate([ + { token: 'rif', usd: 1 }, + { token: 'rbtc', usd: 1 } + ]) + + expect((await Rate.findAll()).length).to.be.eql(2) + expect((await BillingPlan.findAll()).length).to.be.eql(9) + expect((await Offer.findAll()).length).to.be.eql(3) + + // Prepare aggregation query + const aggregateBillingPriceAvg = getBillingPriceAvgQuery(sequelize, 'usd') + + const offers = await Offer.findAll({ + raw: true, + attributes: { + exclude: ['totalCapacity', 'peerId', 'createdAt', 'updatedAt'], + include: [ + [ + aggregateBillingPriceAvg, + 'avgBillingPrice' + ] + ] + }, + order: [literal('avgBillingPrice')] + }) + const expectedRes = [ + { provider: 'abc', avgBillingPrice: 1 }, + { provider: 'abc2', avgBillingPrice: 2 }, + { provider: 'abc3', avgBillingPrice: 3 } + ] + expect(offers).to.be.deep.equal(expectedRes) + }) it('should aggregate total stakes for offers and order by stakes', async () => { // POPULATE DB await Offer.bulkCreate([ @@ -218,11 +274,11 @@ describe('Models', () => { { provider: 'abc3', totalCapacity: 1234, peerId: '2', averagePrice: '1234' } ]) await StakeModel.bulkCreate([ - { total: '1000', symbol: 'rbtc', account: 'abc', token: '0x0000000000000000000000000000000000000000' }, - { total: '1000', symbol: 'rif', account: 'abc', token: '0x12345' }, - { total: '2000', symbol: 'rbtc', account: 'abc2', token: '0x0000000000000000000000000000000000000000' }, - { total: '1000', symbol: 'rif', account: 'abc2', token: '0x12345' }, - { total: '1000', symbol: 'rbtc', account: 'abc3', token: '0x0000000000000000000000000000000000000000' } + { total: '369000000000000000', symbol: 'rbtc', account: 'abc', token: '0x0000000000000000000000000000000000000000' }, + { total: '369000000000000000123791', symbol: 'rif', account: 'abc', token: '0x12345' }, + { total: '369000000000000000', symbol: 'rbtc', account: 'abc2', token: '0x0000000000000000000000000000000000000000' }, + { total: '36900023748000000000000', symbol: 'rif', account: 'abc2', token: '0x12345' }, + { total: '1000000000000000000', symbol: 'rbtc', account: 'abc3', token: '0x0000000000000000000000000000000000000000' } ]) await Rate.bulkCreate([ { token: 'rif', usd: 1 }, @@ -234,7 +290,7 @@ describe('Models', () => { expect((await Offer.findAll()).length).to.be.eql(3) // Prepare aggregation query - const aggregateStakeQuery = await getStakesAggregateQuery(sequelize, 'usd') + const aggregateStakeQuery = getStakesAggregateQuery(sequelize, 'usd') const offers = await Offer.findAll({ raw: true, @@ -251,9 +307,9 @@ describe('Models', () => { }) const expectedRes = [ - { provider: 'abc2', totalStakesUSD: 5000 }, - { provider: 'abc', totalStakesUSD: 3000 }, - { provider: 'abc3', totalStakesUSD: 2000 } + { provider: 'abc', totalStakesUSD: 369000 }, + { provider: 'abc2', totalStakesUSD: 36900 }, + { provider: 'abc3', totalStakesUSD: 2 } ] expect(offers).to.be.deep.equal(expectedRes) }) diff --git a/test/services/storage/processor.spec.ts b/test/services/storage/processor.spec.ts index ed2b7f0b..1be7b6fd 100644 --- a/test/services/storage/processor.spec.ts +++ b/test/services/storage/processor.spec.ts @@ -96,11 +96,13 @@ describe('Storage services: Events Processor', () => { }) }) describe('BillingPlanSet', () => { + const token = '0x0000000000000000000000000000000000000000' const billingEvent: EventData = eventMock({ event: 'BillingPlanSet', returnValues: { price: 1000, period: 99, + token, provider } }) @@ -110,6 +112,7 @@ describe('Storage services: Events Processor', () => { returnValues: { price: 1000, period: 69696, + token, provider } }) @@ -121,24 +124,27 @@ describe('Storage services: Events Processor', () => { expect(billingPlan).to.be.instanceOf(BillingPlan) expect(billingPlan?.createdAt).to.be.eql(billingPlan?.updatedAt) // new instance expect(billingPlan?.price).to.be.eql(new BigNumber(event.returnValues.price)) + expect(billingPlan?.tokenAddress).to.be.eql(event.returnValues.token) expect(billingPlan?.period).to.be.eql(new BigNumber(event.returnValues.period)) }) it('create new BillingPlan if has one with different period`', async () => { await processor(billingEvent) - const billingPlan = await BillingPlan.findOne({ where: { offerId: provider, period: '99' } }) + const billingPlan = await BillingPlan.findOne({ where: { offerId: provider, period: '99', tokenAddress: token } }) expect(billingPlan).to.be.instanceOf(BillingPlan) expect(billingPlan?.createdAt).to.be.eql(billingPlan?.updatedAt) // new instance expect(billingPlan?.price).to.be.eql(new BigNumber(billingEvent.returnValues.price)) + expect(billingPlan?.tokenAddress).to.be.eql(billingEvent.returnValues.token) expect(billingPlan?.period).to.be.eql(new BigNumber(billingEvent.returnValues.period)) }) it('update BillingPlan', async () => { // Create new offer and billing plan const offer = await Offer.create({ provider }) - const billing = await BillingPlan.create({ offerId: offer.provider, period: 99, price: 1 }) + const billing = await BillingPlan.create({ offerId: offer.provider, period: 99, price: 1, tokenAddress: token }) expect(offer).to.be.instanceOf(Offer) expect(billing?.price).to.be.eql(new BigNumber(1)) + expect(billing?.tokenAddress).to.be.eql(token) expect(billing).to.be.instanceOf(BillingPlan) const newPrice = 99999 @@ -151,6 +157,7 @@ describe('Storage services: Events Processor', () => { expect(billingPlan).to.be.instanceOf(BillingPlan) expect(billingPlan?.updatedAt).to.be.gt(billingPlan?.createdAt) expect(billingPlan?.price).to.be.eql(new BigNumber(newPrice)) + expect(billingPlan?.tokenAddress).to.be.eql(token) expect(billingPlan?.period).to.be.eql(new BigNumber(billingEvent.returnValues.period)) }) }) @@ -213,12 +220,13 @@ describe('Storage services: Events Processor', () => { let offer: Offer let plan: BillingPlan let agreementData: object + const token = '0x0000' const billingPeriod = 99 const size = 100 const availableFunds = 100 const agreementCreator = asciiToHex('AgreementCreator') const dataReference = ['Reference1', 'Reference2'].map(asciiToHex) - const agreementReference = soliditySha3(agreementCreator, ...dataReference) + const agreementReference = soliditySha3(agreementCreator, ...dataReference, token) const blockNumber = 13 const agreementNotExistTest = (event: EventData): Mocha.Test => it('should throw error if agreement not exist', async () => { await expect(processor(event)).to.eventually.be.rejectedWith( @@ -241,6 +249,7 @@ describe('Storage services: Events Processor', () => { consumer: agreementCreator, offerId: provider, size, + tokenAddress: token, billingPeriod, billingPrice: 100, availableFunds, @@ -251,7 +260,7 @@ describe('Storage services: Events Processor', () => { await sequelize.sync({ force: true }) agreementServiceEmitSpy.resetHistory() offer = await Offer.create({ provider }) - plan = await BillingPlan.create({ offerId: offer.provider, price: 100, period: billingPeriod }) + plan = await BillingPlan.create({ offerId: offer.provider, price: 100, period: billingPeriod, tokenAddress: token }) expect(offer).to.be.instanceOf(Offer) expect(plan).to.be.instanceOf(BillingPlan) }) @@ -266,15 +275,16 @@ describe('Storage services: Events Processor', () => { dataReference, size, availableFunds, - provider + provider, + token } }) it('should throw error if billing plan not exist', async () => { - await expect(BillingPlan.destroy({ where: { offerId: provider, period: billingPeriod.toString() } })).to.eventually.become(1) + await expect(BillingPlan.destroy({ where: { offerId: provider, period: billingPeriod.toString(), tokenAddress: token } })).to.eventually.become(1) await expect(processor(event)).to.eventually.be.rejectedWith( EventError, - `Price for period ${event.returnValues.billingPeriod.toString()} and offer ${provider} not found when creating new request ${agreementReference}` + `Price for period ${event.returnValues.billingPeriod.toString()}, token ${token} and offer ${provider} not found when creating new request ${agreementReference}` ) }) it('should create/overwrite new agreement', async () => { @@ -290,6 +300,7 @@ describe('Storage services: Events Processor', () => { expect(agreement?.billingPeriod).to.be.eql(new BigNumber(event.returnValues.billingPeriod)) expect(agreement?.billingPrice).to.be.eql(new BigNumber(plan.price)) expect(agreement?.availableFunds).to.be.eql(new BigNumber(event.returnValues.availableFunds)) + expect(agreement?.tokenAddress).to.be.eql(token) expect(agreement?.lastPayout).to.be.eql(await getBlockDate(eth, event.blockNumber)) expect(agreementServiceEmitSpy).to.have.been.calledOnceWith('created') })