From 13d09e9c355e97566323d31684236c3effc16010 Mon Sep 17 00:00:00 2001 From: Victor Yanev Date: Tue, 17 Dec 2024 16:01:34 +0200 Subject: [PATCH] feat: Add `OPERATOR` tier (#3316) * feat: Add `OPERATOR` tier Signed-off-by: Victor Yanev * fix: build error Signed-off-by: Victor Yanev * fix: remove duplicated fetch of remaining budget Signed-off-by: Victor Yanev * chore: improve readability Signed-off-by: Victor Yanev * fix: make sure that operator address is always up-to-date with the client Signed-off-by: Victor Yanev * chore: formatting Signed-off-by: Victor Yanev * chore: formatting Signed-off-by: Victor Yanev * chore: fix tests Signed-off-by: Victor Yanev * fix: do not ignore error when EVM address is not provided to `addExpense` Signed-off-by: Victor Yanev * chore: revert unnecessary change Signed-off-by: Victor Yanev * chore: revert unnecessary change Signed-off-by: Victor Yanev * chore: reduce code duplication in metricService.spec.ts Signed-off-by: Victor Yanev * chore: reduce code duplication in metricService.spec.ts Signed-off-by: Victor Yanev * fix: error in charts:install workflow Signed-off-by: Victor Yanev * fix: hbarLimiter.spec.ts Signed-off-by: Victor Yanev * fix: remove unnecessary rest in hbarLimiter.spec.ts Signed-off-by: Victor Yanev * chore: prepend 0x to operator address Signed-off-by: Victor Yanev * test: extend assertions in hbarLimitService.spec.ts Signed-off-by: Victor Yanev --------- Signed-off-by: Victor Yanev --- docs/configuration.md | 1 - .../src/services/globalConfig.ts | 22 +- .../db/types/hbarLimiter/subscriptionTier.ts | 5 + packages/relay/src/lib/relay.ts | 6 +- .../lib/services/hapiService/hapiService.ts | 20 +- .../lib/services/hbarLimitService/index.ts | 139 ++-- packages/relay/tests/lib/eth/eth-helpers.ts | 3 - .../relay/tests/lib/ethGetBlockBy.spec.ts | 7 +- packages/relay/tests/lib/hapiService.spec.ts | 8 +- packages/relay/tests/lib/openrpc.spec.ts | 8 +- packages/relay/tests/lib/sdkClient.spec.ts | 5 - .../hbarLimitService/hbarLimitService.spec.ts | 631 +++++++++++------- .../metricService/metricService.spec.ts | 243 +++---- .../tests/acceptance/hbarLimiter.spec.ts | 11 + 14 files changed, 595 insertions(+), 514 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ce6950925f..a89ba5d20f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,7 +111,6 @@ Unless you need to set a non-default value, it is recommended to only populate o | `REDIS_RECONNECT_DELAY_MS` | "1000" | Sets the delay between reconnect retries from the Redis client in ms | | `SEND_RAW_TRANSACTION_SIZE_LIMIT` | "131072" | Sets the limit of the transaction size the relay accepts on eth_sendRawTransaction | | `GET_RECORD_DEFAULT_TO_CONSENSUS_NODE` | "false" | Flag to set if get transaction record logic should first query the mirror node (false) or consensus node via the SDK (true). | -| `HBAR_RATE_LIMIT_WHITELIST` | [""] | An array of EVM addresses that are allowed to bypass the HBAR rate limits | ## WS-Server diff --git a/packages/config-service/src/services/globalConfig.ts b/packages/config-service/src/services/globalConfig.ts index f9ba35c39a..f44eda0c8b 100644 --- a/packages/config-service/src/services/globalConfig.ts +++ b/packages/config-service/src/services/globalConfig.ts @@ -283,43 +283,31 @@ export class GlobalConfig { envName: 'HBAR_RATE_LIMIT_BASIC', type: 'number', required: false, - defaultValue: null, + defaultValue: 1_120_000_000, // 11.2 hbar }, HBAR_RATE_LIMIT_EXTENDED: { envName: 'HBAR_RATE_LIMIT_EXTENDED', type: 'number', required: false, - defaultValue: null, + defaultValue: 3_200_000_000, // 32 hbar }, HBAR_RATE_LIMIT_PRIVILEGED: { envName: 'HBAR_RATE_LIMIT_PRIVILEGED', type: 'number', required: false, - defaultValue: null, + defaultValue: 8_000_000_000, // 80 hbar }, HBAR_RATE_LIMIT_DURATION: { envName: 'HBAR_RATE_LIMIT_DURATION', type: 'number', required: false, - defaultValue: null, - }, - HBAR_RATE_LIMIT_PREEMPTIVE_CHECK: { - envName: 'HBAR_RATE_LIMIT_PREEMPTIVE_CHECK', - type: 'boolean', - required: false, - defaultValue: null, + defaultValue: 86_400_000, // 24 hours }, HBAR_RATE_LIMIT_TINYBAR: { envName: 'HBAR_RATE_LIMIT_TINYBAR', type: 'number', required: false, - defaultValue: null, - }, - HBAR_RATE_LIMIT_WHITELIST: { - envName: 'HBAR_RATE_LIMIT_WHITELIST', - type: 'array', - required: false, - defaultValue: null, + defaultValue: 800_000_000_000, // 8000 hbar }, HEDERA_NETWORK: { envName: 'HEDERA_NETWORK', diff --git a/packages/relay/src/lib/db/types/hbarLimiter/subscriptionTier.ts b/packages/relay/src/lib/db/types/hbarLimiter/subscriptionTier.ts index 58e0b3e8de..fff722f7de 100644 --- a/packages/relay/src/lib/db/types/hbarLimiter/subscriptionTier.ts +++ b/packages/relay/src/lib/db/types/hbarLimiter/subscriptionTier.ts @@ -33,4 +33,9 @@ export enum SubscriptionTier { * Trusted Partners */ PRIVILEGED = 'PRIVILEGED', + + /** + * Relay Operators + */ + OPERATOR = 'OPERATOR', } diff --git a/packages/relay/src/lib/relay.ts b/packages/relay/src/lib/relay.ts index 061dd8909d..05feb2aed2 100644 --- a/packages/relay/src/lib/relay.ts +++ b/packages/relay/src/lib/relay.ts @@ -19,7 +19,7 @@ */ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; -import { Client, Hbar } from '@hashgraph/sdk'; +import { Client } from '@hashgraph/sdk'; import EventEmitter from 'events'; import { Logger } from 'pino'; import { Gauge, Registry } from 'prom-client'; @@ -160,13 +160,15 @@ export class RelayImpl implements Relay { ipAddressHbarSpendingPlanRepository, logger.child({ name: 'hbar-rate-limit' }), register, - Hbar.fromTinybars(total), duration, ); const hapiService = new HAPIService(logger, register, this.cacheService, this.eventEmitter, hbarLimitService); this.clientMain = hapiService.getMainClientInstance(); + if (this.clientMain.operatorAccountId) { + hbarLimitService.setOperatorAddress(this.clientMain.operatorAccountId.toSolidityAddress()); + } this.web3Impl = new Web3Impl(this.clientMain); this.netImpl = new NetImpl(this.clientMain); diff --git a/packages/relay/src/lib/services/hapiService/hapiService.ts b/packages/relay/src/lib/services/hapiService/hapiService.ts index ac0e002b9e..a956ba44b8 100644 --- a/packages/relay/src/lib/services/hapiService/hapiService.ts +++ b/packages/relay/src/lib/services/hapiService/hapiService.ts @@ -18,19 +18,20 @@ * */ +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { AccountId, Client, PrivateKey } from '@hashgraph/sdk'; import dotenv from 'dotenv'; -import { Logger } from 'pino'; import EventEmitter from 'events'; import findConfig from 'find-config'; -import constants from '../../constants'; -import { Utils } from './../../../utils'; +import fs from 'fs'; +import { Logger } from 'pino'; import { Counter, Registry } from 'prom-client'; -import { SDKClient } from '../../clients/sdkClient'; -import { HbarLimitService } from '../hbarLimitService'; + +import { Utils } from '../../../utils'; +import { SDKClient } from '../../clients'; +import constants from '../../constants'; import { CacheService } from '../cacheService/cacheService'; -import { AccountId, Client, PrivateKey } from '@hashgraph/sdk'; -import fs from 'fs'; -import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { HbarLimitService } from '../hbarLimitService'; export default class HAPIService { /** @@ -289,6 +290,9 @@ export default class HAPIService { this.clientMain = this.initClient(this.logger, this.hederaNetwork); this.client = this.initSDKClient(this.logger); this.resetCounters(); + if (this.clientMain.operatorAccountId) { + this.hbarLimitService.setOperatorAddress(this.clientMain.operatorAccountId.toSolidityAddress()); + } } /** diff --git a/packages/relay/src/lib/services/hbarLimitService/index.ts b/packages/relay/src/lib/services/hbarLimitService/index.ts index e12cfa14fc..5cfa957a8c 100644 --- a/packages/relay/src/lib/services/hbarLimitService/index.ts +++ b/packages/relay/src/lib/services/hbarLimitService/index.ts @@ -18,10 +18,13 @@ * */ -import { Hbar } from '@hashgraph/sdk'; +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { AccountId, Hbar } from '@hashgraph/sdk'; import { Logger } from 'pino'; import { Counter, Gauge, Registry } from 'prom-client'; +import { prepend0x } from '../../../formatters'; +import { Utils } from '../../../utils'; import constants from '../../constants'; import { EvmAddressHbarSpendingPlanRepository } from '../../db/repositories/hbarLimiter/evmAddressHbarSpendingPlanRepository'; import { HbarSpendingPlanRepository } from '../../db/repositories/hbarLimiter/hbarSpendingPlanRepository'; @@ -36,6 +39,7 @@ export class HbarLimitService implements IHbarLimitService { BASIC: Hbar.fromTinybars(constants.HBAR_RATE_LIMIT_BASIC), EXTENDED: Hbar.fromTinybars(constants.HBAR_RATE_LIMIT_EXTENDED), PRIVILEGED: Hbar.fromTinybars(constants.HBAR_RATE_LIMIT_PRIVILEGED), + OPERATOR: Hbar.fromTinybars(constants.HBAR_RATE_LIMIT_TOTAL), }; /** @@ -74,16 +78,16 @@ export class HbarLimitService implements IHbarLimitService { private readonly averageSpendingPlanAmountSpentGauge: Record; /** - * The remaining budget for the rate limiter. + * The reset timestamp for the rate limiter. * @private */ - private remainingBudget: Hbar; + private reset: Date; /** - * The reset timestamp for the rate limiter. + * The operator address for the rate limiter. * @private */ - private reset: Date; + private operatorAddress?: string; constructor( private readonly hbarSpendingPlanRepository: HbarSpendingPlanRepository, @@ -91,13 +95,20 @@ export class HbarLimitService implements IHbarLimitService { private readonly ipAddressHbarSpendingPlanRepository: IPAddressHbarSpendingPlanRepository, private readonly logger: Logger, private readonly register: Registry, - private readonly totalBudget: Hbar, private readonly limitDuration: number, ) { this.reset = this.getResetTimestamp(); - this.remainingBudget = this.totalBudget; - if (this.totalBudget.toTinybars().lte(0)) { + const operatorId = ConfigService.get('OPERATOR_ID_MAIN'); + const operatorKey = ConfigService.get('OPERATOR_KEY_MAIN'); + if (operatorId) { + this.setOperatorAddress(AccountId.fromString(operatorId as string).toSolidityAddress()); + } else if (operatorKey) { + this.setOperatorAddress(Utils.createPrivateKeyBasedOnFormat(operatorKey as string).publicKey.toEvmAddress()); + } + + const totalBudget = HbarLimitService.TIER_LIMITS[SubscriptionTier.OPERATOR]; + if (totalBudget.toTinybars().lte(0)) { this.isHBarRateLimiterEnabled = false; } @@ -118,7 +129,7 @@ export class HbarLimitService implements IHbarLimitService { help: 'Relay Hbar rate limit remaining budget', registers: [register], }); - this.hbarLimitRemainingGauge.set(this.remainingBudget.toTinybars().toNumber()); + this.hbarLimitRemainingGauge.set(totalBudget.toTinybars().toNumber()); this.uniqueSpendingPlansCounter = Object.values(SubscriptionTier).reduce( (acc, tier) => { @@ -161,6 +172,14 @@ export class HbarLimitService implements IHbarLimitService { return this.isHBarRateLimiterEnabled; } + /** + * Sets the operator address for the rate limiter. Used for tracking operator expenses. + * @param {string} operatorAddress - The EVM address of the operator. + */ + setOperatorAddress(operatorAddress: string) { + this.operatorAddress = prepend0x(operatorAddress); + } + /** * Resets the {@link HbarSpendingPlan#amountSpent} field for all existing plans. * @param {RequestDetails} requestDetails - The request details used for logging and tracking. @@ -171,12 +190,13 @@ export class HbarLimitService implements IHbarLimitService { this.logger.trace(`${requestDetails.formattedRequestId} Resetting HBAR rate limiter...`); } await this.hbarSpendingPlanRepository.resetAmountSpentOfAllPlans(requestDetails); - this.resetBudget(); + const remainingBudget = await this.getRemainingBudget(requestDetails); + this.hbarLimitRemainingGauge.set(remainingBudget.toTinybars().toNumber()); this.resetTemporaryMetrics(); this.reset = this.getResetTimestamp(); if (this.logger.isLevelEnabled('trace')) { this.logger.trace( - `${requestDetails.formattedRequestId} HBAR Rate Limit reset: remainingBudget=${this.remainingBudget}, newResetTimestamp=${this.reset}`, + `${requestDetails.formattedRequestId} HBAR Rate Limit reset: remainingBudget=${remainingBudget}, newResetTimestamp=${this.reset}`, ); } } @@ -221,8 +241,8 @@ export class HbarLimitService implements IHbarLimitService { } let spendingPlan = await this.getSpendingPlan(evmAddress, requestDetails); if (!spendingPlan) { - // Create a basic spending plan if none exists for the evm address or ip address - spendingPlan = await this.createBasicSpendingPlan(evmAddress, requestDetails); + // Create a basic spending plan if none exists for the evm address + spendingPlan = await this.createSpendingPlanForAddress(evmAddress, requestDetails); } const spendingLimit = HbarLimitService.TIER_LIMITS[spendingPlan.subscriptionTier].toTinybars(); @@ -262,9 +282,13 @@ export class HbarLimitService implements IHbarLimitService { return; } - const newRemainingBudget = this.remainingBudget.toTinybars().sub(cost); - this.remainingBudget = Hbar.fromTinybars(newRemainingBudget); - this.hbarLimitRemainingGauge.set(newRemainingBudget.toNumber()); + const operatorPlan = await this.getOperatorSpendingPlan(requestDetails); + await this.hbarSpendingPlanRepository.addToAmountSpent(operatorPlan.id, cost, requestDetails, this.limitDuration); + // Done asynchronously in the background + this.updateAverageAmountSpentPerSubscriptionTier(operatorPlan.subscriptionTier, requestDetails).then(); + + const remainingBudget = await this.getRemainingBudget(requestDetails); + this.hbarLimitRemainingGauge.set(remainingBudget.toTinybars().toNumber()); const ipAddress = requestDetails.ipAddress; if (!evmAddress && !ipAddress) { @@ -278,10 +302,10 @@ export class HbarLimitService implements IHbarLimitService { if (!spendingPlan) { if (evmAddress) { // Create a basic spending plan if none exists for the evm address - spendingPlan = await this.createBasicSpendingPlan(evmAddress, requestDetails); + spendingPlan = await this.createSpendingPlanForAddress(evmAddress, requestDetails); } else { this.logger.warn( - `${requestDetails.formattedRequestId} Cannot add expense to a spending plan without an evm address or ip address`, + `${requestDetails.formattedRequestId} Cannot add expense to a spending plan without an evm address`, ); return; } @@ -309,7 +333,7 @@ export class HbarLimitService implements IHbarLimitService { if (this.logger.isLevelEnabled('trace')) { this.logger.trace( - `${requestDetails.formattedRequestId} HBAR rate limit expense update: cost=${cost} tℏ, remainingBudget=${this.remainingBudget}`, + `${requestDetails.formattedRequestId} HBAR rate limit expense update: cost=${cost} tℏ, remainingBudget=${remainingBudget}`, ); } } @@ -334,27 +358,25 @@ export class HbarLimitService implements IHbarLimitService { if (this.shouldResetLimiter()) { await this.resetLimiter(requestDetails); } + const totalBudget = HbarLimitService.TIER_LIMITS[SubscriptionTier.OPERATOR]; + const remainingBudget = await this.getRemainingBudget(requestDetails); // note: estimatedTxFee is only applicable in a few cases (currently, only for file transactions). // In most situations, estimatedTxFee is set to 0 (i.e., not considered). // In such cases, it should still be false if remainingBudget === 0. - if (this.remainingBudget.toTinybars().lte(0) || this.remainingBudget.toTinybars().sub(estimatedTxFee).lt(0)) { + if (remainingBudget.toTinybars().lte(0) || remainingBudget.toTinybars().sub(estimatedTxFee).lt(0)) { this.hbarLimitCounter.labels(mode, methodName).inc(1); this.logger.warn( - `${requestDetails.formattedRequestId} Total HBAR rate limit reached: remainingBudget=${ - this.remainingBudget - }, totalBudget=${ - this.totalBudget - }, estimatedTxFee=${estimatedTxFee}, resetTimestamp=${this.reset.getMilliseconds()}, txConstructorName=${txConstructorName} mode=${mode}, methodName=${methodName}`, + `${ + requestDetails.formattedRequestId + } Total HBAR rate limit reached: remainingBudget=${remainingBudget}, totalBudget=${totalBudget}, estimatedTxFee=${estimatedTxFee}, resetTimestamp=${this.reset.getMilliseconds()}, txConstructorName=${txConstructorName} mode=${mode}, methodName=${methodName}`, ); return true; } else { if (this.logger.isLevelEnabled('trace')) { this.logger.trace( - `${requestDetails.formattedRequestId} Total HBAR rate limit NOT reached: remainingBudget=${ - this.remainingBudget - }, totalBudget=${ - this.totalBudget - }, estimatedTxFee=${estimatedTxFee}, resetTimestamp=${this.reset.getMilliseconds()}, txConstructorName=${txConstructorName} mode=${mode}, methodName=${methodName}`, + `${ + requestDetails.formattedRequestId + } Total HBAR rate limit NOT reached: remainingBudget=${remainingBudget}, totalBudget=${totalBudget}, estimatedTxFee=${estimatedTxFee}, resetTimestamp=${this.reset.getMilliseconds()}, txConstructorName=${txConstructorName} mode=${mode}, methodName=${methodName}`, ); } return false; @@ -389,15 +411,6 @@ export class HbarLimitService implements IHbarLimitService { return Date.now() >= this.reset.getTime(); } - /** - * Resets the remaining budget to the total budget. - * @private - */ - private resetBudget(): void { - this.remainingBudget = this.totalBudget; - this.hbarLimitRemainingGauge.set(this.remainingBudget.toTinybars().toNumber()); - } - /** * Resets the metrics which are used to track the number of unique spending plans used during the limit duration. * @private @@ -448,7 +461,6 @@ export class HbarLimitService implements IHbarLimitService { return await this.getSpendingPlanByEvmAddress(evmAddress, requestDetails); } catch (error) { this.logger.warn( - error, `${requestDetails.formattedRequestId} Failed to get spending plan for evm address '${evmAddress}'`, ); } @@ -458,9 +470,10 @@ export class HbarLimitService implements IHbarLimitService { try { return await this.getSpendingPlanByIPAddress(requestDetails); } catch (error) { - this.logger.warn(error, `${requestDetails.formattedRequestId} Failed to get spending plan`); + this.logger.warn(`${requestDetails.formattedRequestId} Failed to get spending plan for ip address`); } } + return null; } @@ -501,20 +514,22 @@ export class HbarLimitService implements IHbarLimitService { * Creates a basic spending plan for the given evm address. * @param {string} evmAddress - The evm address to create the spending plan for. * @param {RequestDetails} requestDetails - The request details for logging and tracking. + * @param {SubscriptionTier} [subscriptionTier] - The subscription tier for the spending plan. (default = BASIC) * @returns {Promise} - A promise that resolves with the created spending plan. * @throws {Error} - If neither evm address nor IP address is provided. * @private */ - private async createBasicSpendingPlan( + private async createSpendingPlanForAddress( evmAddress: string, requestDetails: RequestDetails, + subscriptionTier: SubscriptionTier = SubscriptionTier.BASIC, ): Promise { if (!evmAddress) { throw new Error('Cannot create a spending plan without an associated evm address'); } const spendingPlan = await this.hbarSpendingPlanRepository.create( - SubscriptionTier.BASIC, + subscriptionTier, requestDetails, this.limitDuration, ); @@ -532,4 +547,42 @@ export class HbarLimitService implements IHbarLimitService { return spendingPlan; } + + /** + * Gets the operator spending plan. If the plan does not exist, it will be created. + * @param {RequestDetails} requestDetails - The request details for logging and tracking. + * @returns {Promise} - A promise that resolves with the operator spending plan. + * @private + */ + private async getOperatorSpendingPlan(requestDetails: RequestDetails): Promise { + let operatorPlan = await this.getSpendingPlan(this.operatorAddress!, requestDetails); + if (!operatorPlan) { + this.logger.trace(`${requestDetails.formattedRequestId} Creating operator spending plan...`); + operatorPlan = await this.createSpendingPlanForAddress( + this.operatorAddress!, + requestDetails, + SubscriptionTier.OPERATOR, + ); + } + return operatorPlan; + } + + /** + * Gets the remaining budget of the rate limiter. This is the total budget minus the amount spent by the operator. + * @param {RequestDetails} requestDetails - The request details for logging and tracking. + * @returns {Promise} - A promise that resolves with the remaining budget. + * @private + */ + private async getRemainingBudget(requestDetails: RequestDetails): Promise { + const totalBudget = HbarLimitService.TIER_LIMITS[SubscriptionTier.OPERATOR]; + try { + const operatorPlan = await this.getOperatorSpendingPlan(requestDetails); + return Hbar.fromTinybars(totalBudget.toTinybars().sub(operatorPlan.amountSpent)); + } catch (error) { + this.logger.error(error); + // If we get to here, then something went wrong with the operator spending plan. + // In this case, we should just return the total budget, so that the rate limiter does not block all requests. + return totalBudget; + } + } } diff --git a/packages/relay/tests/lib/eth/eth-helpers.ts b/packages/relay/tests/lib/eth/eth-helpers.ts index be34e407a8..58295adc8a 100644 --- a/packages/relay/tests/lib/eth/eth-helpers.ts +++ b/packages/relay/tests/lib/eth/eth-helpers.ts @@ -19,7 +19,6 @@ */ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; -import { Hbar } from '@hashgraph/sdk'; import MockAdapter from 'axios-mock-adapter'; import EventEmitter from 'events'; import pino from 'pino'; @@ -68,7 +67,6 @@ export function generateEthTestEnv(fixedFeeHistory = false) { const web3Mock = new MockAdapter(mirrorNodeInstance.getMirrorNodeWeb3Instance(), { onNoMatch: 'throwException' }); const duration = constants.HBAR_RATE_LIMIT_DURATION; - const total = constants.HBAR_RATE_LIMIT_TOTAL; const eventEmitter = new EventEmitter(); const hbarSpendingPlanRepository = new HbarSpendingPlanRepository(cacheService, logger); @@ -80,7 +78,6 @@ export function generateEthTestEnv(fixedFeeHistory = false) { ipAddressHbarSpendingPlanRepository, logger, register, - Hbar.fromTinybars(total), duration, ); diff --git a/packages/relay/tests/lib/ethGetBlockBy.spec.ts b/packages/relay/tests/lib/ethGetBlockBy.spec.ts index 56c7b7afc0..f3e9e7d2bd 100644 --- a/packages/relay/tests/lib/ethGetBlockBy.spec.ts +++ b/packages/relay/tests/lib/ethGetBlockBy.spec.ts @@ -19,7 +19,6 @@ */ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; -import { Hbar } from '@hashgraph/sdk'; import MockAdapter from 'axios-mock-adapter'; import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -27,8 +26,8 @@ import { EventEmitter } from 'events'; import pino from 'pino'; import { register, Registry } from 'prom-client'; -import { nanOrNumberTo0x, nullableNumberTo0x, numberTo0x, toHash32 } from '../../../../packages/relay/src/formatters'; -import { MirrorNodeClient } from '../../src/lib/clients/mirrorNodeClient'; +import { nanOrNumberTo0x, nullableNumberTo0x, numberTo0x, toHash32 } from '../../src/formatters'; +import { MirrorNodeClient } from '../../src/lib/clients'; import constants from '../../src/lib/constants'; import { EvmAddressHbarSpendingPlanRepository } from '../../src/lib/db/repositories/hbarLimiter/evmAddressHbarSpendingPlanRepository'; import { HbarSpendingPlanRepository } from '../../src/lib/db/repositories/hbarLimiter/hbarSpendingPlanRepository'; @@ -139,7 +138,6 @@ describe('eth_getBlockBy', async function () { restMock = new MockAdapter(mirrorNodeInstance.getMirrorNodeRestInstance(), { onNoMatch: 'throwException' }); const duration = constants.HBAR_RATE_LIMIT_DURATION; - const total = constants.HBAR_RATE_LIMIT_TOTAL; const eventEmitter = new EventEmitter(); const hbarSpendingPlanRepository = new HbarSpendingPlanRepository(cacheService, logger); @@ -151,7 +149,6 @@ describe('eth_getBlockBy', async function () { ipAddressHbarSpendingPlanRepository, logger, register, - Hbar.fromTinybars(total), duration, ); diff --git a/packages/relay/tests/lib/hapiService.spec.ts b/packages/relay/tests/lib/hapiService.spec.ts index 5b3d66f9a3..d93b6d07d5 100644 --- a/packages/relay/tests/lib/hapiService.spec.ts +++ b/packages/relay/tests/lib/hapiService.spec.ts @@ -19,7 +19,7 @@ */ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; -import { Client, Hbar } from '@hashgraph/sdk'; +import { Client } from '@hashgraph/sdk'; import { expect } from 'chai'; import EventEmitter from 'events'; import pino from 'pino'; @@ -50,7 +50,6 @@ describe('HAPI Service', async function () { const requestDetails = new RequestDetails({ requestId: 'hapiService.spec.ts', ipAddress: '0.0.0.0' }); this.beforeAll(() => { - const total = constants.HBAR_RATE_LIMIT_TOTAL; const duration = constants.HBAR_RATE_LIMIT_DURATION; eventEmitter = new EventEmitter(); cacheService = new CacheService(logger.child({ name: `cache` }), registry); @@ -64,7 +63,6 @@ describe('HAPI Service', async function () { ipAddressHbarSpendingPlanRepository, logger, register, - Hbar.fromTinybars(total), duration, ); }); @@ -177,7 +175,7 @@ describe('HAPI Service', async function () { const costAmount = 10000; hapiService = new HAPIService(logger, registry, cacheService, eventEmitter, hbarLimitService); - const hbarLimiterBudgetBefore = hbarLimitService['remainingBudget']; + const hbarLimiterBudgetBefore = await hbarLimitService['getRemainingBudget'](requestDetails); const oldClientInstance = hapiService.getMainClientInstance(); const oldSDKInstance = hapiService.getSDKClient(); @@ -186,7 +184,7 @@ describe('HAPI Service', async function () { const newSDKInstance = hapiService.getSDKClient(); const newClientInstance = hapiService.getMainClientInstance(); - const hbarLimiterBudgetAfter = hbarLimitService['remainingBudget']; + const hbarLimiterBudgetAfter = await hbarLimitService['getRemainingBudget'](requestDetails); expect(hbarLimiterBudgetBefore.toTinybars().toNumber()).to.be.greaterThan( hbarLimiterBudgetAfter.toTinybars().toNumber(), diff --git a/packages/relay/tests/lib/openrpc.spec.ts b/packages/relay/tests/lib/openrpc.spec.ts index 3c1a1b3ae2..1ca3274505 100644 --- a/packages/relay/tests/lib/openrpc.spec.ts +++ b/packages/relay/tests/lib/openrpc.spec.ts @@ -19,7 +19,7 @@ */ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; -import { AccountInfo, Hbar } from '@hashgraph/sdk'; +import { AccountInfo } from '@hashgraph/sdk'; import { parseOpenRPCDocument, validateOpenRPCDocument } from '@open-rpc/schema-utils-js'; import Ajv from 'ajv'; import axios from 'axios'; @@ -33,15 +33,15 @@ import { register, Registry } from 'prom-client'; import sinon from 'sinon'; import openRpcSchema from '../../../../docs/openrpc.json'; +import { RelayImpl } from '../../src'; import { numberTo0x } from '../../src/formatters'; import { SDKClient } from '../../src/lib/clients'; -import { MirrorNodeClient } from '../../src/lib/clients/mirrorNodeClient'; +import { MirrorNodeClient } from '../../src/lib/clients'; import constants from '../../src/lib/constants'; import { EvmAddressHbarSpendingPlanRepository } from '../../src/lib/db/repositories/hbarLimiter/evmAddressHbarSpendingPlanRepository'; import { HbarSpendingPlanRepository } from '../../src/lib/db/repositories/hbarLimiter/hbarSpendingPlanRepository'; import { IPAddressHbarSpendingPlanRepository } from '../../src/lib/db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository'; import { EthImpl } from '../../src/lib/eth'; -import { RelayImpl } from '../../src/lib/relay'; import { CacheService } from '../../src/lib/services/cacheService/cacheService'; import ClientService from '../../src/lib/services/hapiService/hapiService'; import { HbarLimitService } from '../../src/lib/services/hbarLimitService'; @@ -129,7 +129,6 @@ describe('Open RPC Specification', function () { instance, ); const duration = constants.HBAR_RATE_LIMIT_DURATION; - const total = constants.HBAR_RATE_LIMIT_TOTAL; const eventEmitter = new EventEmitter(); const hbarSpendingPlanRepository = new HbarSpendingPlanRepository(cacheService, logger); @@ -141,7 +140,6 @@ describe('Open RPC Specification', function () { ipAddressHbarSpendingPlanRepository, logger, register, - Hbar.fromTinybars(total), duration, ); diff --git a/packages/relay/tests/lib/sdkClient.spec.ts b/packages/relay/tests/lib/sdkClient.spec.ts index 46f533aea7..43e26cc3d4 100644 --- a/packages/relay/tests/lib/sdkClient.spec.ts +++ b/packages/relay/tests/lib/sdkClient.spec.ts @@ -43,7 +43,6 @@ import MockAdapter from 'axios-mock-adapter'; import { expect } from 'chai'; import EventEmitter from 'events'; import Long from 'long'; -import { Context } from 'mocha'; import pino from 'pino'; import { register, Registry } from 'prom-client'; import * as sinon from 'sinon'; @@ -115,7 +114,6 @@ describe('SdkClient', async function () { Utils.createPrivateKeyBasedOnFormat(ConfigService.get('OPERATOR_KEY_MAIN')!), ); const duration = constants.HBAR_RATE_LIMIT_DURATION; - const total = constants.HBAR_RATE_LIMIT_TOTAL; eventEmitter = new EventEmitter(); cacheService = new CacheService(logger, registry); @@ -128,7 +126,6 @@ describe('SdkClient', async function () { ipAddressHbarSpendingPlanRepository, logger, register, - Hbar.fromTinybars(total), duration, ); @@ -2246,8 +2243,6 @@ describe('SdkClient', async function () { let hbarLimitServiceMock: sinon.SinonMock; let sdkClientMock: sinon.SinonMock; - overrideEnvsInMochaDescribe({ HBAR_RATE_LIMIT_PREEMPTIVE_CHECK: true }); - beforeEach(() => { hbarLimitServiceMock = sinon.mock(hbarLimitService); sdkClientMock = sinon.mock(sdkClient); diff --git a/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts b/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts index a1e02b1022..0b857e6297 100644 --- a/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts +++ b/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts @@ -18,7 +18,8 @@ * */ -import { Hbar } from '@hashgraph/sdk'; +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { AccountId, Hbar } from '@hashgraph/sdk'; import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { randomBytes, uuidV4 } from 'ethers'; @@ -27,6 +28,7 @@ import pino, { Logger } from 'pino'; import { Counter, Gauge, Registry } from 'prom-client'; import sinon from 'sinon'; +import { prepend0x } from '../../../../src/formatters'; import constants from '../../../../src/lib/constants'; import { HbarSpendingPlan } from '../../../../src/lib/db/entities/hbarLimiter/hbarSpendingPlan'; import { EvmAddressHbarSpendingPlanRepository } from '../../../../src/lib/db/repositories/hbarLimiter/evmAddressHbarSpendingPlanRepository'; @@ -36,18 +38,18 @@ import { EvmAddressHbarSpendingPlanNotFoundError, HbarSpendingPlanNotActiveError, HbarSpendingPlanNotFoundError, - IPAddressHbarSpendingPlanNotFoundError, } from '../../../../src/lib/db/types/hbarLimiter/errors'; +import { IDetailedHbarSpendingPlan } from '../../../../src/lib/db/types/hbarLimiter/hbarSpendingPlan'; import { SubscriptionTier } from '../../../../src/lib/db/types/hbarLimiter/subscriptionTier'; +import { CacheService } from '../../../../src/lib/services/cacheService/cacheService'; import { HbarLimitService } from '../../../../src/lib/services/hbarLimitService'; import { RequestDetails } from '../../../../src/lib/types'; chai.use(chaiAsPromised); describe('HBAR Rate Limit Service', function () { - const logger = pino(); + const logger = pino({ level: 'trace' }); const register = new Registry(); - const totalBudget = Hbar.fromTinybars(constants.HBAR_RATE_LIMIT_TOTAL); const totalBudgetInTinybars = constants.HBAR_RATE_LIMIT_TOTAL.toNumber(); const limitDuration = constants.HBAR_RATE_LIMIT_DURATION; const mode = constants.EXECUTION_MODE.TRANSACTION; @@ -58,27 +60,46 @@ describe('HBAR Rate Limit Service', function () { const mockEstimatedTxFee = 300; const mockPlanId = uuidV4(randomBytes(16)); const todayAtMidnight = new Date().setHours(0, 0, 0, 0); + const operatorAddress = prepend0x( + AccountId.fromString(ConfigService.get('OPERATOR_ID_MAIN') as string).toSolidityAddress(), + ); const requestDetails = new RequestDetails({ requestId: 'hbarLimitServiceTest', ipAddress: mockIpAddress }); + let cacheService: CacheService; let hbarLimitService: HbarLimitService; - let hbarSpendingPlanRepositoryStub: sinon.SinonStubbedInstance; - let evmAddressHbarSpendingPlanRepositoryStub: sinon.SinonStubbedInstance; - let ipAddressHbarSpendingPlanRepositoryStub: sinon.SinonStubbedInstance; + let hbarSpendingPlanRepository: HbarSpendingPlanRepository; + let hbarSpendingPlanRepositorySpy: sinon.SinonSpiedInstance; + let evmAddressHbarSpendingPlanRepository: EvmAddressHbarSpendingPlanRepository; + let evmAddressHbarSpendingPlanRepositorySpy: sinon.SinonSpiedInstance; + let ipAddressHbarSpendingPlanRepository: IPAddressHbarSpendingPlanRepository; + let ipAddressHbarSpendingPlanRepositorySpy: sinon.SinonSpiedInstance; let loggerSpy: sinon.SinonSpiedInstance; beforeEach(function () { + cacheService = new CacheService(logger.child({ name: `cache` }), register); loggerSpy = sinon.spy(logger); - hbarSpendingPlanRepositoryStub = sinon.createStubInstance(HbarSpendingPlanRepository); - evmAddressHbarSpendingPlanRepositoryStub = sinon.createStubInstance(EvmAddressHbarSpendingPlanRepository); - ipAddressHbarSpendingPlanRepositoryStub = sinon.createStubInstance(IPAddressHbarSpendingPlanRepository); + hbarSpendingPlanRepository = new HbarSpendingPlanRepository( + cacheService, + logger.child({ name: 'hbar-spending-plan-repository' }), + ); + hbarSpendingPlanRepositorySpy = sinon.spy(hbarSpendingPlanRepository); + evmAddressHbarSpendingPlanRepository = new EvmAddressHbarSpendingPlanRepository( + cacheService, + logger.child({ name: 'evm-address-hbar-spending-plan-repository' }), + ); + evmAddressHbarSpendingPlanRepositorySpy = sinon.spy(evmAddressHbarSpendingPlanRepository); + ipAddressHbarSpendingPlanRepository = new IPAddressHbarSpendingPlanRepository( + cacheService, + logger.child({ name: 'ip-address-hbar-spending-plan-repository' }), + ); + ipAddressHbarSpendingPlanRepositorySpy = sinon.spy(ipAddressHbarSpendingPlanRepository); hbarLimitService = new HbarLimitService( - hbarSpendingPlanRepositoryStub, - evmAddressHbarSpendingPlanRepositoryStub, - ipAddressHbarSpendingPlanRepositoryStub, + hbarSpendingPlanRepository, + evmAddressHbarSpendingPlanRepository, + ipAddressHbarSpendingPlanRepository, logger, register, - totalBudget, limitDuration, ); }); @@ -128,12 +149,11 @@ describe('HBAR Rate Limit Service', function () { it('should return tomorrow at midnight', function () { const hbarLimitService = new HbarLimitService( - hbarSpendingPlanRepositoryStub, - evmAddressHbarSpendingPlanRepositoryStub, - ipAddressHbarSpendingPlanRepositoryStub, + hbarSpendingPlanRepository, + evmAddressHbarSpendingPlanRepository, + ipAddressHbarSpendingPlanRepository, logger, register, - totalBudget, limitDuration, ); const tomorrow = new Date(Date.now() + limitDuration); @@ -144,17 +164,9 @@ describe('HBAR Rate Limit Service', function () { }); describe('resetLimiter', function () { - beforeEach(() => { - hbarSpendingPlanRepositoryStub.resetAmountSpentOfAllPlans.resolves(); - }); - - afterEach(() => { - hbarSpendingPlanRepositoryStub.resetAmountSpentOfAllPlans.restore(); - }); - it('should reset the amountSpent field of all spending plans', async function () { await hbarLimitService.resetLimiter(requestDetails); - expect(hbarSpendingPlanRepositoryStub.resetAmountSpentOfAllPlans.called).to.be.true; + expect(hbarSpendingPlanRepositorySpy.resetAmountSpentOfAllPlans.called).to.be.true; }); it('should reset the remaining budget and update the gauge', async function () { @@ -162,7 +174,9 @@ describe('HBAR Rate Limit Service', function () { hbarLimitService.remainingBudget = Hbar.fromTinybars(1000); const setSpy = sinon.spy(hbarLimitService['hbarLimitRemainingGauge'], 'set'); await hbarLimitService.resetLimiter(requestDetails); - expect(hbarLimitService['remainingBudget'].toTinybars().toNumber()).to.eq(totalBudgetInTinybars); + expect((await hbarLimitService['getRemainingBudget'](requestDetails)).toTinybars().toNumber()).to.eq( + totalBudgetInTinybars, + ); expect(setSpy.calledOnceWith(totalBudgetInTinybars)).to.be.true; }); @@ -178,8 +192,13 @@ describe('HBAR Rate Limit Service', function () { describe('shouldLimit', function () { describe('based on evmAddress', async function () { it('should return true if the total budget is exceeded', async function () { - // @ts-ignore - hbarLimitService.remainingBudget = Hbar.fromTinybars(0); + const operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + await hbarSpendingPlanRepository.addToAmountSpent( + operatorPlan.id, + totalBudgetInTinybars, + requestDetails, + limitDuration, + ); const result = await hbarLimitService.shouldLimit( mode, methodName, @@ -191,8 +210,13 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true when remainingBudget < estimatedTxFee ', async function () { - // @ts-ignore - hbarLimitService.remainingBudget = Hbar.fromTinybars(mockEstimatedTxFee - 1); + const operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + await hbarSpendingPlanRepository.addToAmountSpent( + operatorPlan.id, + totalBudgetInTinybars - mockEstimatedTxFee + 1, + requestDetails, + limitDuration, + ); const result = await hbarLimitService.shouldLimit( mode, methodName, @@ -205,12 +229,6 @@ describe('HBAR Rate Limit Service', function () { }); it('should create a basic spending plan if none exists for the evmAddress', async function () { - const newSpendingPlan = createSpendingPlan(mockPlanId); - const error = new EvmAddressHbarSpendingPlanNotFoundError(mockEvmAddress); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); - hbarSpendingPlanRepositoryStub.create.resolves(newSpendingPlan); - evmAddressHbarSpendingPlanRepositoryStub.save.resolves(); - const result = await hbarLimitService.shouldLimit( mode, methodName, @@ -220,13 +238,19 @@ describe('HBAR Rate Limit Service', function () { ); expect(result).to.be.false; - expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; - expect(evmAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; + expect(hbarSpendingPlanRepositorySpy.create.getCalls().length).to.eq(2); // one for operator and one for evm address + expect(evmAddressHbarSpendingPlanRepositorySpy.save.getCalls().length).to.eq(2); // same here + expect(hbarSpendingPlanRepositorySpy.create.getCalls()[0].calledWith(SubscriptionTier.OPERATOR)).to.be.true; expect( - loggerSpy.warn.calledWithMatch( - sinon.match.instanceOf(EvmAddressHbarSpendingPlanNotFoundError), - `Failed to get spending plan for evm address '${mockEvmAddress}'`, - ), + evmAddressHbarSpendingPlanRepositorySpy.save + .getCalls()[0] + .calledWith({ evmAddress: operatorAddress, planId: sinon.match.string }), + ).to.be.true; + expect(hbarSpendingPlanRepositorySpy.create.getCalls()[1].calledWith(SubscriptionTier.BASIC)).to.be.true; + expect( + evmAddressHbarSpendingPlanRepositorySpy.save + .getCalls()[1] + .calledWith({ evmAddress: mockEvmAddress, planId: sinon.match.string }), ).to.be.true; }); @@ -237,12 +261,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true if amountSpent is exactly at the limit', async function () { - const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC]); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( + mockPlanId, + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().toNumber(), + requestDetails, + limitDuration, + ); const result = await hbarLimitService.shouldLimit( mode, @@ -256,15 +286,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return false if amountSpent is just below the limit', async function () { - const spendingPlan = createSpendingPlan( + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().toNumber() - 1, + requestDetails, + limitDuration, ); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit( mode, @@ -278,15 +311,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true if amountSpent is just above the limit', async function () { - const spendingPlan = createSpendingPlan( + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().add(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().toNumber() + 1, + requestDetails, + limitDuration, ); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit( mode, @@ -300,15 +336,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true if amountSpent + estimatedTxFee is above the limit', async function () { - const spendingPlan = createSpendingPlan( + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).add(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).add(1).toNumber(), + requestDetails, + limitDuration, ); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit( mode, @@ -323,15 +362,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return false if amountSpent + estimatedTxFee is below the limit', async function () { - const spendingPlan = createSpendingPlan( + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).sub(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).sub(1).toNumber(), + requestDetails, + limitDuration, ); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit( mode, @@ -345,15 +387,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return false if amountSpent + estimatedTxFee is at the limit', async function () { - const spendingPlan = createSpendingPlan( + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).toNumber(), + requestDetails, + limitDuration, ); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit( mode, @@ -369,15 +414,25 @@ describe('HBAR Rate Limit Service', function () { describe('based on ipAddress', async function () { it('should return true if the total budget is exceeded', async function () { - // @ts-ignore - hbarLimitService.remainingBudget = Hbar.fromTinybars(0); + const operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + await hbarSpendingPlanRepository.addToAmountSpent( + operatorPlan.id, + totalBudgetInTinybars, + requestDetails, + limitDuration, + ); const result = await hbarLimitService.shouldLimit(mode, methodName, txConstructorName, '', requestDetails); expect(result).to.be.true; }); it('should return true when remainingBudget < estimatedTxFee ', async function () { - // @ts-ignore - hbarLimitService.remainingBudget = Hbar.fromTinybars(mockEstimatedTxFee - 1); + const operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + await hbarSpendingPlanRepository.addToAmountSpent( + operatorPlan.id, + totalBudgetInTinybars - mockEstimatedTxFee + 1, + requestDetails, + limitDuration, + ); const result = await hbarLimitService.shouldLimit( mode, methodName, @@ -396,12 +451,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true if amountSpent is exactly at the limit', async function () { - const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC]); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( + mockPlanId, + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().toNumber(), + requestDetails, + limitDuration, + ); const result = await hbarLimitService.shouldLimit(mode, methodName, txConstructorName, '', requestDetails); @@ -409,15 +470,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return false if amountSpent is just below the limit', async function () { - const spendingPlan = createSpendingPlan( + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(1).toNumber(), + requestDetails, + limitDuration, ); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit(mode, methodName, txConstructorName, '', requestDetails); @@ -425,15 +489,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true if amountSpent is just above the limit', async function () { - const spendingPlan = createSpendingPlan( + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().add(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().add(1).toNumber(), + requestDetails, + limitDuration, ); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit(mode, methodName, txConstructorName, '', requestDetails); @@ -441,15 +508,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return true if amountSpent + estimatedTxFee is above the limit', async function () { - const spendingPlan = createSpendingPlan( + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).add(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).add(1).toNumber(), + requestDetails, + limitDuration, ); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit( mode, @@ -464,15 +534,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return false if amountSpent + estimatedTxFee is below the limit', async function () { - const spendingPlan = createSpendingPlan( + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).sub(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).sub(1).toNumber(), + requestDetails, + limitDuration, ); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit(mode, methodName, txConstructorName, '', requestDetails); @@ -480,15 +553,18 @@ describe('HBAR Rate Limit Service', function () { }); it('should return false if amountSpent + estimatedTxFee is at the limit', async function () { - const spendingPlan = createSpendingPlan( + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).toNumber(), + requestDetails, + limitDuration, ); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitService.shouldLimit(mode, methodName, txConstructorName, '', requestDetails); @@ -497,26 +573,34 @@ describe('HBAR Rate Limit Service', function () { }); describe('disable the rate limiter', function () { - const hbarLimitServiceDisabled = new HbarLimitService( - hbarSpendingPlanRepositoryStub, - evmAddressHbarSpendingPlanRepositoryStub, - ipAddressHbarSpendingPlanRepositoryStub, - logger, - register, - Hbar.fromTinybars(0), - limitDuration, - ); + let hbarLimitServiceDisabled: HbarLimitService; + + beforeEach(function () { + hbarLimitServiceDisabled = new HbarLimitService( + hbarSpendingPlanRepository, + evmAddressHbarSpendingPlanRepository, + ipAddressHbarSpendingPlanRepository, + logger, + register, + limitDuration, + ); + // @ts-ignore + hbarLimitServiceDisabled['isHBarRateLimiterEnabled'] = false; + }); it('should return false if the rate limiter is disabled by setting HBAR_RATE_LIMIT_TINYBAR to zero', async function () { - const spendingPlan = createSpendingPlan( + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + await hbarSpendingPlanRepository.create(SubscriptionTier.BASIC, requestDetails, limitDuration, mockPlanId); + await hbarSpendingPlanRepository.addToAmountSpent( mockPlanId, - HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().sub(mockEstimatedTxFee).add(1), + HbarLimitService.TIER_LIMITS[SubscriptionTier.BASIC].toTinybars().toNumber(), + requestDetails, + limitDuration, ); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); const result = await hbarLimitServiceDisabled.shouldLimit( mode, @@ -531,8 +615,9 @@ describe('HBAR Rate Limit Service', function () { expect(result).to.be.false; }); - it('should return undefined if the rate limiter is disabled and addExpense is called', async function () { + it('should not add expenses if the rate limiter is disabled and addExpense is called', async function () { expect(await hbarLimitServiceDisabled.addExpense(100, mockEvmAddress, requestDetails)).to.be.undefined; + expect(hbarSpendingPlanRepositorySpy.addToAmountSpent.notCalled).to.be.true; }); }); }); @@ -553,12 +638,17 @@ describe('HBAR Rate Limit Service', function () { }); it('should return spending plan for evmAddress if evmAddress is provided', async function () { - const spendingPlan = createSpendingPlan(mockPlanId); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + const spendingPlan = await hbarSpendingPlanRepository.create( + SubscriptionTier.BASIC, + requestDetails, + limitDuration, + mockPlanId, + ); const result = await hbarLimitService['getSpendingPlan'](mockEvmAddress, requestDetails); @@ -566,12 +656,17 @@ describe('HBAR Rate Limit Service', function () { }); it('should return spending plan for ipAddress if ipAddress is provided', async function () { - const spendingPlan = createSpendingPlan(mockPlanId); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress: mockIpAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + await ipAddressHbarSpendingPlanRepository.save( + { ipAddress: mockIpAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + const spendingPlan = await hbarSpendingPlanRepository.create( + SubscriptionTier.BASIC, + requestDetails, + limitDuration, + mockPlanId, + ); const result = await hbarLimitService['getSpendingPlan']('', requestDetails); @@ -579,20 +674,12 @@ describe('HBAR Rate Limit Service', function () { }); it('should return null if no spending plan is found for evmAddress', async function () { - const error = new EvmAddressHbarSpendingPlanNotFoundError(mockEvmAddress); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); - const result = await hbarLimitService['getSpendingPlan'](mockEvmAddress, requestDetails); - expect(result).to.be.null; }); it('should return null if no spending plan is found for ipAddress', async function () { - const error = new IPAddressHbarSpendingPlanNotFoundError(mockIpAddress); - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); - const result = await hbarLimitService['getSpendingPlan']('', requestDetails); - expect(result).to.be.null; }); }); @@ -605,37 +692,53 @@ describe('HBAR Rate Limit Service', function () { it('should handle error when getSpendingPlanByEvmAddress throws an EvmAddressHbarSpendingPlanNotFoundError', async function () { const error = new EvmAddressHbarSpendingPlanNotFoundError(mockEvmAddress); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); await testGetSpendingPlanByEvmAddressError(error, EvmAddressHbarSpendingPlanNotFoundError); }); it('should handle error when getSpendingPlanByEvmAddress throws an HbarSpendingPlanNotFoundError', async function () { + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); const error = new HbarSpendingPlanNotFoundError(mockPlanId); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.rejects(error); await testGetSpendingPlanByEvmAddressError(error, HbarSpendingPlanNotFoundError); }); it('should handle error when getSpendingPlanByEvmAddress throws an HbarSpendingPlanNotActiveError', async function () { + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + const spendingPlan = await hbarSpendingPlanRepository.create( + SubscriptionTier.BASIC, + requestDetails, + limitDuration, + mockPlanId, + ); + await cacheService.set( + `hbarSpendingPlan:${mockPlanId}`, + { ...spendingPlan, active: false }, + 'hbarLimitServiceTest', + requestDetails, + ); const error = new HbarSpendingPlanNotActiveError(mockPlanId); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.rejects(error); await testGetSpendingPlanByEvmAddressError(error, HbarSpendingPlanNotActiveError); }); it('should return the spending plan for the given evmAddress', async function () { - const spendingPlan = createSpendingPlan(mockPlanId); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + await evmAddressHbarSpendingPlanRepository.save( + { evmAddress: mockEvmAddress, planId: mockPlanId }, + requestDetails, + limitDuration, + ); + const spendingPlan = await hbarSpendingPlanRepository.create( + SubscriptionTier.BASIC, + requestDetails, + limitDuration, + mockPlanId, + ); const result = await hbarLimitService['getSpendingPlanByEvmAddress'](mockEvmAddress, requestDetails); @@ -643,68 +746,59 @@ describe('HBAR Rate Limit Service', function () { }); }); - describe('createBasicSpendingPlan', function () { - const testCreateBasicSpendingPlan = async (evmAddress: string, ipAddress?: string) => { + describe('createSpendingPlanForAddress', function () { + const testCreateSpendingPlanForAddress = async (evmAddress: string, ipAddress?: string) => { const requestDetails = new RequestDetails({ requestId: 'hbarLimitServiceTest', ipAddress: ipAddress ?? '' }); - const newSpendingPlan = createSpendingPlan(mockPlanId); - hbarSpendingPlanRepositoryStub.create.resolves(newSpendingPlan); - evmAddressHbarSpendingPlanRepositoryStub.save.resolves(); - const promise = hbarLimitService['createBasicSpendingPlan'](evmAddress, requestDetails); + const promise = hbarLimitService['createSpendingPlanForAddress'](evmAddress, requestDetails); if (evmAddress) { - await expect(promise).eventually.to.deep.equal(newSpendingPlan); - expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; - expect(ipAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.false; - expect(evmAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; + await expect(promise).eventually.to.deep.include({ + subscriptionTier: SubscriptionTier.BASIC, + active: true, + spendingHistory: [], + amountSpent: 0, + }); + expect(hbarSpendingPlanRepositorySpy.create.calledOnce).to.be.true; + expect(ipAddressHbarSpendingPlanRepositorySpy.save.calledOnce).to.be.false; + expect(evmAddressHbarSpendingPlanRepositorySpy.save.calledOnce).to.be.true; } else { await expect(promise).to.be.rejectedWith('Cannot create a spending plan without an associated evm address'); - expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.false; - expect(ipAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.false; - expect(evmAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.false; + expect(hbarSpendingPlanRepositorySpy.create.calledOnce).to.be.false; + expect(ipAddressHbarSpendingPlanRepositorySpy.save.calledOnce).to.be.false; + expect(evmAddressHbarSpendingPlanRepositorySpy.save.calledOnce).to.be.false; } }; it('should create a basic spending plan for the given evmAddress', async function () { - await testCreateBasicSpendingPlan(mockEvmAddress); + await testCreateSpendingPlanForAddress(mockEvmAddress); }); it('should create a basic spending plan and link it only to the given evmAddress, if also an ipAddress is available', async function () { - await testCreateBasicSpendingPlan(mockEvmAddress, '127.0.0.1'); + await testCreateSpendingPlanForAddress(mockEvmAddress, '127.0.0.1'); }); it('should throw an error if no evmAddress is provided', async function () { - await testCreateBasicSpendingPlan(''); + await testCreateSpendingPlanForAddress(''); }); }); describe('addExpense', function () { const testAddExpense = async (evmAddress: string, ipAddress: string, expense: number = 100) => { const otherPlanOfTheSameTier = createSpendingPlan(uuidV4(randomBytes(16)), 200); - const existingSpendingPlan = createSpendingPlan(mockPlanId, 0); - if (evmAddress) { - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress, - planId: mockPlanId, - }); - } else if (ipAddress) { - ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ipAddress, - planId: mockPlanId, - }); - } else { - hbarSpendingPlanRepositoryStub.create.resolves(existingSpendingPlan); - } - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(existingSpendingPlan); - hbarSpendingPlanRepositoryStub.addToAmountSpent.resolves(); - hbarSpendingPlanRepositoryStub.findAllActiveBySubscriptionTier.resolves([ + await cacheService.set( + `hbarSpendingPlan:${otherPlanOfTheSameTier.id}`, otherPlanOfTheSameTier, - { - ...existingSpendingPlan, - amountSpent: expense, - spendingHistory: [{ amount: expense, timestamp: new Date() }], - }, - ]); + 'hbarLimitServiceTest', + requestDetails, + ); + await cacheService.set( + `hbarSpendingPlan:${otherPlanOfTheSameTier.id}:amountSpent`, + otherPlanOfTheSameTier.amountSpent, + 'hbarLimitServiceTest', + requestDetails, + ); + const incUniqueSpendingPlansCounterSpy = sinon.spy( hbarLimitService['uniqueSpendingPlansCounter'][SubscriptionTier.BASIC], 'inc', @@ -715,36 +809,66 @@ describe('HBAR Rate Limit Service', function () { ); const updateAverageAmountSpentPerSubscriptionTierSpy = sinon.spy( hbarLimitService, - 'updateAverageAmountSpentPerSubscriptionTier' as any, + 'updateAverageAmountSpentPerSubscriptionTier', ); - await hbarLimitService.addExpense(expense, evmAddress, requestDetails); + const addExpensePromise = hbarLimitService.addExpense( + expense, + evmAddress, + new RequestDetails({ + ...requestDetails, + ipAddress, + }), + ); - expect(hbarSpendingPlanRepositoryStub.addToAmountSpent.calledOnceWith(mockPlanId, expense)).to.be.true; - expect(hbarLimitService['remainingBudget'].toTinybars().toNumber()).to.eq( - hbarLimitService['totalBudget'].toTinybars().sub(expense).toNumber(), + let operatorPlan: IDetailedHbarSpendingPlan; + if (evmAddress) { + await expect(addExpensePromise).to.eventually.be.fulfilled; + sinon.assert.calledTwice(hbarSpendingPlanRepositorySpy.addToAmountSpent); // once for operator and once for evm address + operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + sinon.assert.calledWith(hbarSpendingPlanRepositorySpy.addToAmountSpent.firstCall, operatorPlan.id, expense); + sinon.assert.calledWith(hbarSpendingPlanRepositorySpy.addToAmountSpent.secondCall, sinon.match.string, expense); + await Promise.all(updateAverageAmountSpentPerSubscriptionTierSpy.returnValues); + const expectedAverageUsage = Math.round((otherPlanOfTheSameTier.amountSpent + expense) / 2); + sinon.assert.calledOnceWithExactly(setAverageSpendingPlanAmountSpentGaugeSpy, expectedAverageUsage); + sinon.assert.calledOnceWithExactly(incUniqueSpendingPlansCounterSpy, 1); + } else { + await expect(addExpensePromise).to.eventually.be.fulfilled; + sinon.assert.calledWith( + loggerSpy.warn, + `${requestDetails.formattedRequestId} Cannot add expense to a spending plan without an evm address`, + ); + sinon.assert.calledOnce(hbarSpendingPlanRepositorySpy.addToAmountSpent); // only once for the operator + operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + sinon.assert.calledWith(hbarSpendingPlanRepositorySpy.addToAmountSpent.firstCall, operatorPlan.id, expense); + } + sinon.assert.calledWith(hbarSpendingPlanRepositorySpy.addToAmountSpent, sinon.match.string, expense); + expect((await hbarLimitService['getRemainingBudget'](requestDetails)).toTinybars().toNumber()).to.eq( + totalBudgetInTinybars - expense, ); expect((await hbarLimitService['hbarLimitRemainingGauge'].get()).values[0].value).to.equal( - hbarLimitService['totalBudget'].toTinybars().sub(expense).toNumber(), + totalBudgetInTinybars - expense, ); - await Promise.all(updateAverageAmountSpentPerSubscriptionTierSpy.returnValues); - const expectedAverageUsage = Math.round((otherPlanOfTheSameTier.amountSpent + expense) / 2); - sinon.assert.calledOnceWithExactly(setAverageSpendingPlanAmountSpentGaugeSpy, expectedAverageUsage); - sinon.assert.calledOnceWithExactly(incUniqueSpendingPlansCounterSpy, 1); + expect(operatorPlan.amountSpent).to.eq(expense); }; it('should create a basic spending plan if none exists', async function () { - const newSpendingPlan = createSpendingPlan(mockPlanId); - hbarSpendingPlanRepositoryStub.create.resolves(newSpendingPlan); - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects( - new EvmAddressHbarSpendingPlanNotFoundError(mockEvmAddress), - ); - evmAddressHbarSpendingPlanRepositoryStub.save.resolves(); - await hbarLimitService.addExpense(100, mockEvmAddress, requestDetails); - expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; - expect(evmAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; + expect(hbarSpendingPlanRepositorySpy.create.getCalls().length).to.eq(2); // one for operator and one for evm address + expect(evmAddressHbarSpendingPlanRepositorySpy.save.getCalls().length).to.eq(2); // same here + expect(hbarSpendingPlanRepositorySpy.create.getCalls()[0].calledWith(SubscriptionTier.OPERATOR)).to.be.true; + expect( + evmAddressHbarSpendingPlanRepositorySpy.save + .getCalls()[0] + .calledWith({ evmAddress: operatorAddress, planId: sinon.match.string }), + ).to.be.true; + expect(hbarSpendingPlanRepositorySpy.create.getCalls()[1].calledWith(SubscriptionTier.BASIC)).to.be.true; + expect( + evmAddressHbarSpendingPlanRepositorySpy.save + .getCalls()[1] + .calledWith({ evmAddress: mockEvmAddress, planId: sinon.match.string }), + ).to.be.true; }); it('should add the expense to the spending plan and update the remaining budget when both evmAddress and ipAddress are provided', async function () { @@ -760,23 +884,24 @@ describe('HBAR Rate Limit Service', function () { }); it('should handle errors when adding expense fails', async function () { - evmAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - evmAddress: mockEvmAddress, - planId: mockPlanId, - }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(createSpendingPlan(mockPlanId)); - hbarSpendingPlanRepositoryStub.addToAmountSpent.rejects(new Error('Failed to add expense')); - - await expect(hbarLimitService.addExpense(100, mockEvmAddress, requestDetails)).to.be.eventually.rejectedWith( - 'Failed to add expense', + await expect(hbarLimitService.addExpense(100, '', new RequestDetails({ ...requestDetails, ipAddress: '' }))).to.be + .eventually.fulfilled; + sinon.assert.calledWith( + loggerSpy.trace, + 'Cannot add expense to a spending plan without an evm address or ip address', ); }); }); describe('isTotalBudgetExceeded', function () { const testIsTotalBudgetExceeded = async (remainingBudget: number, expected: boolean) => { - // @ts-ignore - hbarLimitService.remainingBudget = Hbar.fromTinybars(remainingBudget); + const operatorPlan = await hbarLimitService['getOperatorSpendingPlan'](requestDetails); + await hbarSpendingPlanRepository.addToAmountSpent( + operatorPlan.id, + totalBudgetInTinybars - remainingBudget, + requestDetails, + limitDuration, + ); await expect( hbarLimitService['isTotalBudgetExceeded'](mode, methodName, txConstructorName, 0, requestDetails), ).to.eventually.equal(expected); diff --git a/packages/relay/tests/lib/services/metricService/metricService.spec.ts b/packages/relay/tests/lib/services/metricService/metricService.spec.ts index 2527f51126..3462192000 100644 --- a/packages/relay/tests/lib/services/metricService/metricService.spec.ts +++ b/packages/relay/tests/lib/services/metricService/metricService.spec.ts @@ -60,7 +60,6 @@ describe('Metric Service', function () { const mockedTxFee = 36900000; const operatorAccountId = `0.0.1022`; const mockedCallerName = 'caller_name'; - const mockedExecutionMode = 'exection_mode'; const mockedConstructorName = 'constructor_name'; const mockedInteractingEntity = 'interacting_entity'; const mockedTransactionId = '0.0.1022@1681130064.409933500'; @@ -103,19 +102,57 @@ describe('Metric Service', function () { ], } as unknown as TransactionRecord; + const verifyConsensusNodeClientHistogramGasFee = async () => { + // @ts-ignore + const gasMetricObject = (await metricService['consensusNodeClientHistogramGasFee'].get()).values.find( + (metric) => metric.metricName === metricHistogramGasFeeSumTitle, + )!; + + expect(gasMetricObject.metricName).to.eq(metricHistogramGasFeeSumTitle); + expect(gasMetricObject.labels.caller).to.eq(mockedCallerName); + expect(gasMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); + expect(gasMetricObject.value).to.eq( + mockedConsensusNodeTransactionRecord.contractFunctionResult?.gasUsed.toNumber(), + ); + }; + + const verifyConsensusNodeClientHistogramCost = async (executionMode: string, expectedTxRecordFee: number = 0) => { + const metricObjects = await metricService['consensusNodeClientHistogramCost'].get(); + + if (expectedTxRecordFee) { + const txRecordFeeMetricObject = metricObjects.values.find((metric) => { + return ( + metric.labels.mode === constants.EXECUTION_MODE.RECORD && metric.metricName === metricHistogramCostSumTitle + ); + }); + expect(txRecordFeeMetricObject?.metricName).to.eq(metricHistogramCostSumTitle); + expect(txRecordFeeMetricObject?.labels.caller).to.eq(mockedCallerName); + expect(txRecordFeeMetricObject?.labels.interactingEntity).to.eq(mockedInteractingEntity); + expect(txRecordFeeMetricObject?.value).to.eq(expectedTxRecordFee); + } + + const transactionFeeMetricObject = metricObjects.values.find((metric) => { + return metric.labels.mode === executionMode && metric.metricName === metricHistogramCostSumTitle; + }); + expect(transactionFeeMetricObject?.metricName).to.eq(metricHistogramCostSumTitle); + expect(transactionFeeMetricObject?.labels.caller).to.eq(mockedCallerName); + expect(transactionFeeMetricObject?.labels.interactingEntity).to.eq(mockedInteractingEntity); + expect(transactionFeeMetricObject?.value).to.eq(mockedTxFee); + }; + overrideEnvsInMochaDescribe({ OPERATOR_KEY_FORMAT: 'DER' }); before(() => { // consensus node client - const hederaNetwork = ConfigService.get('HEDERA_NETWORK')!; + const hederaNetwork = ConfigService.get('HEDERA_NETWORK')! as string; if (hederaNetwork in constants.CHAIN_IDS) { client = Client.forName(hederaNetwork); } else { client = Client.forNetwork(JSON.parse(hederaNetwork)); } client = client.setOperator( - AccountId.fromString(ConfigService.get('OPERATOR_ID_MAIN')!), - Utils.createPrivateKeyBasedOnFormat(ConfigService.get('OPERATOR_KEY_MAIN')!), + AccountId.fromString(ConfigService.get('OPERATOR_ID_MAIN')! as string), + Utils.createPrivateKeyBasedOnFormat(ConfigService.get('OPERATOR_KEY_MAIN')! as string), ); // mirror node client @@ -128,7 +165,7 @@ describe('Metric Service', function () { timeout: 20 * 1000, }); mirrorNodeClient = new MirrorNodeClient( - ConfigService.get('MIRROR_NODE_URL') || '', + (ConfigService.get('MIRROR_NODE_URL') as string) || '', logger.child({ name: `mirror-node` }), registry, new CacheService(logger.child({ name: `cache` }), registry), @@ -140,7 +177,6 @@ describe('Metric Service', function () { mock = new MockAdapter(instance); const duration = constants.HBAR_RATE_LIMIT_DURATION; - const total = constants.HBAR_RATE_LIMIT_TOTAL; eventEmitter = new EventEmitter(); @@ -154,7 +190,6 @@ describe('Metric Service', function () { ipAddressHbarSpendingPlanRepository, logger, register, - Hbar.fromTinybars(total), duration, ); @@ -185,30 +220,35 @@ describe('Metric Service', function () { originalCallerAddress: mockedOriginalCallerAddress, }; + const verifyMetrics = async (originalBudget: Hbar, expectedTxRecordFee: number) => { + // validate hbarLimitService + // note: since the query is made to consensus node, the total charged amount = txFee + txRecordFee + const updatedBudget = await hbarLimitService['getRemainingBudget'](requestDetails); + expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq( + mockedTxFee + expectedTxRecordFee, + ); + + await verifyConsensusNodeClientHistogramCost(constants.EXECUTION_MODE.TRANSACTION, expectedTxRecordFee); + await verifyConsensusNodeClientHistogramGasFee(); + }; + withOverriddenEnvsInMochaTest({ GET_RECORD_DEFAULT_TO_CONSENSUS_NODE: false }, () => { it('Should execute captureTransactionMetrics() by retrieving transaction record from MIRROR NODE client', async () => { mock .onGet(`transactions/${mockedTransactionIdFormatted}?nonce=0`) .reply(200, mockedMirrorNodeTransactionRecord); - const originalBudget = hbarLimitService['remainingBudget']; + const originalBudget = await hbarLimitService['getRemainingBudget'](requestDetails); // capture metrics await metricService.captureTransactionMetrics(mockedExecuteTransactionEventPayload); // validate hbarLimitService - const updatedBudget = hbarLimitService['remainingBudget']; + const updatedBudget = await hbarLimitService['getRemainingBudget'](requestDetails); expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq(mockedTxFee); // validate cost metrics - const costMetricObject = (await metricService['consensusNodeClientHistogramCost'].get()).values.find( - (metric) => metric.metricName === metricHistogramCostSumTitle, - ); - expect(costMetricObject).to.not.be.undefined; - expect(costMetricObject!.metricName).to.eq(metricHistogramCostSumTitle); - expect(costMetricObject!.labels.caller).to.eq(mockedCallerName); - expect(costMetricObject!.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(costMetricObject!.value).to.eq(mockedTxFee); + await verifyConsensusNodeClientHistogramCost(constants.EXECUTION_MODE.TRANSACTION); }); }); @@ -221,55 +261,12 @@ describe('Metric Service', function () { .stub(TransactionRecordQuery.prototype, 'execute') .resolves(mockedConsensusNodeTransactionRecord); - const originalBudget = hbarLimitService['remainingBudget']; + const originalBudget = await hbarLimitService['getRemainingBudget'](requestDetails); await metricService.captureTransactionMetrics(mockedExecuteTransactionEventPayload); - expect(transactionRecordStub.called).to.be.true; - // validate hbarLimitService - // note: since the query is made to consensus node, the total charged amount = txFee + txRecordFee - const updatedBudget = hbarLimitService['remainingBudget']; - expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq( - mockedTxFee + expectedTxRecordFee, - ); - - // validate cost metric - // @ts-ignore - const metricObjects = await metricService['consensusNodeClientHistogramCost'].get(); - const txRecordFeeMetricObject = metricObjects.values.find((metric) => { - return ( - metric.labels.mode === constants.EXECUTION_MODE.RECORD && metric.metricName === metricHistogramCostSumTitle - ); - }); - const transactionFeeMetricObject = metricObjects.values.find((metric) => { - return ( - metric.labels.mode === constants.EXECUTION_MODE.TRANSACTION && - metric.metricName === metricHistogramCostSumTitle - ); - }); - - expect(txRecordFeeMetricObject?.metricName).to.eq(metricHistogramCostSumTitle); - expect(txRecordFeeMetricObject?.labels.caller).to.eq(mockedCallerName); - expect(txRecordFeeMetricObject?.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(txRecordFeeMetricObject?.value).to.eq(expectedTxRecordFee); - - expect(transactionFeeMetricObject?.metricName).to.eq(metricHistogramCostSumTitle); - expect(transactionFeeMetricObject?.labels.caller).to.eq(mockedCallerName); - expect(transactionFeeMetricObject?.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(transactionFeeMetricObject?.value).to.eq(mockedTxFee); - - // validate gas metric - // @ts-ignore - const gasMetricObject = (await metricService['consensusNodeClientHistogramGasFee'].get()).values.find( - (metric) => metric.metricName === metricHistogramGasFeeSumTitle, - )!; - - expect(gasMetricObject.metricName).to.eq(metricHistogramGasFeeSumTitle); - expect(gasMetricObject.labels.caller).to.eq(mockedCallerName); - expect(gasMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(gasMetricObject.value).to.eq( - mockedConsensusNodeTransactionRecord.contractFunctionResult?.gasUsed.toNumber(), - ); + expect(transactionRecordStub.called).to.be.true; + await verifyMetrics(originalBudget, expectedTxRecordFee); }); }); @@ -282,7 +279,7 @@ describe('Metric Service', function () { .stub(TransactionRecordQuery.prototype, 'execute') .resolves(mockedConsensusNodeTransactionRecord); - const originalBudget = hbarLimitService['remainingBudget']; + const originalBudget = await hbarLimitService['getRemainingBudget'](requestDetails); // emitting an EXECUTE_TRANSACTION event to kick off capturing metrics process asynchronously eventEmitter.emit(constants.EVENTS.EXECUTE_TRANSACTION, mockedExecuteTransactionEventPayload); @@ -291,52 +288,7 @@ describe('Metric Service', function () { await new Promise((r) => setTimeout(r, 100)); expect(transactionRecordStub.called).to.be.true; - - // validate hbarLimitService - // note: since the query is made to consensus node, the total charged amount = txFee + txRecordFee - const updatedBudget = hbarLimitService['remainingBudget']; - - expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq( - mockedTxFee + expectedTxRecordFee, - ); - - // validate cost metric - // @ts-ignore - const metricObjects = await metricService['consensusNodeClientHistogramCost'].get(); - const txRecordFeeMetricObject = metricObjects.values.find((metric) => { - return ( - metric.labels.mode === constants.EXECUTION_MODE.RECORD && metric.metricName === metricHistogramCostSumTitle - ); - }); - const transactionFeeMetricObject = metricObjects.values.find((metric) => { - return ( - metric.labels.mode === constants.EXECUTION_MODE.TRANSACTION && - metric.metricName === metricHistogramCostSumTitle - ); - }); - - expect(txRecordFeeMetricObject?.metricName).to.eq(metricHistogramCostSumTitle); - expect(txRecordFeeMetricObject?.labels.caller).to.eq(mockedCallerName); - expect(txRecordFeeMetricObject?.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(txRecordFeeMetricObject?.value).to.eq(expectedTxRecordFee); - - expect(transactionFeeMetricObject?.metricName).to.eq(metricHistogramCostSumTitle); - expect(transactionFeeMetricObject?.labels.caller).to.eq(mockedCallerName); - expect(transactionFeeMetricObject?.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(transactionFeeMetricObject?.value).to.eq(mockedTxFee); - - // validate gas metric - // @ts-ignore - const gasMetricObject = (await metricService['consensusNodeClientHistogramGasFee'].get()).values.find( - (metric) => metric.metricName === metricHistogramGasFeeSumTitle, - )!; - - expect(gasMetricObject.metricName).to.eq(metricHistogramGasFeeSumTitle); - expect(gasMetricObject.labels.caller).to.eq(mockedCallerName); - expect(gasMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(gasMetricObject.value).to.eq( - mockedConsensusNodeTransactionRecord.contractFunctionResult?.gasUsed.toNumber(), - ); + await verifyMetrics(originalBudget, expectedTxRecordFee); }); }); }); @@ -344,7 +296,7 @@ describe('Metric Service', function () { describe('addExpenseAndCaptureMetrics', () => { const mockedGasUsed = mockedConsensusNodeTransactionRecord.contractFunctionResult!.gasUsed.toNumber(); const mockedExecuteQueryEventPayload: IExecuteQueryEventPayload = { - executionMode: mockedExecutionMode, + executionMode: constants.EXECUTION_MODE.QUERY, transactionId: mockedTransactionId, txConstructorName: mockedConstructorName, callerName: mockedCallerName, @@ -355,44 +307,26 @@ describe('Metric Service', function () { requestDetails, originalCallerAddress: mockedOriginalCallerAddress, }; + + const verifyMetrics = async (originalBudget: Hbar) => { + const updatedBudget = await hbarLimitService['getRemainingBudget'](requestDetails); + expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq(mockedTxFee); + + await verifyConsensusNodeClientHistogramCost(constants.EXECUTION_MODE.QUERY); + await verifyConsensusNodeClientHistogramGasFee(); + }; + it('should execute addExpenseAndCaptureMetrics() to capture metrics in HBAR limiter and metric registry', async () => { - const originalBudget = hbarLimitService['remainingBudget']; + const originalBudget = await hbarLimitService['getRemainingBudget'](requestDetails); // capture metrics await metricService.addExpenseAndCaptureMetrics(mockedExecuteQueryEventPayload); - // validate hbarLimitService - const updatedBudget = hbarLimitService['remainingBudget']; - - expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq(mockedTxFee); - - // validate cost metrics - // @ts-ignore - const costMetricObject = (await metricService['consensusNodeClientHistogramCost'].get()).values.find( - (metric) => metric.metricName === metricHistogramCostSumTitle, - )!; - - expect(costMetricObject.metricName).to.eq(metricHistogramCostSumTitle); - expect(costMetricObject.labels.caller).to.eq(mockedCallerName); - expect(costMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(costMetricObject.value).to.eq(mockedTxFee); - - // validate gas metric - // @ts-ignore - const gasMetricObject = (await metricService['consensusNodeClientHistogramGasFee'].get()).values.find( - (metric) => metric.metricName === metricHistogramGasFeeSumTitle, - )!; - - expect(gasMetricObject.metricName).to.eq(metricHistogramGasFeeSumTitle); - expect(gasMetricObject.labels.caller).to.eq(mockedCallerName); - expect(gasMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(gasMetricObject.value).to.eq( - mockedConsensusNodeTransactionRecord.contractFunctionResult?.gasUsed.toNumber(), - ); + await verifyMetrics(originalBudget); }); it('should listen to EXECUTE_QUERY event and kick off addExpenseAndCaptureMetrics()', async () => { - const originalBudget = hbarLimitService['remainingBudget']; + const originalBudget = await hbarLimitService['getRemainingBudget'](requestDetails); // emitting an EXECUTE_QUERY event to kick off capturing metrics process eventEmitter.emit(constants.EVENTS.EXECUTE_QUERY, mockedExecuteQueryEventPayload); @@ -400,32 +334,7 @@ describe('Metric Service', function () { // small wait for hbar rate limiter to settle await new Promise((r) => setTimeout(r, 100)); - // validate hbarLimitService - const updatedBudget = hbarLimitService['remainingBudget']; - expect(originalBudget.toTinybars().toNumber() - updatedBudget.toTinybars().toNumber()).to.eq(mockedTxFee); - - // validate cost metrics - // @ts-ignore - const costMetricObject = (await metricService['consensusNodeClientHistogramCost'].get()).values.find( - (metric) => metric.metricName === metricHistogramCostSumTitle, - )!; - expect(costMetricObject.metricName).to.eq(metricHistogramCostSumTitle); - expect(costMetricObject.labels.caller).to.eq(mockedCallerName); - expect(costMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(costMetricObject.value).to.eq(mockedTxFee); - - // validate gas metric - // @ts-ignore - const gasMetricObject = (await metricService['consensusNodeClientHistogramGasFee'].get()).values.find( - (metric) => metric.metricName === metricHistogramGasFeeSumTitle, - )!; - - expect(gasMetricObject.metricName).to.eq(metricHistogramGasFeeSumTitle); - expect(gasMetricObject.labels.caller).to.eq(mockedCallerName); - expect(gasMetricObject.labels.interactingEntity).to.eq(mockedInteractingEntity); - expect(gasMetricObject.value).to.eq( - mockedConsensusNodeTransactionRecord.contractFunctionResult?.gasUsed.toNumber(), - ); + await verifyMetrics(originalBudget); }); }); }); diff --git a/packages/server/tests/acceptance/hbarLimiter.spec.ts b/packages/server/tests/acceptance/hbarLimiter.spec.ts index cf8912c35d..a805d066fc 100644 --- a/packages/server/tests/acceptance/hbarLimiter.spec.ts +++ b/packages/server/tests/acceptance/hbarLimiter.spec.ts @@ -251,8 +251,19 @@ describe('@hbarlimiter HBAR Limiter Acceptance Tests', function () { }); afterEach(async () => { + const operatorPlans = await hbarSpendingPlanRepository.findAllActiveBySubscriptionTier( + [SubscriptionTier.OPERATOR], + requestDetails, + ); + expect(operatorPlans.length).to.be.eq(1); // sanity check + await hbarSpendingPlanRepository.resetAmountSpentOfAllPlans(requestDetails); + // we don't want to reset the operator account's spending, so we have to add the amount spent back + for (const plan of operatorPlans) { + await hbarSpendingPlanRepository.addToAmountSpent(plan.id, plan.amountSpent, requestDetails, mockTTL); + } + // Note: Since the total HBAR budget is shared across the entire Relay instance by multiple test cases, // and expense updates occur asynchronously, the wait below ensures that the HBAR amount has sufficient time // to update properly after each test.