Skip to content

Commit

Permalink
Merge branch 'main' into add-helper-method-for-overriding-env-variables
Browse files Browse the repository at this point in the history
Signed-off-by: Victor Yanev <[email protected]>

# Conflicts:
#	packages/relay/tests/lib/repositories/hbarLimiter/ethAddressHbarSpendingPlanRepository.spec.ts
#	packages/relay/tests/lib/repositories/hbarLimiter/hbarSpendingPlanRepository.spec.ts
  • Loading branch information
victor-yanev committed Oct 8, 2024
1 parent 6c7e2c2 commit de2f554
Show file tree
Hide file tree
Showing 33 changed files with 629 additions and 418 deletions.
77 changes: 41 additions & 36 deletions docs/configuration.md

Large diffs are not rendered by default.

136 changes: 122 additions & 14 deletions docs/design/hbar-limiter.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
- [HBar Allocation Strategy](#hbar-allocation-strategy)
- [Metrics to Track](#metrics-to-track)
- [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)
- [Spending Limits of Different Tiers](#spending-limits-of-different-tiers)
- [Total Budget and Limit Duration](#total-budget-and-limit-duration)
- [Additional Considerations](#additional-considerations)
- [Performance](#performance)
- [Monitoring and logging](#monitoring-and-logging)
Expand Down Expand Up @@ -77,20 +81,43 @@ The purpose of the HBar Limiter is to track and control the spending of HBars in

The HBar limiter will be implemented as a separate service, used by other services/classes that need it. It will have two main purposes - to capture the gas fees for different operation and to check if an operation needs to be paused, due to an exceeded HBar limit.

### General Users (BASIC tier):

**NOTE:** Each general user will have a unique spending plan, linked both to their ETH and IP addresses. Each new user will be automatically assigned a BASIC spending plan when they send their first transaction and this plan will remain linked to them for any subsequent requests.

```mermaid
flowchart TD
A[User] -->|sends transaction| B[JSON-RPC Relay]
B --> C[Estimate fees which will be paid by the relay operator]
C --> D{HBAR Limiter}
D -->|new user, i.e., who is not linked to a spending plan| E[Create a new BASIC spending plan]
E --> F[Link user's ETH & IP addresses to plan]
D -->|existing user, i.e., who is linked to a spending plan| G[Retrieve spending plan linked to user]
F --> H{Plan has enough balance to cover fees}
G --> H
H --> |no| I[Limit request]
H --> |yes| J[Execute transaction]
J --> K[Capture all fees the operator has been charged during execution]
K --> L[Update the spending plan's remaining balance]
```

### Supported Projects (EXTENDED tier) and Trusted Partners (PRIVILEGED tier):

**NOTE:** There will be one spending plan per project/partner with a total spending limit, shared amongst a group of users (IP and ETH addresses) linked to that plan. This means that they will share a common total spending limit for the project/partner.

All users associated with a project/partner will be pre-configured in the relay as shown in the

```mermaid
flowchart TD
A[User] -->|sends transaction| B[JSON-RPC Relay]
B --> C{HbarLimitService}
C -->|new user, i.e., who is not linked to a spending plan| D[Create a BASIC HbarSpendingPlan]
D --> E[Link user's ETH & IP addresses to plan]
E --> F[Estimate fees of any additional HFS transactions which need to be executed by the operator]
C -->|existing user, i.e., who is linked to a spending plan| G[Retrieve HbarSpendingPlan linked to user]
G --> F
F --> H{The plan exceeds its daily HBar allowance?}
H --> |yes| I[Limit request]
H --> |no| J[Execute transaction]
J --> K[Capture fees the operator has been charged]
K --> L[Update spending plan]
B --> C[Estimate fees which will be paid by the relay operator]
C --> D{HBAR Limiter}
D --> E[Retrieve spending plan linked to user's ETH and/or IP address]
E --> F{Plan has enough balance to cover fees}
F --> |no| G[Limit request]
F --> |yes| H[Execute transaction]
H --> I[Capture all fees the operator has been charged during execution]
I --> J[Update the spending plan's remaining balance]
```

### Class Diagram
Expand Down Expand Up @@ -149,7 +176,7 @@ classDiagram
-createdAt: Date
-active: boolean
-spendingHistory: HbarSpendingRecord[]
-spentToday: number
-amountSpent: number
}
class HbarSpendingRecord {
Expand Down Expand Up @@ -188,8 +215,8 @@ classDiagram
+checkExistsAndActive(id: string): Promise<void>
+getSpendingHistory(id: string): Promise<HbarSpendingRecord[]>
+addAmountToSpendingHistory(id: string, amount: number): Promise<number>
+getSpentToday(id: string): Promise<number>
+addAmountToSpentToday(id: string, amount: number): Promise<void>
+getAmountSpent(id: string): Promise<number>
+addToAmountSpent(id: string, amount: number): Promise<void>
}
class EthAddressHbarSpendingPlanRepository {
Expand Down Expand Up @@ -271,6 +298,87 @@ c. Current day's usage (increase limits if overall usage is low)
- Keep a small portion of the daily budget (e.g., 10%) as a reserve
- Use this to accommodate unexpected spikes or high-priority users

## Configurations

### Pre-populating the Cache with Spending Plans for Supported Projects and Partner Projects

The following configurations will be used to automatically populate the cache with `HbarSpendingPlan`, `EthAddressHbarSpendingPlan`, and `IPAddressHbarSpendingPlan` entries for the outlined supported projects and partner projects on every start-up of the relay.

All other users (ETH and IP addresses which are not specified in the configuration file) will be treated as "general users" and will be assigned a basic `HbarSpendingPlan` on their first request and their ETH address and IP address will be linked to that plan for all subsequent requests.

```json
[
{
"name": "partner name",
"ethAddresses": ["0x123", "0x124"],
"ipAddresses": ["127.0.0.1", "128.0.0.1"],
"subscriptionType": "PRIVILEGED"
},
{
"name": "some other partner that has given us only eth addresses",
"ethAddresses": ["0x125", "0x126"],
"subscriptionType": "PRIVILEGED"
},
{
"name": "supported project name",
"ethAddresses": ["0x127", "0x128"],
"ipAddresses": ["129.0.0.1", "130.0.0.1"],
"subscriptionType": "EXTENDED"
},
{
"name": "some other supported project that has given us only ip addresses",
"ipAddresses": ["131.0.0.1", "132.0.0.1"],
"subscriptionType": "EXTENDED"
}
]
```

On every start-up, the relay will check if these entries are already populated in the cache. If not, it will populate them accordingly.

The JSON file can also be updated over time to add new supported projects or partner projects, and it will populate only the new entries on the next start-up.

```json
[
...,
{
"name": "new partner name",
"ethAddresses": ["0x129", "0x130"],
"ipAddresses": ["133.0.0.1"],
"subscriptionType": "PRIVILEGED"
}
]
```

### Spending Limits of Different Tiers

The spending limits for different tiers are defined as environment variables:
- `HBAR_RATE_LIMIT_BASIC`: The spending limit (in tinybars) for general users (tier 3)
- `HBAR_RATE_LIMIT_EXTENDED`: The spending limit (in tinybars) for supported projects (tier 2)
- `HBAR_RATE_LIMIT_PRIVILEGED`: The spending limit (in tinybars) for trusted partners (tier 1)

Example configuration for tiered spending limits:
```dotenv
HBAR_RATE_LIMIT_BASIC=92592592
HBAR_RATE_LIMIT_EXTENDED=925925925
HBAR_RATE_LIMIT_PRIVILEGED=1851851850
```

### Total Budget and Limit Duration

The total budget and the limit duration are defined as environment variables:
- `HBAR_RATE_LIMIT_DURATION`: The time window (in milliseconds) for which both the total budget and the spending limits are applicable.
- On initialization of `HbarLimitService`, a reset timestamp is calculated by adding the `HBAR_RATE_LIMIT_DURATION` to the current timestamp.
- The total budget and spending limits are reset when the current timestamp exceeds the reset timestamp.
- `HBAR_RATE_LIMIT_TINYBAR`: The ceiling (in tinybars) on the total amount of HBARs that can be spent in the limit duration.
- This is the largest bucket from which others pull from.
- If the total amount spent exceeds this limit, all spending is paused until the next reset.

Example configuration for a total budget of 110 HBARs (11_000_000_000 tinybars) per 80 seconds:
```dotenv
HBAR_RATE_LIMIT_TINYBAR=11000000000
HBAR_RATE_LIMIT_DURATION=80000
```

## Additional Considerations

### Performance
Expand Down
1 change: 0 additions & 1 deletion packages/relay/src/lib/clients/sdkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,6 @@ export class SDKClient {
* @param {string} originalCallerAddress - The address of the original caller making the request.
* @param {number} networkGasPriceInWeiBars - The predefined gas price of the network in weibar.
* @param {number} currentNetworkExchangeRateInCents - The exchange rate in cents of the current network.
* @param {string} requestId - The unique identifier for the request.
* @returns {Promise<{ txResponse: TransactionResponse; fileId: FileId | null }>}
* @throws {SDKClientError} Throws an error if no file ID is created or if the preemptive fee check fails.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ export class HbarSpendingPlan implements IDetailedHbarSpendingPlan {
createdAt: Date;
active: boolean;
spendingHistory: HbarSpendingRecord[];
spentToday: number;
amountSpent: number;

constructor(data: IDetailedHbarSpendingPlan) {
this.id = data.id;
this.subscriptionType = data.subscriptionType;
this.createdAt = new Date(data.createdAt);
this.active = data.active;
this.spendingHistory = data.spendingHistory?.map((spending) => new HbarSpendingRecord(spending)) || [];
this.spentToday = data.spentToday ?? 0;
this.amountSpent = data.amountSpent ?? 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { RequestDetails } from '../../../types';

export class EthAddressHbarSpendingPlanRepository {
private readonly collectionKey = 'ethAddressHbarSpendingPlan';
private readonly threeMonthsInMillis = 90 * 24 * 60 * 60 * 1000;
private readonly oneDayInMillis = 24 * 60 * 60 * 1000;

/**
* The cache service used for storing data.
Expand Down Expand Up @@ -68,11 +68,12 @@ export class EthAddressHbarSpendingPlanRepository {
*
* @param {IEthAddressHbarSpendingPlan} addressPlan - The plan to save.
* @param {RequestDetails} requestDetails - The request details for logging and tracking.
* @param {number} ttl - The time-to-live for the cache entry.
* @returns {Promise<void>} - A promise that resolves when the ETH address is linked to the plan.
*/
async save(addressPlan: IEthAddressHbarSpendingPlan, requestDetails: RequestDetails): Promise<void> {
async save(addressPlan: IEthAddressHbarSpendingPlan, requestDetails: RequestDetails, ttl: number): Promise<void> {
const key = this.getKey(addressPlan.ethAddress);
await this.cache.set(key, addressPlan, 'save', requestDetails, this.threeMonthsInMillis);
await this.cache.set(key, addressPlan, 'save', requestDetails, ttl);
this.logger.trace(`Saved EthAddressHbarSpendingPlan with address ${addressPlan.ethAddress}`);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ import { RequestDetails } from '../../../types';

export class HbarSpendingPlanRepository {
private readonly collectionKey = 'hbarSpendingPlan';
private readonly oneDayInMillis = 24 * 60 * 60 * 1000;
private readonly threeMonthsInMillis = this.oneDayInMillis * 90;

/**
* The cache service used for storing data.
Expand Down Expand Up @@ -71,7 +69,7 @@ export class HbarSpendingPlanRepository {
}

/**
* Gets an HBar spending plan by ID with detailed information (spendingHistory and spentToday).
* Gets an HBar spending plan by ID with detailed information (spendingHistory and amountSpent).
* @param {string} id - The ID of the plan.
* @param {RequestDetails} requestDetails - The request details for logging and tracking.
* @returns {Promise<IDetailedHbarSpendingPlan>} - The detailed HBar spending plan object.
Expand All @@ -81,28 +79,33 @@ export class HbarSpendingPlanRepository {
return new HbarSpendingPlan({
...plan,
spendingHistory: [],
spentToday: await this.getSpentToday(id, requestDetails),
amountSpent: await this.getAmountSpent(id, requestDetails),
});
}

/**
* Creates a new HBar spending plan.
* @param {SubscriptionType} subscriptionType - The subscription type of the plan to create.
* @param {RequestDetails} requestDetails - The request details for logging and tracking.
* @param {number} ttl - The time-to-live for the plan in milliseconds.
* @returns {Promise<IDetailedHbarSpendingPlan>} - The created HBar spending plan object.
*/
async create(subscriptionType: SubscriptionType, requestDetails: RequestDetails): Promise<IDetailedHbarSpendingPlan> {
async create(
subscriptionType: SubscriptionType,
requestDetails: RequestDetails,
ttl: number,
): Promise<IDetailedHbarSpendingPlan> {
const plan: IDetailedHbarSpendingPlan = {
id: uuidV4(randomBytes(16)),
subscriptionType,
createdAt: new Date(),
active: true,
spendingHistory: [],
spentToday: 0,
amountSpent: 0,
};
this.logger.trace(`Creating HbarSpendingPlan with ID ${plan.id}...`);
const key = this.getKey(plan.id);
await this.cache.set(key, plan, 'create', requestDetails, this.threeMonthsInMillis);
await this.cache.set(key, plan, 'create', requestDetails, ttl);
return new HbarSpendingPlan(plan);
}

Expand Down Expand Up @@ -157,48 +160,51 @@ export class HbarSpendingPlanRepository {
}

/**
* Gets the amount spent today for an HBar spending plan.
* @param {string} id - The ID of the plan.
* @param {RequestDetails} requestDetails - The request details for logging and tracking.
* @returns {Promise<number>} - A promise that resolves with the amount spent today.
* Gets the amount spent for an HBar spending plan.
* @param id - The ID of the plan.
@param {RequestDetails} requestDetails - The request details for logging and tracking.
* @returns {Promise<number>} - A promise that resolves with the amount spent.
*/
async getSpentToday(id: string, requestDetails: RequestDetails): Promise<number> {
async getAmountSpent(id: string, requestDetails: RequestDetails): Promise<number> {
await this.checkExistsAndActive(id, requestDetails);

this.logger.trace(`Retrieving spentToday for HbarSpendingPlan with ID ${id}...`);
const key = this.getSpentTodayKey(id);
return this.cache.getAsync(key, 'getSpentToday', requestDetails).then((spentToday) => parseInt(spentToday ?? '0'));
this.logger.trace(`Retrieving amountSpent for HbarSpendingPlan with ID ${id}...`);
const key = this.getAmountSpentKey(id);
return this.cache
.getAsync(key, 'getAmountSpent', requestDetails)
.then((amountSpent) => parseInt(amountSpent ?? '0'));
}

/**
* Resets the amount spent today for all hbar spending plans.
* Resets the amount spent for all hbar spending plans.
* @returns {Promise<void>} - A promise that resolves when the operation is complete.
*/
async resetAllSpentTodayEntries(requestDetails: RequestDetails): Promise<void> {
this.logger.trace('Resetting the spentToday entries for all HbarSpendingPlans...');
const callerMethod = this.resetAllSpentTodayEntries.name;
const keys = await this.cache.keys(`${this.collectionKey}:*:spentToday`, callerMethod, requestDetails);
async resetAmountSpentOfAllPlans(requestDetails: RequestDetails): Promise<void> {
this.logger.trace('Resetting the `amountSpent` entries for all HbarSpendingPlans...');
const callerMethod = this.resetAmountSpentOfAllPlans.name;
const keys = await this.cache.keys(this.getAmountSpentKey('*'), callerMethod, requestDetails);
await Promise.all(keys.map((key) => this.cache.delete(key, callerMethod, requestDetails)));
this.logger.trace(`Successfully reset ${keys.length} spentToday entries for HbarSpendingPlans.`);
this.logger.trace(`Successfully reset ${keys.length} "amountSpent" entries for HbarSpendingPlans.`);
}

/**
* Adds an amount to the amount spent today for a plan.
* Adds an amount to the amount spent for a plan.
* @param {string} id - The ID of the plan.
* @param {number} amount - The amount to add.
* @param {RequestDetails} requestDetails - The request details for logging and tracking.
* @param {number} ttl - The time-to-live for the amountSpent entry in milliseconds.
* @returns {Promise<void>} - A promise that resolves when the operation is complete.
*/
async addAmountToSpentToday(id: string, amount: number, requestDetails: RequestDetails): Promise<void> {
async addToAmountSpent(id: string, amount: number, requestDetails: RequestDetails, ttl: number): Promise<void> {
await this.checkExistsAndActive(id, requestDetails);

const key = this.getSpentTodayKey(id);
if (!(await this.cache.getAsync(key, 'addAmountToSpentToday', requestDetails))) {
this.logger.trace(`No spending yet for HbarSpendingPlan with ID ${id}, setting spentToday to ${amount}...`);
await this.cache.set(key, amount, 'addAmountToSpentToday', requestDetails, this.oneDayInMillis);
const key = this.getAmountSpentKey(id);
if (!(await this.cache.getAsync(key, 'addToAmountSpent', requestDetails))) {
this.logger.trace(`No spending yet for HbarSpendingPlan with ID ${id}, setting amountSpent to ${amount}...`);
await this.cache.set(key, amount, 'addToAmountSpent', requestDetails, ttl);
} else {
this.logger.trace(`Adding ${amount} to spentToday for HbarSpendingPlan with ID ${id}...`);
await this.cache.incrBy(key, amount, 'addAmountToSpentToday', requestDetails);
this.logger.trace(`Adding ${amount} to amountSpent for HbarSpendingPlan with ID ${id}...`);
await this.cache.incrBy(key, amount, 'addToAmountSpent', requestDetails);
}
}

Expand All @@ -213,7 +219,7 @@ export class HbarSpendingPlanRepository {
requestDetails: RequestDetails,
): Promise<IDetailedHbarSpendingPlan[]> {
const callerMethod = this.findAllActiveBySubscriptionType.name;
const keys = await this.cache.keys(`${this.collectionKey}:*`, callerMethod, requestDetails);
const keys = await this.cache.keys(this.getKey('*'), callerMethod, requestDetails);
const plans = await Promise.all(
keys.map((key) => this.cache.getAsync<IHbarSpendingPlan>(key, callerMethod, requestDetails)),
);
Expand All @@ -226,7 +232,7 @@ export class HbarSpendingPlanRepository {
...plan,
createdAt: new Date(plan.createdAt),
spendingHistory: [],
spentToday: await this.getSpentToday(plan.id, requestDetails),
amountSpent: await this.getAmountSpent(plan.id, requestDetails),
}),
),
);
Expand All @@ -242,12 +248,12 @@ export class HbarSpendingPlanRepository {
}

/**
* Gets the cache key for the amount spent today for an {@link IHbarSpendingPlan}.
* Gets the cache key for the amount spent for an {@link IHbarSpendingPlan}.
* @param id - The ID of the plan to get the key for.
* @private
*/
private getSpentTodayKey(id: string): string {
return `${this.collectionKey}:${id}:spentToday`;
private getAmountSpentKey(id: string): string {
return `${this.collectionKey}:${id}:amountSpent`;
}

/**
Expand Down
Loading

0 comments on commit de2f554

Please sign in to comment.