diff --git a/docs/configuration.md b/docs/configuration.md index d516ce06a..c43e52cde 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,7 +75,7 @@ Unless you need to set a non-default value, it is recommended to only populate o | `HBAR_RATE_LIMIT_BASIC` | "1120000000" | Individual limit (in tinybars) for spending plans with a BASIC tier. Defaults to 11.2 HBARs. | | `HBAR_RATE_LIMIT_EXTENDED` | "3200000000" | Individual limit (in tinybars) for spending plans with a EXTENDED tier. Defaults to 32 HBARs. | | `HBAR_RATE_LIMIT_PRIVILEGED` | "8000000000" | Individual limit (in tinybars) for spending plans with a PRIVILEGED tier. Defaults to 80 HBARs. | -| `HBAR_SPENDING_PLANS_CONFIG_FILE` | "spendingPlansConfig.json" | The name of the JSON file containing the pre-configured spending plans for supported projects and partner projects. | +| `HBAR_SPENDING_PLANS_CONFIG` | "spendingPlansConfig.json" | The environment variable that either points to a file containing the spending plans, or the JSON content defining the spending plans. | | `HAPI_CLIENT_DURATION_RESET` | "3600000" | Time until client reinitialization. (ms) | | `HAPI_CLIENT_ERROR_RESET` | [21, 50] | Array of status codes, which when encountered will trigger a reinitialization. Status codes are availble [here](https://github.com/hashgraph/hedera-protobufs/blob/main/services/response_code.proto). | | `HAPI_CLIENT_TRANSACTION_RESET` | "50" | Number of transaction executions, until client reinitialization. | diff --git a/docs/design/hbar-limiter.md b/docs/design/hbar-limiter.md index 9e2c29c84..3ba84649a 100644 --- a/docs/design/hbar-limiter.md +++ b/docs/design/hbar-limiter.md @@ -12,6 +12,9 @@ - [Early Detection and Prevention (Preemptive Rate Limit)](#early-detection-and-prevention-preemptive-rate-limit) - [Architecture](#architecture) - [High-Level Design](#high-level-design) + - [What is an HbarSpendingPlan?](#what-is-an-hbarspendingplan) + - [General Users (BASIC tier):](#general-users-basic-tier) + - [Supported Projects (EXTENDED tier) and Trusted Partners (PRIVILEGED tier):](#supported-projects-extended-tier-and-trusted-partners-privileged-tier) - [Class Diagram](#class-diagram) - [Service Layer](#service-layer) - [Database Layer:](#database-layer) @@ -21,6 +24,12 @@ - [Allocation Algorithm](#allocation-algorithm) - [Configurations](#configurations) - [Pre-populating the Cache with Spending Plans for Supported Projects and Partner Projects](#pre-populating-the-cache-with-spending-plans-for-supported-projects-and-partner-projects) + - [JSON Configuration File](#json-configuration-file) + - [The JSON file should have the following structure:](#the-json-file-should-have-the-following-structure) + - [Important notes](#important-notes) + - [Incremental changes to the JSON file](#incremental-changes-to-the-json-file) + - [Adding new partners or supported projects](#adding-new-partners-or-supported-projects) + - [Removing or updating existing partners or supported projects](#removing-or-updating-existing-partners-or-supported-projects) - [Spending Limits of Different Tiers](#spending-limits-of-different-tiers) - [Total Budget and Limit Duration](#total-budget-and-limit-duration) - [Additional Considerations](#additional-considerations) @@ -319,8 +328,8 @@ All other users (ETH and IP addresses which are not specified in the configurati The relay will read the pre-configured spending plans from a JSON file. This file should be placed in the root directory of the relay. -The default filename for the configuration file is `spendingPlansConfig.json`, but it could also be specified by the environment variable `HBAR_SPENDING_PLANS_CONFIG_FILE`. -- `HBAR_SPENDING_PLANS_CONFIG_FILE`: The name of the file containing the pre-configured spending plans for supported projects and partners. +The default filename for the configuration file is `spendingPlansConfig.json`, but it could also be specified by the environment variable `HBAR_SPENDING_PLANS_CONFIG`. +- `HBAR_SPENDING_PLANS_CONFIG`: The name of the file or environment variable containing the pre-configured spending plans for supported projects and partners. #### The JSON file should have the following structure: ```json diff --git a/packages/config-service/src/services/globalConfig.ts b/packages/config-service/src/services/globalConfig.ts index 5f0f7c02c..c45c5c2b7 100644 --- a/packages/config-service/src/services/globalConfig.ts +++ b/packages/config-service/src/services/globalConfig.ts @@ -321,8 +321,8 @@ export class GlobalConfig { required: true, defaultValue: null, }, - HBAR_SPENDING_PLANS_CONFIG_FILE: { - envName: 'HBAR_SPENDING_PLANS_CONFIG_FILE', + HBAR_SPENDING_PLANS_CONFIG: { + envName: 'HBAR_SPENDING_PLANS_CONFIG', type: 'string', required: false, defaultValue: 'spendingPlansConfig.json', diff --git a/packages/config-service/tests/src/services/loggerService.spec.ts b/packages/config-service/tests/src/services/loggerService.spec.ts index dd5a1bb7d..e33faa0f1 100644 --- a/packages/config-service/tests/src/services/loggerService.spec.ts +++ b/packages/config-service/tests/src/services/loggerService.spec.ts @@ -37,7 +37,7 @@ describe('LoggerService tests', async function () { }); it('should be able to mask every value if it starts with known secret prefix', async () => { - const { envName } = GlobalConfig.ENTRIES.HBAR_SPENDING_PLANS_CONFIG_FILE; + const { envName } = GlobalConfig.ENTRIES.OPERATOR_KEY_MAIN; for (const prefix of LoggerService.KNOWN_SECRET_PREFIXES) { const value = prefix + '_VVurqVVh68wgxgcVjrvVVVcNcVVVVi3CRwl1'; diff --git a/packages/relay/src/lib/clients/cache/localLRUCache.ts b/packages/relay/src/lib/clients/cache/localLRUCache.ts index ab2250b53..5a7262838 100644 --- a/packages/relay/src/lib/clients/cache/localLRUCache.ts +++ b/packages/relay/src/lib/clients/cache/localLRUCache.ts @@ -251,6 +251,7 @@ export class LocalLRUCache implements ICacheClient { */ public async clear(): Promise { this.cache.clear(); + this.reservedCache?.clear(); } /** diff --git a/packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts b/packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts index ba10371d9..5e5cd93dd 100644 --- a/packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts +++ b/packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts @@ -36,6 +36,7 @@ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services' * It reads the pre-configured spending plans from a JSON file and populates the cache with them. * * @see SpendingPlanConfig + * @see SPENDING_PLANS_CONFIG */ export class HbarSpendingPlanConfigService { /** @@ -127,17 +128,33 @@ export class HbarSpendingPlanConfigService { * @private */ private static loadSpendingPlansConfig(logger: Logger): SpendingPlanConfig[] { - const filename = String(ConfigService.get('HBAR_SPENDING_PLANS_CONFIG_FILE')); - const configPath = findConfig(filename); - if (!configPath || !fs.existsSync(configPath)) { - logger.trace(`Configuration file not found at path "${configPath ?? filename}"`); + const spendingPlanConfig = ConfigService.get('HBAR_SPENDING_PLANS_CONFIG') as string; + + if (!spendingPlanConfig) { + logger.trace('HBAR_SPENDING_PLANS_CONFIG is undefined'); return []; } + + // Try to parse the value directly as JSON try { - const rawData = fs.readFileSync(configPath, 'utf-8'); - return JSON.parse(rawData) as SpendingPlanConfig[]; - } catch (error: any) { - throw new Error(`Failed to parse JSON from ${configPath}: ${error.message}`); + return JSON.parse(spendingPlanConfig) as SpendingPlanConfig[]; + } catch (jsonParseError: any) { + // If parsing as JSON fails, treat it as a file path + logger.trace( + `Failed to parse HBAR_SPENDING_PLAN as JSON: ${jsonParseError.message}, now treating it as a file path...`, + ); + try { + const configFilePath = findConfig(spendingPlanConfig); + if (configFilePath && fs.existsSync(configFilePath)) { + const fileContent = fs.readFileSync(configFilePath, 'utf-8'); + return JSON.parse(fileContent) as SpendingPlanConfig[]; + } else { + logger.trace(`HBAR Spending Configuration file not found at path "${configFilePath ?? spendingPlanConfig}"`); + return []; + } + } catch (fileError: any) { + throw new Error(`File error: ${fileError.message}`); + } } } @@ -319,7 +336,7 @@ export class HbarSpendingPlanConfigService { /** * Deletes obsolete ETH address associations from the cache. * - * For example, if an ETH address is associated with a plan different from the one in the {@link SPENDING_PLANS_CONFIG_FILE}, + * For example, if an ETH address is associated with a plan different from the one in the {@link SPENDING_PLANS_CONFIG}, * the association is deleted from the cache to allow the new association from the configuration file to take effect. * * @param {SpendingPlanConfig} planConfig - The spending plan configuration. @@ -347,7 +364,7 @@ export class HbarSpendingPlanConfigService { /** * Deletes obsolete IP address associations from the cache. * - * For example, if an IP address is associated with a plan different from the one in the {@link SPENDING_PLANS_CONFIG_FILE}, + * For example, if an IP address is associated with a plan different from the one in the {@link SPENDING_PLANS_CONFIG}, * the association is deleted from the cache to allow the new association from the configuration file to take effect. * * @param {SpendingPlanConfig} planConfig - The spending plan configuration. diff --git a/packages/relay/tests/lib/config/hbarSpendingPlanConfigService.spec.ts b/packages/relay/tests/lib/config/hbarSpendingPlanConfigService.spec.ts index ad6da1645..2bd86be17 100644 --- a/packages/relay/tests/lib/config/hbarSpendingPlanConfigService.spec.ts +++ b/packages/relay/tests/lib/config/hbarSpendingPlanConfigService.spec.ts @@ -29,7 +29,13 @@ import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { SpendingPlanConfig } from '../../../src/lib/types/spendingPlanConfig'; import { RequestDetails } from '../../../src/lib/types'; -import { overrideEnvsInMochaDescribe, toHex, useInMemoryRedisServer, verifyResult } from '../../helpers'; +import { + overrideEnvsInMochaDescribe, + toHex, + useInMemoryRedisServer, + verifyResult, + withOverriddenEnvsInMochaTest, +} from '../../helpers'; import findConfig from 'find-config'; import { HbarSpendingPlanConfigService } from '../../../src/lib/config/hbarSpendingPlanConfigService'; import { CacheService } from '../../../src/lib/services/cacheService/cacheService'; @@ -62,7 +68,7 @@ describe('HbarSpendingPlanConfigService', function () { const path = findConfig(spendingPlansConfigFile); const spendingPlansConfig = JSON.parse(fs.readFileSync(path!, 'utf-8')) as SpendingPlanConfig[]; - const tests = (isSharedCacheEnabled: boolean) => { + const tests = (hbarSpendingPlansConfigEnv: string) => { let cacheService: CacheService; let hbarSpendingPlanRepository: HbarSpendingPlanRepository; let ethAddressHbarSpendingPlanRepository: EthAddressHbarSpendingPlanRepository; @@ -76,18 +82,12 @@ describe('HbarSpendingPlanConfigService', function () { let ipAddressHbarSpendingPlanRepositorySpy: sinon.SinonSpiedInstance; overrideEnvsInMochaDescribe({ - HBAR_SPENDING_PLANS_CONFIG_FILE: spendingPlansConfigFile, + HBAR_SPENDING_PLANS_CONFIG: hbarSpendingPlansConfigEnv, CACHE_TTL: '100', CACHE_MAX: spendingPlansConfig.length.toString(), }); - if (isSharedCacheEnabled) { - useInMemoryRedisServer(logger, 6384); - } else { - overrideEnvsInMochaDescribe({ REDIS_ENABLED: 'false' }); - } - - before(function () { + before(async function () { const reservedKeys = HbarSpendingPlanConfigService.getPreconfiguredSpendingPlanKeys(logger); cacheService = new CacheService(logger.child({ name: 'cache-service' }), registry, reservedKeys); hbarSpendingPlanRepository = new HbarSpendingPlanRepository( @@ -110,7 +110,13 @@ describe('HbarSpendingPlanConfigService', function () { ); }); - beforeEach(function () { + after(async function () { + if (ConfigService.get('REDIS_ENABLED')) { + await cacheService.disconnectRedisClient(); + } + }); + + beforeEach(async function () { loggerSpy = sinon.spy(logger); cacheServiceSpy = sinon.spy(cacheService); hbarSpendingPlanRepositorySpy = sinon.spy(hbarSpendingPlanRepository); @@ -130,12 +136,19 @@ describe('HbarSpendingPlanConfigService', function () { await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).not.to.be.rejected; }); - it('should throw an error if configuration file is not a parsable JSON', async function () { - sinon.stub(fs, 'readFileSync').returns('invalid JSON'); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Failed to parse JSON from ${path}: Unexpected token 'i', "invalid JSON" is not valid JSON`, - ); - }); + withOverriddenEnvsInMochaTest( + { + HBAR_SPENDING_PLANS_CONFIG: spendingPlansConfigFile, + }, + () => { + it('should throw an error if configuration file is not a parsable JSON', async function () { + sinon.stub(fs, 'readFileSync').returns('invalid JSON'); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Unexpected token 'i', "invalid JSON" is not valid JSON`); + }); + }, + ); it('should throw an error if the configuration file has entry without ID', async function () { const invalidPlan = { @@ -147,9 +160,9 @@ describe('HbarSpendingPlanConfigService', function () { .stub(HbarSpendingPlanConfigService, 'loadSpendingPlansConfig' as any) .returns([...spendingPlansConfig, invalidPlan]); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`, - ); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`); }); it('should throw an error if the configuration file has entry without name', async function () { @@ -162,9 +175,9 @@ describe('HbarSpendingPlanConfigService', function () { .stub(HbarSpendingPlanConfigService, 'loadSpendingPlansConfig' as any) .returns([...spendingPlansConfig, invalidPlan]); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`, - ); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`); }); it('should throw an error if the configuration file has entry without subscriptionTier', async function () { @@ -177,9 +190,9 @@ describe('HbarSpendingPlanConfigService', function () { .stub(HbarSpendingPlanConfigService, 'loadSpendingPlansConfig' as any) .returns([...spendingPlansConfig, invalidPlan]); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`, - ); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`); }); it('should throw an error if the configuration file has entry with invalid subscriptionTier', async function () { @@ -193,9 +206,9 @@ describe('HbarSpendingPlanConfigService', function () { .stub(HbarSpendingPlanConfigService, 'loadSpendingPlansConfig' as any) .returns([...spendingPlansConfig, invalidPlan]); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`, - ); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`); }); it('should throw an error if the configuration file has entry without ethAddresses and ipAddresses', async function () { @@ -208,9 +221,9 @@ describe('HbarSpendingPlanConfigService', function () { .stub(HbarSpendingPlanConfigService, 'loadSpendingPlansConfig' as any) .returns([...spendingPlansConfig, invalidPlan]); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`, - ); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`); }); it('should throw an error if the configuration file has entry with empty ethAddresses and ipAddresses', async function () { @@ -225,9 +238,9 @@ describe('HbarSpendingPlanConfigService', function () { .stub(HbarSpendingPlanConfigService, 'loadSpendingPlansConfig' as any) .returns([...spendingPlansConfig, invalidPlan]); - await expect(hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans()).to.be.rejectedWith( - `Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`, - ); + await expect( + hbarSpendingPlanConfigService.populatePreconfiguredSpendingPlans(), + ).to.be.eventually.rejectedWith(`Invalid spending plan configuration: ${JSON.stringify(invalidPlan)}`); }); }); @@ -651,11 +664,27 @@ describe('HbarSpendingPlanConfigService', function () { }); }; - describe('with shared cache enabled', function () { - tests(true); + describe('using Redis cache', function () { + useInMemoryRedisServer(logger, 6384); + + describe('and with a spending plan config file', function () { + tests(spendingPlansConfigFile); + }); + + describe('and with a spending plan config variable', function () { + tests(JSON.stringify(spendingPlansConfig)); + }); }); - describe('with shared cache disabled', function () { - tests(false); + describe('using LRU cache', function () { + overrideEnvsInMochaDescribe({ REDIS_ENABLED: false }); + + describe('and with a spending plan config file', function () { + tests(spendingPlansConfigFile); + }); + + describe('and with a spending plan config variable', function () { + tests(JSON.stringify(spendingPlansConfig)); + }); }); }); diff --git a/packages/relay/tests/lib/relay.spec.ts b/packages/relay/tests/lib/relay.spec.ts index 5b1c74dd2..844655edd 100644 --- a/packages/relay/tests/lib/relay.spec.ts +++ b/packages/relay/tests/lib/relay.spec.ts @@ -20,7 +20,6 @@ import chai, { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import findConfig from 'find-config'; import fs from 'fs'; import pino from 'pino'; import sinon from 'sinon'; @@ -94,7 +93,7 @@ describe('RelayImpl', () => { }); describe('when a configuration file is provided', () => { - overrideEnvsInMochaDescribe({ HBAR_SPENDING_PLANS_CONFIG_FILE: 'spendingPlansConfig.example.json' }); + overrideEnvsInMochaDescribe({ HBAR_SPENDING_PLANS_CONFIG: 'spendingPlansConfig.example.json' }); it('should populate preconfigured spending plans successfully', async () => { expect((relay = new RelayImpl(logger, register))).to.not.throw; @@ -107,7 +106,7 @@ describe('RelayImpl', () => { describe('when no configuration file is provided', () => { const nonExistingFile = 'nonExistingFile.json'; - overrideEnvsInMochaDescribe({ HBAR_SPENDING_PLANS_CONFIG_FILE: nonExistingFile }); + overrideEnvsInMochaDescribe({ HBAR_SPENDING_PLANS_CONFIG: nonExistingFile }); it('should not throw an error', async () => { expect((relay = new RelayImpl(logger, register))).to.not.throw; @@ -119,12 +118,9 @@ describe('RelayImpl', () => { }); describe('when a configuration file with invalid JSON is provided', () => { - let path: string | null; - - overrideEnvsInMochaDescribe({ HBAR_SPENDING_PLANS_CONFIG_FILE: 'spendingPlansConfig.example.json' }); + overrideEnvsInMochaDescribe({ HBAR_SPENDING_PLANS_CONFIG: 'spendingPlansConfig.example.json' }); beforeEach(() => { - path = findConfig('spendingPlansConfig.example.json'); sinon.stub(fs, 'readFileSync').returns('invalid JSON'); }); @@ -134,8 +130,7 @@ describe('RelayImpl', () => { expect(populatePreconfiguredSpendingPlansSpy.calledOnce).to.be.true; await expect(populatePreconfiguredSpendingPlansSpy.returnValues[0]).not.to.be.rejected; - const cause = `Failed to parse JSON from ${path}: Unexpected token 'i', "invalid JSON" is not valid JSON`; - const message = `Failed to load pre-configured spending plans: ${cause}`; + const message = `Failed to load pre-configured spending plans: File error: Unexpected token 'i', "invalid JSON" is not valid JSON`; expect(loggerSpy.warn.calledWith(message)).to.be.true; }); }); diff --git a/packages/server/tests/acceptance/hbarLimiter.spec.ts b/packages/server/tests/acceptance/hbarLimiter.spec.ts index 873acf5f6..e9e5ebe40 100644 --- a/packages/server/tests/acceptance/hbarLimiter.spec.ts +++ b/packages/server/tests/acceptance/hbarLimiter.spec.ts @@ -615,7 +615,7 @@ describe('@hbarlimiter HBAR Limiter Acceptance Tests', function () { }; describe('given a valid JSON file with pre-configured spending plans', async () => { - const SPENDING_PLANS_CONFIG_FILE = ConfigService.get('HBAR_SPENDING_PLANS_CONFIG_FILE') as string; + const SPENDING_PLANS_CONFIG_FILE = ConfigService.get('HBAR_SPENDING_PLANS_CONFIG') as string; const configPath = findConfig(SPENDING_PLANS_CONFIG_FILE); const rawData = fs.readFileSync(configPath!, 'utf-8'); const expectedNonBasicPlans2 = JSON.parse(rawData) as SpendingPlanConfig[]; diff --git a/packages/server/tests/localAcceptance.env b/packages/server/tests/localAcceptance.env index 9652019ed..4485f0595 100644 --- a/packages/server/tests/localAcceptance.env +++ b/packages/server/tests/localAcceptance.env @@ -30,4 +30,4 @@ WRITE_SNAPSHOT_ON_MEMORY_LEAK=false HBAR_RATE_LIMIT_TINYBAR=5000000000# 50 HBARs HBAR_RATE_LIMIT_DURATION=80000# 80 seconds HBAR_RATE_LIMIT_BASIC=4000000000# 40 HBARs -HBAR_SPENDING_PLANS_CONFIG_FILE=./packages/server/tests/testSpendingPlansConfig.json +HBAR_SPENDING_PLANS_CONFIG=./packages/server/tests/testSpendingPlansConfig.json