diff --git a/packages/relay/src/lib/db/entities/hbarLimiter/ipAddressHbarSpendingPlan.ts b/packages/relay/src/lib/db/entities/hbarLimiter/ipAddressHbarSpendingPlan.ts new file mode 100644 index 0000000000..a60a982e5b --- /dev/null +++ b/packages/relay/src/lib/db/entities/hbarLimiter/ipAddressHbarSpendingPlan.ts @@ -0,0 +1,31 @@ +/* + * + * Hedera JSON RPC Relay + * + * Copyright (C) 2022-2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IIPAddressHbarSpendingPlan } from '../../types/hbarLimiter/ipAddressHbarSpendingPlan'; + +export class IPAddressHbarSpendingPlan implements IIPAddressHbarSpendingPlan { + ipAddress: string; + planId: string; + + constructor(data: IIPAddressHbarSpendingPlan) { + this.ipAddress = data.ipAddress; + this.planId = data.planId; + } +} diff --git a/packages/relay/src/lib/db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository.ts b/packages/relay/src/lib/db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository.ts new file mode 100644 index 0000000000..564cd301bf --- /dev/null +++ b/packages/relay/src/lib/db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository.ts @@ -0,0 +1,97 @@ +/* + * + * Hedera JSON RPC Relay + * + * Copyright (C) 2022-2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { CacheService } from '../../../services/cacheService/cacheService'; +import { Logger } from 'pino'; +import { IIPAddressHbarSpendingPlan } from '../../types/hbarLimiter/ipAddressHbarSpendingPlan'; +import { IPAddressHbarSpendingPlanNotFoundError } from '../../types/hbarLimiter/errors'; +import { IPAddressHbarSpendingPlan } from '../../entities/hbarLimiter/ipAddressHbarSpendingPlan'; + +export class IPAddressHbarSpendingPlanRepository { + private readonly collectionKey = 'ipAddressHbarSpendingPlan'; + private readonly threeMonthsInMillis = 90 * 24 * 60 * 60 * 1000; + + /** + * The cache service used for storing data. + * @private + */ + private readonly cache: CacheService; + + /** + * The logger used for logging all output from this class. + * @private + */ + private readonly logger: Logger; + + constructor(cache: CacheService, logger: Logger) { + this.cache = cache; + this.logger = logger; + } + + /** + * Finds an {@link IPAddressHbarSpendingPlan} for an IP address. + * + * @param {string} ipAddress - The IP address to search for. + * @returns {Promise} - The associated plan for the IP address. + */ + async findByAddress(ipAddress: string): Promise { + const key = this.getKey(ipAddress); + const addressPlan = await this.cache.getAsync(key, 'findByAddress'); + if (!addressPlan) { + throw new IPAddressHbarSpendingPlanNotFoundError(ipAddress); + } + this.logger.trace(`Retrieved IPAddressHbarSpendingPlan with address ${ipAddress}`); + return new IPAddressHbarSpendingPlan(addressPlan); + } + + /** + * Saves an {@link IPAddressHbarSpendingPlan} to the cache, linking the plan to the IP address. + * + * @param {IIPAddressHbarSpendingPlan} addressPlan - The plan to save. + * @returns {Promise} - A promise that resolves when the IP address is linked to the plan. + */ + async save(addressPlan: IIPAddressHbarSpendingPlan): Promise { + const key = this.getKey(addressPlan.ipAddress); + await this.cache.set(key, addressPlan, 'save', this.threeMonthsInMillis); + this.logger.trace(`Saved IPAddressHbarSpendingPlan with address ${addressPlan.ipAddress}`); + } + + /** + * Deletes an {@link IPAddressHbarSpendingPlan} from the cache, unlinking the plan from the IP address. + * + * @param {string} ipAddress - The IP address to unlink the plan from. + * @returns {Promise} - A promise that resolves when the IP address is unlinked from the plan. + */ + async delete(ipAddress: string): Promise { + const key = this.getKey(ipAddress); + await this.cache.delete(key, 'delete'); + this.logger.trace(`Deleted IPAddressHbarSpendingPlan with address ${ipAddress}`); + } + + /** + * Gets the cache key for an {@link IPAddressHbarSpendingPlan}. + * + * @param {string} ipAddress - The IP address to get the key for. + * @private + */ + private getKey(ipAddress: string): string { + return `${this.collectionKey}:${ipAddress}`; + } +} diff --git a/packages/relay/src/lib/db/types/hbarLimiter/errors.ts b/packages/relay/src/lib/db/types/hbarLimiter/errors.ts index 3ea4eaa291..1e089949c7 100644 --- a/packages/relay/src/lib/db/types/hbarLimiter/errors.ts +++ b/packages/relay/src/lib/db/types/hbarLimiter/errors.ts @@ -38,3 +38,10 @@ export class EthAddressHbarSpendingPlanNotFoundError extends Error { this.name = 'EthAddressHbarSpendingPlanNotFoundError'; } } + +export class IPAddressHbarSpendingPlanNotFoundError extends Error { + constructor(ipAddress: string) { + super(`IPAddressHbarSpendingPlan with address ${ipAddress} not found`); + this.name = 'IPAddressHbarSpendingPlanNotFoundError'; + } +} diff --git a/packages/relay/src/lib/db/types/hbarLimiter/ipAddressHbarSpendingPlan.ts b/packages/relay/src/lib/db/types/hbarLimiter/ipAddressHbarSpendingPlan.ts new file mode 100644 index 0000000000..5222f57ca2 --- /dev/null +++ b/packages/relay/src/lib/db/types/hbarLimiter/ipAddressHbarSpendingPlan.ts @@ -0,0 +1,24 @@ +/* + * + * Hedera JSON RPC Relay + * + * Copyright (C) 2022-2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export interface IIPAddressHbarSpendingPlan { + ipAddress: string; + planId: string; +} diff --git a/packages/relay/src/lib/services/hbarLimitService/index.ts b/packages/relay/src/lib/services/hbarLimitService/index.ts index 8424bd97a0..ca6e581800 100644 --- a/packages/relay/src/lib/services/hbarLimitService/index.ts +++ b/packages/relay/src/lib/services/hbarLimitService/index.ts @@ -26,6 +26,7 @@ import { SubscriptionType } from '../../db/types/hbarLimiter/subscriptionType'; import { IDetailedHbarSpendingPlan } from '../../db/types/hbarLimiter/hbarSpendingPlan'; import { HbarSpendingPlanRepository } from '../../db/repositories/hbarLimiter/hbarSpendingPlanRepository'; import { EthAddressHbarSpendingPlanRepository } from '../../db/repositories/hbarLimiter/ethAddressHbarSpendingPlanRepository'; +import { IPAddressHbarSpendingPlanRepository } from '../../db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository'; export class HbarLimitService implements IHbarLimitService { static readonly ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000; @@ -80,6 +81,7 @@ export class HbarLimitService implements IHbarLimitService { constructor( private readonly hbarSpendingPlanRepository: HbarSpendingPlanRepository, private readonly ethAddressHbarSpendingPlanRepository: EthAddressHbarSpendingPlanRepository, + private readonly ipAddressHbarSpendingPlanRepository: IPAddressHbarSpendingPlanRepository, private readonly logger: Logger, private readonly register: Registry, private readonly totalBudget: number, @@ -305,7 +307,11 @@ export class HbarLimitService implements IHbarLimitService { } } if (ipAddress) { - // TODO: Implement this with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 + try { + return await this.getSpendingPlanByIPAddress(ipAddress); + } catch (error) { + this.logger.warn(error, `Failed to get spending plan for IP address '${ipAddress}'`); + } } return null; } @@ -321,6 +327,17 @@ export class HbarLimitService implements IHbarLimitService { return this.hbarSpendingPlanRepository.findByIdWithDetails(ethAddressHbarSpendingPlan.planId); } + /** + * Gets the spending plan for the given IP address. + * @param {string} ipAddress - The IP address to get the spending plan for. + * @returns {Promise} - A promise that resolves with the spending plan. + * @private + */ + private async getSpendingPlanByIPAddress(ipAddress: string): Promise { + const ipAddressHbarSpendingPlan = await this.ipAddressHbarSpendingPlanRepository.findByAddress(ipAddress); + return this.hbarSpendingPlanRepository.findByIdWithDetails(ipAddressHbarSpendingPlan.planId); + } + /** * Creates a basic spending plan for the given eth address. * @param {string} ethAddress - The eth address to create the spending plan for. @@ -337,10 +354,10 @@ export class HbarLimitService implements IHbarLimitService { if (ethAddress) { this.logger.trace(`Linking spending plan with ID ${spendingPlan.id} to eth address ${ethAddress}`); await this.ethAddressHbarSpendingPlanRepository.save({ ethAddress, planId: spendingPlan.id }); - } else if (ipAddress) { + } + if (ipAddress) { this.logger.trace(`Linking spending plan with ID ${spendingPlan.id} to ip address ${ipAddress}`); - // TODO: Implement this with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 - // await this.ipAddressHbarSpendingPlanRepository.save({ ipAddress, planId: spendingPlan.id }); + await this.ipAddressHbarSpendingPlanRepository.save({ ipAddress, planId: spendingPlan.id }); } return spendingPlan; } diff --git a/packages/relay/tests/lib/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository.spec.ts b/packages/relay/tests/lib/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository.spec.ts new file mode 100644 index 0000000000..2debf7cee8 --- /dev/null +++ b/packages/relay/tests/lib/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository.spec.ts @@ -0,0 +1,130 @@ +/* + * + * Hedera JSON RPC Relay + * + * Copyright (C) 2022-2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import chai, { expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { IPAddressHbarSpendingPlanRepository } from '../../../../src/lib/db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository'; +import { CacheService } from '../../../../src/lib/services/cacheService/cacheService'; +import pino from 'pino'; +import { IIPAddressHbarSpendingPlan } from '../../../../src/lib/db/types/hbarLimiter/ipAddressHbarSpendingPlan'; +import { IPAddressHbarSpendingPlanNotFoundError } from '../../../../src/lib/db/types/hbarLimiter/errors'; +import { randomBytes, uuidV4 } from 'ethers'; +import { Registry } from 'prom-client'; +import { useInMemoryRedisServer } from '../../../helpers'; + +chai.use(chaiAsPromised); + +describe('IPAddressHbarSpendingPlanRepository', function () { + const logger = pino(); + const registry = new Registry(); + + const tests = (isSharedCacheEnabled: boolean) => { + let cacheService: CacheService; + let repository: IPAddressHbarSpendingPlanRepository; + const ipAddress = '555.555.555.555'; + const nonExistingIpAddress = 'xxx.xxx.xxx.xxx'; + + if (isSharedCacheEnabled) { + useInMemoryRedisServer(logger, 6383); + } + + before(() => { + cacheService = new CacheService(logger.child({ name: 'CacheService' }), registry); + repository = new IPAddressHbarSpendingPlanRepository( + cacheService, + logger.child({ name: 'IPAddressHbarSpendingPlanRepository' }), + ); + }); + + after(async () => { + await cacheService.disconnectRedisClient(); + }); + + describe('findByAddress', () => { + it('retrieves an address plan by ip', async () => { + const addressPlan: IIPAddressHbarSpendingPlan = { ipAddress, planId: uuidV4(randomBytes(16)) }; + await cacheService.set(`${repository['collectionKey']}:${ipAddress}`, addressPlan, 'test'); + + const result = await repository.findByAddress(ipAddress); + expect(result).to.deep.equal(addressPlan); + }); + + it('throws an error if address plan is not found', async () => { + await expect(repository.findByAddress(nonExistingIpAddress)).to.be.eventually.rejectedWith( + IPAddressHbarSpendingPlanNotFoundError, + `IPAddressHbarSpendingPlan with address ${nonExistingIpAddress} not found`, + ); + }); + }); + + describe('save', () => { + it('saves an address plan successfully', async () => { + const addressPlan: IIPAddressHbarSpendingPlan = { ipAddress, planId: uuidV4(randomBytes(16)) }; + + await repository.save(addressPlan); + const result = await cacheService.getAsync( + `${repository['collectionKey']}:${ipAddress}`, + 'test', + ); + expect(result).to.deep.equal(addressPlan); + }); + + it('overwrites an existing address plan', async () => { + const addressPlan: IIPAddressHbarSpendingPlan = { ipAddress, planId: uuidV4(randomBytes(16)) }; + await cacheService.set(`${repository['collectionKey']}:${ipAddress}`, addressPlan, 'test'); + + const newPlanId = uuidV4(randomBytes(16)); + const newAddressPlan: IIPAddressHbarSpendingPlan = { ipAddress, planId: newPlanId }; + await repository.save(newAddressPlan); + const result = await cacheService.getAsync( + `${repository['collectionKey']}:${ipAddress}`, + 'test', + ); + expect(result).to.deep.equal(newAddressPlan); + }); + }); + + describe('delete', () => { + it('deletes an address plan successfully', async () => { + const addressPlan: IIPAddressHbarSpendingPlan = { ipAddress, planId: uuidV4(randomBytes(16)) }; + await cacheService.set(`${repository['collectionKey']}:${ipAddress}`, addressPlan, 'test'); + + await repository.delete(ipAddress); + const result = await cacheService.getAsync( + `${repository['collectionKey']}:${ipAddress}`, + 'test', + ); + expect(result).to.be.null; + }); + + it('does not throw an error if address plan to delete does not exist', async () => { + await expect(repository.delete(nonExistingIpAddress)).to.be.fulfilled; + }); + }); + }; + + describe('with shared cache', () => { + tests(true); + }); + + describe('without shared cache', () => { + tests(false); + }); +}); diff --git a/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts b/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts index 35d78eb231..2bbd1a3985 100644 --- a/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts +++ b/packages/relay/tests/lib/services/hbarLimitService/hbarLimitService.spec.ts @@ -31,38 +31,43 @@ import { SubscriptionType } from '../../../../src/lib/db/types/hbarLimiter/subsc import { HbarSpendingPlan } from '../../../../src/lib/db/entities/hbarLimiter/hbarSpendingPlan'; import { HbarSpendingPlanRepository } from '../../../../src/lib/db/repositories/hbarLimiter/hbarSpendingPlanRepository'; import { EthAddressHbarSpendingPlanRepository } from '../../../../src/lib/db/repositories/hbarLimiter/ethAddressHbarSpendingPlanRepository'; +import { IPAddressHbarSpendingPlanRepository } from '../../../../src/lib/db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository'; import { HbarSpendingPlanNotFoundError, HbarSpendingPlanNotActiveError, EthAddressHbarSpendingPlanNotFoundError, + IPAddressHbarSpendingPlanNotFoundError, } from '../../../../src/lib/db/types/hbarLimiter/errors'; chai.use(chaiAsPromised); describe('HbarLimitService', function () { const logger = pino(); + const register = new Registry(); const totalBudget = 100_000; - const mockIpAddress = 'x.x.x'; - const mockEstimatedTxFee = 300; + const mode = constants.EXECUTION_MODE.TRANSACTION; const methodName = 'testMethod'; const mockEthAddress = '0x123'; - const register = new Registry(); + const mockIpAddress = 'x.x.x'; + const mockEstimatedTxFee = 300; const mockRequestId = getRequestId(); const mockPlanId = uuidV4(randomBytes(16)); - const mode = constants.EXECUTION_MODE.TRANSACTION; let hbarLimitService: HbarLimitService; let hbarSpendingPlanRepositoryStub: sinon.SinonStubbedInstance; let ethAddressHbarSpendingPlanRepositoryStub: sinon.SinonStubbedInstance; + let ipAddressHbarSpendingPlanRepositoryStub: sinon.SinonStubbedInstance; let loggerSpy: sinon.SinonSpiedInstance; beforeEach(function () { loggerSpy = sinon.spy(logger); hbarSpendingPlanRepositoryStub = sinon.createStubInstance(HbarSpendingPlanRepository); ethAddressHbarSpendingPlanRepositoryStub = sinon.createStubInstance(EthAddressHbarSpendingPlanRepository); + ipAddressHbarSpendingPlanRepositoryStub = sinon.createStubInstance(IPAddressHbarSpendingPlanRepository); hbarLimitService = new HbarLimitService( hbarSpendingPlanRepositoryStub, ethAddressHbarSpendingPlanRepositoryStub, + ipAddressHbarSpendingPlanRepositoryStub, logger, register, totalBudget, @@ -106,144 +111,288 @@ describe('HbarLimitService', function () { }); describe('shouldLimit', function () { - it('should return true if the total daily budget is exceeded', async function () { - // @ts-ignore - hbarLimitService.remainingBudget = 0; - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); - expect(result).to.be.true; - }); + describe('based on ethAddress', async function () { + it('should return true if the total daily budget is exceeded', async function () { + // @ts-ignore + hbarLimitService.remainingBudget = 0; + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + expect(result).to.be.true; + }); - it('should return true when remainingBudget < estimatedTxFee ', async function () { - // @ts-ignore - hbarLimitService.remainingBudget = mockEstimatedTxFee - 1; - const result = await hbarLimitService.shouldLimit( - mode, - methodName, - mockEthAddress, - mockRequestId, - mockIpAddress, - mockEstimatedTxFee, - ); - expect(result).to.be.true; - }); + it('should return true when remainingBudget < estimatedTxFee ', async function () { + // @ts-ignore + hbarLimitService.remainingBudget = mockEstimatedTxFee - 1; + const result = await hbarLimitService.shouldLimit( + mode, + methodName, + mockEthAddress, + mockRequestId, + mockIpAddress, + mockEstimatedTxFee, + ); + expect(result).to.be.true; + }); - it('should create a basic spending plan if none exists for the ethAddress', async function () { - const newSpendingPlan = createSpendingPlan(mockPlanId); - const error = new EthAddressHbarSpendingPlanNotFoundError(mockEthAddress); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); - hbarSpendingPlanRepositoryStub.create.resolves(newSpendingPlan); - ethAddressHbarSpendingPlanRepositoryStub.save.resolves(); + it('should create a basic spending plan if none exists for the ethAddress', async function () { + const newSpendingPlan = createSpendingPlan(mockPlanId); + const error = new EthAddressHbarSpendingPlanNotFoundError(mockEthAddress); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); + hbarSpendingPlanRepositoryStub.create.resolves(newSpendingPlan); + ethAddressHbarSpendingPlanRepositoryStub.save.resolves(); - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); - expect(result).to.be.false; - expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; - expect(ethAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; - expect( - loggerSpy.warn.calledWithMatch( - sinon.match.instanceOf(EthAddressHbarSpendingPlanNotFoundError), - `Failed to get spending plan for eth address '${mockEthAddress}'`, - ), - ).to.be.true; - }); + expect(result).to.be.false; + expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; + expect(ethAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; + expect( + loggerSpy.warn.calledWithMatch( + sinon.match.instanceOf(EthAddressHbarSpendingPlanNotFoundError), + `Failed to get spending plan for eth address '${mockEthAddress}'`, + ), + ).to.be.true; + }); - it('should return false if ethAddress is null or empty', async function () { - const result = await hbarLimitService.shouldLimit(mode, methodName, ''); - expect(result).to.be.false; - }); + it('should return false if ethAddress is null or empty', async function () { + const result = await hbarLimitService.shouldLimit(mode, methodName, ''); + expect(result).to.be.false; + }); - it('should return true if spentToday is exactly at the limit', async function () { - const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC]); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ethAddress: mockEthAddress, - planId: mockPlanId, + it('should return true if spentToday is exactly at the limit', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC]); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + + expect(result).to.be.true; }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + it('should return false if spentToday is just below the limit', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - 1); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - expect(result).to.be.true; - }); + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); - it('should return false if spentToday is just below the limit', async function () { - const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - 1); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ethAddress: mockEthAddress, - planId: mockPlanId, + expect(result).to.be.false; }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + it('should return true if spentToday is just above the limit', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] + 1); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - expect(result).to.be.false; - }); + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); - it('should return true if spentToday is just above the limit', async function () { - const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] + 1); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ethAddress: mockEthAddress, - planId: mockPlanId, + expect(result).to.be.true; }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + it('should return true if spentToday + estimatedTxFee is above the limit', async function () { + const spendingPlan = createSpendingPlan( + mockPlanId, + HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee + 1, + ); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit( + mode, + methodName, + mockEthAddress, + mockRequestId, + mockIpAddress, + mockEstimatedTxFee, + ); - expect(result).to.be.true; - }); + expect(result).to.be.true; + }); - it('should return true if spentToday + estimatedTxFee is above the limit', async function () { - const spendingPlan = createSpendingPlan( - mockPlanId, - HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee + 1, - ); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ethAddress: mockEthAddress, - planId: mockPlanId, + it('should return false if spentToday + estimatedTxFee is below the limit', async function () { + const spendingPlan = createSpendingPlan( + mockPlanId, + HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee - 1, + ); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + + expect(result).to.be.false; }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - const result = await hbarLimitService.shouldLimit( - mode, - methodName, - mockEthAddress, - mockRequestId, - mockIpAddress, - mockEstimatedTxFee, - ); + it('should return false if spentToday + estimatedTxFee is at the limit', async function () { + const spendingPlan = createSpendingPlan( + mockPlanId, + HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee, + ); + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); - expect(result).to.be.true; + expect(result).to.be.false; + }); }); - it('should return false if spentToday + estimatedTxFee is below the limit', async function () { - const spendingPlan = createSpendingPlan( - mockPlanId, - HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee - 1, - ); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ethAddress: mockEthAddress, - planId: mockPlanId, + describe('based on ipAddress', async function () { + it('should return true if the total daily budget is exceeded', async function () { + // @ts-ignore + hbarLimitService.remainingBudget = 0; + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); + expect(result).to.be.true; }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + it('should return true when remainingBudget < estimatedTxFee ', async function () { + // @ts-ignore + hbarLimitService.remainingBudget = mockEstimatedTxFee - 1; + const result = await hbarLimitService.shouldLimit( + mode, + methodName, + '', + mockRequestId, + mockIpAddress, + mockEstimatedTxFee, + ); + expect(result).to.be.true; + }); - expect(result).to.be.false; - }); + it('should create a basic spending plan if none exists for the ipAddress', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, 0); + const error = new IPAddressHbarSpendingPlanNotFoundError(mockIpAddress); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); + hbarSpendingPlanRepositoryStub.create.resolves(spendingPlan); + ipAddressHbarSpendingPlanRepositoryStub.save.resolves(); + + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); + + expect(result).to.be.false; + expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; + expect(ipAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; + expect( + loggerSpy.warn.calledWithMatch( + sinon.match.instanceOf(IPAddressHbarSpendingPlanNotFoundError), + `Failed to get spending plan for IP address '${mockIpAddress}'`, + ), + ).to.be.true; + }); - it('should return false if spentToday + estimatedTxFee is at the limit', async function () { - const spendingPlan = createSpendingPlan( - mockPlanId, - HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee, - ); - ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ - ethAddress: mockEthAddress, - planId: mockPlanId, + it('should return false if ipAddress is null or empty', async function () { + const result = await hbarLimitService.shouldLimit(mode, methodName, '', ''); + expect(result).to.be.false; }); - hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); - const result = await hbarLimitService.shouldLimit(mode, methodName, mockEthAddress); + it('should return true if spentToday is exactly at the limit', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC]); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); + + expect(result).to.be.true; + }); + + it('should return false if spentToday is just below the limit', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - 1); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); - expect(result).to.be.false; + expect(result).to.be.false; + }); + + it('should return true if spentToday is just above the limit', async function () { + const spendingPlan = createSpendingPlan(mockPlanId, HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] + 1); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); + + expect(result).to.be.true; + }); + + it('should return true if spentToday + estimatedTxFee is above the limit', async function () { + const spendingPlan = createSpendingPlan( + mockPlanId, + HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee + 1, + ); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit( + mode, + methodName, + '', + mockRequestId, + mockIpAddress, + mockEstimatedTxFee, + ); + + expect(result).to.be.true; + }); + + it('should return false if spentToday + estimatedTxFee is below the limit', async function () { + const spendingPlan = createSpendingPlan( + mockPlanId, + HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee - 1, + ); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); + + expect(result).to.be.false; + }); + + it('should return false if spentToday + estimatedTxFee is at the limit', async function () { + const spendingPlan = createSpendingPlan( + mockPlanId, + HbarLimitService.DAILY_LIMITS[SubscriptionType.BASIC] - mockEstimatedTxFee, + ); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService.shouldLimit(mode, methodName, '', mockIpAddress); + + expect(result).to.be.false; + }); }); }); @@ -275,7 +424,16 @@ describe('HbarLimitService', function () { }); it('should return spending plan for ipAddress if ipAddress is provided', async function () { - // TODO: Implement this with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 + const spendingPlan = createSpendingPlan(mockPlanId); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress: mockIpAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(spendingPlan); + + const result = await hbarLimitService['getSpendingPlan']('', mockIpAddress); + + expect(result).to.deep.equal(spendingPlan); }); it('should return null if no spending plan is found for ethAddress', async function () { @@ -288,7 +446,12 @@ describe('HbarLimitService', function () { }); it('should return null if no spending plan is found for ipAddress', async function () { - // TODO: Implement this with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 + const error = new IPAddressHbarSpendingPlanNotFoundError(mockIpAddress); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.rejects(error); + + const result = await hbarLimitService['getSpendingPlan']('', mockIpAddress); + + expect(result).to.be.null; }); }); @@ -350,12 +513,9 @@ describe('HbarLimitService', function () { expect(hbarSpendingPlanRepositoryStub.create.calledOnce).to.be.true; if (ethAddress) { expect(ethAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.true; - // TODO: Uncomment this with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 - // expect(ipAddressHbarSpendingPlanRepositoryStub.save.notCalled).to.be.true; - } else { - expect(ethAddressHbarSpendingPlanRepositoryStub.save.notCalled).to.be.true; - // TODO: Uncomment this with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 - // expect(ipAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.eq(!!ipAddress); + } + if (ipAddress) { + expect(ipAddressHbarSpendingPlanRepositoryStub.save.calledOnce).to.be.eq(!!ipAddress); } }; @@ -363,17 +523,21 @@ describe('HbarLimitService', function () { await testCreateBasicSpendingPlan(mockEthAddress); }); + it('should create a basic spending plan for the given ipAddress', async function () { + await testCreateBasicSpendingPlan('', mockIpAddress); + }); + it('should create a basic spending plan and link it to the ETH address if both ethAddress and ipAddress are provided', async function () { await testCreateBasicSpendingPlan(mockEthAddress, '127.0.0.1'); }); - it('should create a basic spending plan for the given ipAddress', async function () { - await testCreateBasicSpendingPlan('', '127.0.0.1'); + it('should create a basic spending plan if none exists', async function () { + await testCreateBasicSpendingPlan(mockEthAddress, '127.0.0.1'); }); }); describe('addExpense', function () { - const testAddExpense = async (ethAddress: string, ipAddress?: string, expense: number = 100) => { + const testAddExpense = async (ethAddress: string, ipAddress: string, expense: number = 100) => { const otherPlanUsedToday = createSpendingPlan(uuidV4(randomBytes(16)), 200); const existingSpendingPlan = createSpendingPlan(mockPlanId, 0); if (ethAddress) { @@ -382,10 +546,10 @@ describe('HbarLimitService', function () { planId: mockPlanId, }); } else if (ipAddress) { - // TODO: Uncomment with https://github.com/hashgraph/hedera-json-rpc-relay/issues/2888 - // ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves(existingSpendingPlan); - // TODO: Remove this line after uncommenting the line above - hbarSpendingPlanRepositoryStub.create.resolves(existingSpendingPlan); + ipAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ipAddress, + planId: mockPlanId, + }); } else { hbarSpendingPlanRepositoryStub.create.resolves(existingSpendingPlan); } @@ -499,6 +663,19 @@ describe('HbarLimitService', function () { await testIsDailyBudgetExceeded(-1, true); }); + it('should handle errors when adding expense fails', async function () { + ethAddressHbarSpendingPlanRepositoryStub.findByAddress.resolves({ + ethAddress: mockEthAddress, + planId: mockPlanId, + }); + hbarSpendingPlanRepositoryStub.findByIdWithDetails.resolves(createSpendingPlan(mockPlanId)); + hbarSpendingPlanRepositoryStub.addAmountToSpentToday.rejects(new Error('Failed to add expense')); + + await expect(hbarLimitService.addExpense(100, mockEthAddress)).to.be.eventually.rejectedWith( + 'Failed to add expense', + ); + }); + it('should return false when the remaining budget is greater than zero', async function () { await testIsDailyBudgetExceeded(100, false); });