Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added ability to load spending plans from environment variable.… #3201

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
13 changes: 11 additions & 2 deletions docs/design/hbar-limiter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/config-service/src/services/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/relay/src/lib/clients/cache/localLRUCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export class LocalLRUCache implements ICacheClient {
*/
public async clear(): Promise<void> {
this.cache.clear();
this.reservedCache?.clear();
}

/**
Expand Down
37 changes: 27 additions & 10 deletions packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
* 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 {
/**
Expand Down Expand Up @@ -127,17 +128,33 @@
* @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');

Check warning on line 134 in packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts

View check run for this annotation

Codecov / codecov/patch

packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts#L134

Added line #L134 was not covered by tests
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}`);
}
}
}

Expand Down Expand Up @@ -319,7 +336,7 @@
/**
* 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.
Expand Down Expand Up @@ -347,7 +364,7 @@
/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -76,18 +82,12 @@ describe('HbarSpendingPlanConfigService', function () {
let ipAddressHbarSpendingPlanRepositorySpy: sinon.SinonSpiedInstance<IPAddressHbarSpendingPlanRepository>;

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(
Expand All @@ -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);
Expand All @@ -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 = {
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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)}`);
});
});

Expand Down Expand Up @@ -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));
});
});
});
Loading
Loading