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: Implement HbarLimitService#addExpense #2902

Merged
merged 12 commits into from
Sep 2, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,7 @@
*/

export interface IHbarLimitService {
/**
* Resets the Hbar limiter.
*/
resetLimiter(): Promise<void>;

/**
* Determines if the Hbar limit should be applied based on the provided Ethereum address
* and optionally an IP address.
*
* @param {string} ethAddress - The Ethereum address to check.
* @param {string} [ipAddress] - The optional IP address to check.
* @returns {Promise<boolean>} - True if the limit should be applied, false otherwise.
*/
shouldLimit(ethAddress: string, ipAddress?: string): Promise<boolean>;
shouldLimit(mode: string, methodName: string, ethAddress: string, ipAddress?: string): Promise<boolean>;
victor-yanev marked this conversation as resolved.
Show resolved Hide resolved
addExpense(cost: number, ethAddress: string, ipAddress?: string): Promise<void>;
}
140 changes: 137 additions & 3 deletions packages/relay/src/lib/services/hbarLimitService/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,72 @@ import { IDetailedHbarSpendingPlan } from '../../db/types/hbarLimiter/hbarSpendi
import { SubscriptionType } from '../../db/types/hbarLimiter/subscriptionType';
import { Logger } from 'pino';
import { formatRequestIdMessage } from '../../../formatters';
import { Counter, Gauge, Registry } from 'prom-client';
victor-yanev marked this conversation as resolved.
Show resolved Hide resolved

export class HbarLimitService implements IHbarLimitService {
static readonly ONE_DAY_IN_MILLIS = 24 * 60 * 60 * 1000;
// TODO: Replace with actual values
static readonly DAILY_LIMITS: Record<SubscriptionType, number> = {
BASIC: parseInt(process.env.HBAR_DAILY_LIMIT_BASIC ?? '1000'),
EXTENDED: parseInt(process.env.HBAR_DAILY_LIMIT_EXTENDED ?? '10000'),
PRIVILEGED: parseInt(process.env.HBAR_DAILY_LIMIT_PRIVILEGED ?? '100000'),
};

/**
* Counts the number of times the rate limit has been reached.
* @private
*/
private readonly hbarLimitCounter: Counter;

/**
* Tracks the remaining budget for the rate limiter.
* @private
*/
private readonly hbarLimitRemainingGauge: Gauge;

/**
* The remaining budget for the rate limiter.
* @private
*/
private remainingBudget: number;

/**
* The reset timestamp for the rate limiter.
* @private
*/
private reset: Date;

constructor(
private readonly hbarSpendingPlanRepository: HbarSpendingPlanRepository,
private readonly ethAddressHbarSpendingPlanRepository: EthAddressHbarSpendingPlanRepository,
private readonly logger: Logger,
) {}
private readonly register: Registry,
private readonly totalBudget: number,
) {
const metricCounterName = 'rpc_relay_hbar_rate_limit';
this.register.removeSingleMetric(metricCounterName);
this.hbarLimitCounter = new Counter({
name: metricCounterName,
help: 'Relay Hbar limit counter',
registers: [register],
labelNames: ['mode', 'methodName'],
});
this.hbarLimitCounter.inc(0);

const rateLimiterRemainingGaugeName = 'rpc_relay_hbar_rate_remaining';
this.register.removeSingleMetric(rateLimiterRemainingGaugeName);
this.hbarLimitRemainingGauge = new Gauge({
name: rateLimiterRemainingGaugeName,
help: 'Relay Hbar rate limit remaining budget',
registers: [register],
});
this.hbarLimitRemainingGauge.set(this.totalBudget);
this.remainingBudget = this.totalBudget;
// Reset the rate limiter at the start of the next day
const now = Date.now();
const tomorrow = new Date(now + HbarLimitService.ONE_DAY_IN_MILLIS);
this.reset = new Date(tomorrow.setHours(0, 0, 0, 0));
}

/**
* Resets the {@link HbarSpendingPlan#spentToday} field for all existing plans.
Expand All @@ -51,17 +103,28 @@ export class HbarLimitService implements IHbarLimitService {

/**
* Checks if the given eth address or ip address should be limited.
* @param {string} mode - The mode of the transaction or request.
* @param {string} methodName - The name of the method being invoked.
* @param {string} ethAddress - The eth address to check.
* @param {string} [ipAddress] - The ip address to check.
* @param {string} [requestId] - A prefix to include in log messages (optional).
* @returns {Promise<boolean>} - A promise that resolves with a boolean indicating if the address should be limited.
*/
async shouldLimit(ethAddress: string, ipAddress?: string, requestId?: string): Promise<boolean> {
async shouldLimit(
mode: string,
methodName: string,
ethAddress: string,
ipAddress?: string,
requestId?: string,
): Promise<boolean> {
const requestIdPrefix = formatRequestIdMessage(requestId);
if (await this.isDailyBudgetExceeded(mode, methodName, requestIdPrefix)) {
return true;
}
if (!ethAddress && !ipAddress) {
this.logger.warn('No eth address or ip address provided, cannot check if address should be limited');
return false;
}
const requestIdPrefix = formatRequestIdMessage(requestId);
const user = `(ethAddress=${ethAddress}, ipAddress=${ipAddress})`;
this.logger.trace(`${requestIdPrefix} Checking if ${user} should be limited...`);
let spendingPlan = await this.getSpendingPlan(ethAddress, ipAddress);
Expand All @@ -79,6 +142,77 @@ export class HbarLimitService implements IHbarLimitService {
return exceedsLimit;
}

/**
* Add expense to the remaining budget.
* @param {number} cost - The cost of the expense.
* @param {string} ethAddress - The Ethereum address to add the expense to.
* @param {string} [ipAddress] - The optional IP address to add the expense to.
* @param {string} [requestId] - An optional unique request ID for tracking the request.
* @returns {Promise<void>} - A promise that resolves when the expense has been added.
*/
async addExpense(cost: number, ethAddress: string, ipAddress?: string, requestId?: string): Promise<void> {
if (!ethAddress && !ipAddress) {
throw new Error('Cannot add expense without an eth address or ip address');
}

let spendingPlan = await this.getSpendingPlan(ethAddress, ipAddress);
if (!spendingPlan) {
// Create a basic spending plan if none exists for the eth address or ip address
spendingPlan = await this.createBasicSpendingPlan(ethAddress, ipAddress);
}

const requestIdPrefix = formatRequestIdMessage(requestId);
this.logger.trace(
`${requestIdPrefix} Adding expense of ${cost} to spending plan with ID ${spendingPlan.id}, new spentToday=${
spendingPlan.spentToday + cost
}`,
);

await this.hbarSpendingPlanRepository.addAmountToSpentToday(spendingPlan.id, cost);
await this.hbarSpendingPlanRepository.addAmountToSpendingHistory(spendingPlan.id, cost);
this.remainingBudget -= cost;
this.hbarLimitRemainingGauge.set(this.remainingBudget);

this.logger.trace(
`${requestIdPrefix} HBAR rate limit expense update: cost=${cost}, remainingBudget=${this.remainingBudget}`,
);
}

/**
* Checks if the total daily budget has been exceeded.
* @param {string} mode - The mode of the transaction or request.
* @param {string} methodName - The name of the method being invoked.
* @param {string} [requestIdPrefix] - An optional prefix to include in log messages.
* @returns {Promise<boolean>} - Resolves `true` if the daily budget has been exceeded, otherwise `false`.
* @private
*/
private async isDailyBudgetExceeded(mode: string, methodName: string, requestIdPrefix?: string): Promise<boolean> {
if (this.shouldResetLimiter()) {
await this.resetLimiter();
}
if (this.remainingBudget <= 0) {
this.hbarLimitCounter.labels(mode, methodName).inc(1);
this.logger.warn(
`${requestIdPrefix} HBAR rate limit incoming call: remainingBudget=${this.remainingBudget}, totalBudget=${this.totalBudget}, resetTimestamp=${this.reset}`,
);
return true;
}

this.logger.trace(
`${requestIdPrefix} HBAR rate limit not reached. ${this.remainingBudget} out of ${this.totalBudget} tℏ left in relay budget until ${this.reset}.`,
);
return false;
}

/**
* Checks if the rate limiter should be reset.
* @returns {boolean} - `true` if the rate limiter should be reset, otherwise `false`.
* @private
*/
private shouldResetLimiter(): boolean {
return Date.now() >= this.reset.getTime();
}

/**
* Gets the spending plan for the given eth address or ip address.
* @param {string} ethAddress - The eth address to get the spending plan for.
Expand Down
Loading
Loading