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: improved preemtive rate limit for file transactions #2922

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
15952c5
fix: moved preemtive logic to HbarLimiter class
quiet-node Aug 27, 2024
4727aae
fix: fixed callDataSize input for shouldPreemtivelyLimit()
quiet-node Aug 27, 2024
f006e27
fix: reworked shouldPreemtivelyLimit()
quiet-node Aug 30, 2024
c799d8f
fix: fixed hbarLimiter test
quiet-node Sep 3, 2024
648ddd0
fix: added caching logic to getCurrentNetworkExchangeRateInCents
quiet-node Sep 3, 2024
19e34d8
fix: awaited cacheSerivce.set
quiet-node Sep 4, 2024
504a9c2
test: added coverage for preemtive rate limit logic in sdkClient class
quiet-node Sep 4, 2024
c57c000
chore: updated expected fail message
quiet-node Sep 4, 2024
b1c5677
chore: added networkExchangeRateEndpoint constant
quiet-node Sep 4, 2024
09db0f2
chore: renamed callingMethod for exchange rate caching
quiet-node Sep 4, 2024
ebba15d
chore: updated error type for jsdoc
quiet-node Sep 4, 2024
30f6486
fix: moved exchangeRate mock to beforeEach
quiet-node Sep 4, 2024
b7dffb3
fix: updated estimateFileTransactionFee UT
quiet-node Sep 4, 2024
2e759e0
fix: cleaned up hbarLimiter spec
quiet-node Sep 4, 2024
0dbf55e
chore: added HBAR_TO_TINYBAR_COEF constant
quiet-node Sep 5, 2024
4c0bf2b
fix: reworked reassigning values for HBAR_RATE_LIMIT_PREEMTIVE_CHECK
quiet-node Sep 9, 2024
dd1fa0d
fix: reverted shouldPreemtivelyLimitFileTransactions() to return boolean
quiet-node Sep 9, 2024
61c6a5a
chore: fixed typo
quiet-node Sep 9, 2024
a781634
fix: renamed variables
quiet-node Sep 9, 2024
e26727d
fix: unnested if else blocks
quiet-node Sep 9, 2024
eb199c1
fix: fixed callDataSize input for shouldPreemtivelyLimit() v2
quiet-node Sep 10, 2024
93c79c2
fix: updated variable name
quiet-node Sep 10, 2024
94aeea6
fix: updated openrpc.spec.ts
quiet-node Sep 10, 2024
e9492f5
feat: fixed the logic of the estimatedFileTransactionTotalFee
quiet-node Sep 12, 2024
c03a74c
fix: renamed estimatedTxFee
quiet-node Sep 16, 2024
46b980d
chore: updated log messages
quiet-node Sep 16, 2024
a431881
chore: renamed vars
quiet-node Sep 16, 2024
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 packages/relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export interface Eth {

protocolVersion(requestId?: string): JsonRpcError;

sendRawTransaction(transaction: string, requestId?: string): Promise<string | JsonRpcError>;
sendRawTransaction(transaction: string, requestId: string): Promise<string | JsonRpcError>;

sendTransaction(requestId?: string): JsonRpcError;

Expand Down
5 changes: 3 additions & 2 deletions packages/relay/src/lib/clients/mirrorNodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,14 +912,15 @@ export class MirrorNodeClient {
return { limit: limit, order: order };
}

public async getNetworkExchangeRate(timestamp?: string, requestIdPrefix?: string) {
public async getNetworkExchangeRate(requestId: string, timestamp?: string) {
const formattedRequestId = formatRequestIdMessage(requestId);
const queryParamObject = {};
this.setQueryParam(queryParamObject, 'timestamp', timestamp);
const queryParams = this.getQueryParams(queryParamObject);
return this.get(
`${MirrorNodeClient.GET_NETWORK_EXCHANGERATE_ENDPOINT}${queryParams}`,
MirrorNodeClient.GET_NETWORK_EXCHANGERATE_ENDPOINT,
requestIdPrefix,
formattedRequestId,
);
}

Expand Down
33 changes: 13 additions & 20 deletions packages/relay/src/lib/clients/sdkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export class SDKClient {
* @param {string} callerName - The name of the caller initiating the transaction.
* @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 All @@ -385,6 +386,7 @@ export class SDKClient {
callerName: string,
originalCallerAddress: string,
networkGasPriceInWeiBars: number,
currentNetworkExchangeRateInCents: number,
requestId: string,
): Promise<{ txResponse: TransactionResponse; fileId: FileId | null }> {
const ethereumTransactionData: EthereumTransactionData = EthereumTransactionData.fromBytes(transactionBuffer);
Expand All @@ -397,29 +399,20 @@ export class SDKClient {
if (ethereumTransactionData.callData.length <= this.fileAppendChunkSize) {
ethereumTransaction.setEthereumData(ethereumTransactionData.toBytes());
} else {
// notice: this solution is temporary and subject to change.
const isPreemtiveCheckOn = process.env.HBAR_RATE_LIMIT_PREEMTIVE_CHECK
? process.env.HBAR_RATE_LIMIT_PREEMTIVE_CHECK === 'true'
: false;
const isPreemptiveCheckOn = process.env.HBAR_RATE_LIMIT_PREEMPTIVE_CHECK === 'true';

if (isPreemtiveCheckOn) {
const numFileCreateTxs = 1;
const numFileAppendTxs = Math.ceil(ethereumTransactionData.callData.length / this.fileAppendChunkSize);
const fileCreateFee = Number(process.env.HOT_FIX_FILE_CREATE_FEE || 100000000); // 1 hbar
const fileAppendFee = Number(process.env.HOT_FIX_FILE_APPEND_FEE || 120000000); // 1.2 hbar

const totalPreemtiveTransactionFee = numFileCreateTxs * fileCreateFee + numFileAppendTxs * fileAppendFee;

const shouldPreemtivelyLimit = this.hbarLimiter.shouldPreemtivelyLimit(
if (isPreemptiveCheckOn) {
const hexCallDataLength = Buffer.from(ethereumTransactionData.callData).toString('hex').length;
const shouldPreemptivelyLimit = this.hbarLimiter.shouldPreemptivelyLimitFileTransactions(
originalCallerAddress,
totalPreemtiveTransactionFee,
hexCallDataLength,
this.fileAppendChunkSize,
currentNetworkExchangeRateInCents,
requestId,
);
if (shouldPreemtivelyLimit) {
this.logger.trace(
`${requestIdPrefix} The total preemptive transaction fee exceeds the current remaining HBAR budget due to an excessively large callData size: numFileCreateTxs=${numFileCreateTxs}, numFileAppendTxs=${numFileAppendTxs}, totalPreemtiveTransactionFee=${totalPreemtiveTransactionFee}, callDataSize=${ethereumTransactionData.callData.length}`,
);
throw predefined.HBAR_RATE_LIMIT_PREEMTIVE_EXCEEDED;

if (shouldPreemptivelyLimit) {
throw predefined.HBAR_RATE_LIMIT_PREEMPTIVE_EXCEEDED;
}
}

Expand Down Expand Up @@ -1045,7 +1038,7 @@ export class SDKClient {
public calculateTxRecordChargeAmount(exchangeRate: ExchangeRate): number {
const exchangeRateInCents = exchangeRate.exchangeRateInCents;
const hbarToTinybar = Hbar.from(1, HbarUnit.Hbar).toTinybars().toNumber();
return Math.round((constants.TX_RECORD_QUERY_COST_IN_CENTS / exchangeRateInCents) * hbarToTinybar);
return Math.round((constants.NETWORK_FEES_IN_CENTS.TRANSACTION_GET_RECORD / exchangeRateInCents) * hbarToTinybar);
}

/**
Expand Down
20 changes: 18 additions & 2 deletions packages/relay/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ enum CACHE_KEY {
RESOLVE_ENTITY_TYPE = 'resolveEntityType',
SYNTHETIC_LOG_TRANSACTION_HASH = 'syntheticLogTransactionHash',
FILTERID = 'filterId',
CURRENT_NETWORK_EXCHANGE_RATE = 'currentNetworkExchangeRate',
}

enum CACHE_TTL {
Expand All @@ -66,6 +67,7 @@ export enum CallType {
}

export default {
HBAR_TO_TINYBAR_COEF: 100_000_000,
TINYBAR_TO_WEIBAR_COEF: 10_000_000_000,
// 131072 bytes are 128kbytes
SEND_RAW_TRANSACTION_SIZE_LIMIT: process.env.SEND_RAW_TRANSACTION_SIZE_LIMIT
Expand Down Expand Up @@ -189,8 +191,22 @@ export default {
// computed hash of an empty Trie object
DEFAULT_ROOT_HASH: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421',

// @source: https://docs.hedera.com/hedera/networks/mainnet/fees
TX_RECORD_QUERY_COST_IN_CENTS: 0.01,
// The fee is calculated via the fee calculator: https://docs.hedera.com/hedera/networks/mainnet/fees
// The maximum fileAppendChunkSize is currently set to 5KB by default; therefore, the estimated fees for FileCreate below are based on a file size of 5KB.
// FILE_APPEND_BASE_FEE & FILE_APPEND_RATE_PER_BYTE are calculated based on data colelction from the fee calculator:
// - 0 bytes = 3.9 cents
// - 100 bytes = 4.01 cents = 3.9 + (100 * 0.0011)
// - 500 bytes = 4.45 cents = 3.9 + (500 * 0.0011)
// - 1000 bytes = 5.01 cents = 3.9 + (1000 * 0.0011)
// - 5120 bytes = 9.53 cents = 3.9 + (5120 * 0.0011)
// final equation: cost_in_cents = base_cost + (bytes × rate_per_byte)
NETWORK_FEES_IN_CENTS: {
TRANSACTION_GET_RECORD: 0.01,
FILE_CREATE_PER_5_KB: 9.51,
FILE_APPEND_PER_5_KB: 9.55,
FILE_APPEND_BASE_FEE: 3.9,
FILE_APPEND_RATE_PER_BYTE: 0.0011,
},

EVENTS: {
EXECUTE_TRANSACTION: 'execute_transaction',
Expand Down
2 changes: 1 addition & 1 deletion packages/relay/src/lib/errors/JsonRpcError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const predefined = {
code: -32606,
message: 'HBAR Rate limit exceeded',
}),
HBAR_RATE_LIMIT_PREEMTIVE_EXCEEDED: new JsonRpcError({
HBAR_RATE_LIMIT_PREEMPTIVE_EXCEEDED: new JsonRpcError({
code: -32606,
message: 'The HBAR rate limit was preemptively exceeded due to an excessively large callData size.',
}),
Expand Down
32 changes: 29 additions & 3 deletions packages/relay/src/lib/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
formatContractResult,
weibarHexToTinyBarInt,
isValidEthereumAddress,
formatRequestIdMessage,
formatTransactionIdWithoutQueryParams,
getFunctionSelector,
} from '../formatters';
Expand Down Expand Up @@ -1509,7 +1510,7 @@

if (!accountNonce) {
this.logger.warn(`${requestIdPrefix} Cannot find updated account nonce.`);
throw predefined.INTERNAL_ERROR(`Cannot find updated account nonce for WRONT_NONCE error.`);
throw predefined.INTERNAL_ERROR(`Cannot find updated account nonce for WRONG_NONCE error.`);

Check warning on line 1513 in packages/relay/src/lib/eth.ts

View check run for this annotation

Codecov / codecov/patch

packages/relay/src/lib/eth.ts#L1513

Added line #L1513 was not covered by tests
}

if (parsedTx.nonce > accountNonce) {
Expand Down Expand Up @@ -1538,9 +1539,10 @@
* Submits a transaction to the network for execution.
*
* @param transaction
* @param requestIdPrefix
* @param requestId
*/
async sendRawTransaction(transaction: string, requestIdPrefix: string): Promise<string | JsonRpcError> {
async sendRawTransaction(transaction: string, requestId: string): Promise<string | JsonRpcError> {
const requestIdPrefix = formatRequestIdMessage(requestId);
if (transaction?.length >= constants.FUNCTION_SELECTOR_CHAR_LENGTH)
this.ethExecutionsCounter
.labels(EthImpl.ethSendRawTransaction, transaction.substring(0, constants.FUNCTION_SELECTOR_CHAR_LENGTH))
Expand All @@ -1563,6 +1565,7 @@
EthImpl.ethSendRawTransaction,
originalCallerAddress,
networkGasPriceInWeiBars,
await this.getCurrentNetworkExchangeRateInCents(requestIdPrefix),
requestIdPrefix,
);

Expand Down Expand Up @@ -2514,4 +2517,27 @@
return total + amount;
}, 0);
}

/**
* Retrieves the current network exchange rate of HBAR to USD in cents.
*
* @param {string} requestId - The unique identifier for the request.
* @returns {Promise<number>} - A promise that resolves to the current exchange rate in cents.
*/
private async getCurrentNetworkExchangeRateInCents(requestId: string): Promise<number> {
const requestIdPrefix = formatRequestIdMessage(requestId);
const cacheKey = constants.CACHE_KEY.CURRENT_NETWORK_EXCHANGE_RATE;
const callingMethod = this.getCurrentNetworkExchangeRateInCents.name;
const cacheTTL = 15 * 60 * 1000; // 15 minutes

let currentNetworkExchangeRate = await this.cacheService.getAsync(cacheKey, callingMethod, requestIdPrefix);

if (!currentNetworkExchangeRate) {
currentNetworkExchangeRate = (await this.mirrorNodeClient.getNetworkExchangeRate(requestId)).current_rate;
await this.cacheService.set(cacheKey, currentNetworkExchangeRate, callingMethod, cacheTTL, requestIdPrefix);
}

const exchangeRateInCents = currentNetworkExchangeRate.cent_equivalent / currentNetworkExchangeRate.hbar_equivalent;
return exchangeRateInCents;
}
}
105 changes: 82 additions & 23 deletions packages/relay/src/lib/hbarlimiter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
*/

import { Logger } from 'pino';
import { Counter, Gauge, Registry } from 'prom-client';
import constants from '../constants';
import { predefined } from '../errors/JsonRpcError';
import { Registry, Counter, Gauge } from 'prom-client';
import { formatRequestIdMessage } from '../../formatters';

export default class HbarLimit {
victor-yanev marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -108,44 +110,63 @@ export default class HbarLimit {
if (this.remainingBudget <= 0) {
this.hbarLimitCounter.labels(mode, methodName).inc(1);
this.logger.warn(
`${requestIdPrefix} HBAR rate limit incoming call: remainingBudget=${this.remainingBudget}, total=${this.total}, resetTimestamp=${this.reset}`,
`${requestIdPrefix} HBAR rate limit incoming call: remainingBudget=${this.remainingBudget}, total=${this.total}, resetTimestamp=${this.reset}.`,
);
return true;
} else {
this.logger.trace(
`${requestIdPrefix} HBAR rate limit not reached: remainingBudget=${this.remainingBudget}, total=${this.total}, resetTimestamp=${this.reset}.`,
);
return false;
}

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

return false;
}

/**
* Determines whether a preemptive HBAR rate limit should be applied based on the remaining budget and the transaction fee.
*
* Bypass if the originalCallerAddress is whitelisted
* Preemptively limits HBAR transactions based on the estimated total fee for file transactions and the remaining budget.
* This method checks if the caller is whitelisted and bypasses the limit if they are. If not, it calculates the
* estimated transaction fees based on the call data size and file append chunk size, and throws an error if the
* remaining budget is insufficient to cover the estimated fees.
*
* @param {string} originalCallerAddress - The address of the original caller making the request.
* @param {number} transactionFee - The transaction fee in tinybars to be checked against the remaining budget.
* @param {string} [requestId] - An optional unique request ID for tracking the request.
* @returns {boolean} - Returns `true` if the rate limit should be preemptively enforced, otherwise `false`.
* @param {string} originalCallerAddress - The address of the caller initiating the transaction.
* @param {number} callDataSize - The size of the call data that will be used in the transaction.
* @param {number} fileChunkSize - The chunk size used for file append transactions.
* @param {string} requestId - The request ID for tracing the request flow.
* @returns {boolean} - Return true if the transaction should be preemptively rate limited, otherwise return false.
* @throws {JsonRpcError} Throws an error if the total estimated transaction fee exceeds the remaining HBAR budget.
*/
shouldPreemtivelyLimit(originalCallerAddress: string, transactionFee: number, requestId?: string): boolean {
if (!this.enabled) {
return false;
}

shouldPreemptivelyLimitFileTransactions(
originalCallerAddress: string,
callDataSize: number,
fileChunkSize: number,
currentNetworkExchangeRateInCents: number,
requestId: string,
): boolean {
const requestIdPrefix = formatRequestIdMessage(requestId);

// check if the caller is a whitelisted caller
if (this.isAccountWhiteListed(originalCallerAddress)) {
this.logger.trace(
`${requestIdPrefix} HBAR preemtive rate limit bypassed - the caller is a whitelisted account: originalCallerAddress=${originalCallerAddress}`,
`${requestIdPrefix} Request bypasses the preemptive limit check - the caller is a whitelisted account: originalCallerAddress=${originalCallerAddress}`,
);
return false;
}

return this.remainingBudget - transactionFee < 0;
const estimatedTxFee = this.estimateFileTransactionsFee(
callDataSize,
fileChunkSize,
currentNetworkExchangeRateInCents,
);

if (this.remainingBudget - estimatedTxFee < 0) {
this.logger.warn(
`${requestIdPrefix} Request fails the preemptive limit check - the remaining HBAR budget was not enough to accommodate the estimated transaction fee: remainingBudget=${this.remainingBudget}, total=${this.total}, resetTimestamp=${this.reset}, callDataSize=${callDataSize}, estimatedTxFee=${estimatedTxFee}, exchangeRateInCents=${currentNetworkExchangeRateInCents}`,
);
return true;
}

this.logger.trace(
`${requestIdPrefix} Request passes the preemptive limit check - the remaining HBAR budget is enough to accommodate the estimated transaction fee: remainingBudget=${this.remainingBudget}, total=${this.total}, resetTimestamp=${this.reset}, callDataSize=${callDataSize}, estimatedTxFee=${estimatedTxFee}, exchangeRateInCents=${currentNetworkExchangeRateInCents}`,
);
return false;
}

/**
Expand Down Expand Up @@ -201,6 +222,44 @@ export default class HbarLimit {
return this.reset;
}

/**
* Estimates the total fee in tinybars for file transactions based on the given call data size,
* file chunk size, and the current network exchange rate.
*
* @param {number} callDataSize - The total size of the call data in bytes.
* @param {number} fileChunkSize - The size of each file chunk in bytes.
* @param {number} currentNetworkExchangeRateInCents - The current network exchange rate in cents per HBAR.
* @returns {number} The estimated transaction fee in tinybars.
*/
estimateFileTransactionsFee(
callDataSize: number,
fileChunkSize: number,
currentNetworkExchangeRateInCents: number,
victor-yanev marked this conversation as resolved.
Show resolved Hide resolved
): number {
const fileCreateTransactions = 1;
const fileCreateFeeInCents = constants.NETWORK_FEES_IN_CENTS.FILE_CREATE_PER_5_KB;

// The first chunk goes in with FileCreateTransaciton, the rest are FileAppendTransactions
const fileAppendTransactions = Math.floor(callDataSize / fileChunkSize) - 1;
const lastFileAppendChunkSize = callDataSize % fileChunkSize;

const fileAppendFeeInCents = constants.NETWORK_FEES_IN_CENTS.FILE_APPEND_PER_5_KB;
const lastFileAppendChunkFeeInCents =
constants.NETWORK_FEES_IN_CENTS.FILE_APPEND_BASE_FEE +
lastFileAppendChunkSize * constants.NETWORK_FEES_IN_CENTS.FILE_APPEND_RATE_PER_BYTE;

const totalTxFeeInCents =
fileCreateTransactions * fileCreateFeeInCents +
fileAppendFeeInCents * fileAppendTransactions +
lastFileAppendChunkFeeInCents;

const estimatedTxFee = Math.round(
(totalTxFeeInCents / currentNetworkExchangeRateInCents) * constants.HBAR_TO_TINYBAR_COEF,
);

return estimatedTxFee;
}

/**
* Decides whether it should reset budget and timer.
*/
Expand Down
29 changes: 28 additions & 1 deletion packages/relay/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,7 @@ export const defaultErrorMessageHex =
'0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000d53657420746f2072657665727400000000000000000000000000000000000000';

export const calculateTxRecordChargeAmount = (exchangeRateIncents: number) => {
const txQueryCostInCents = constants.TX_RECORD_QUERY_COST_IN_CENTS;
const txQueryCostInCents = constants.NETWORK_FEES_IN_CENTS.TRANSACTION_GET_RECORD;
const hbarToTinybar = Hbar.from(1, HbarUnit.Hbar).toTinybars().toNumber();
return Math.round((txQueryCostInCents / exchangeRateIncents) * hbarToTinybar);
};
Expand Down Expand Up @@ -920,3 +920,30 @@ export const stopRedisInMemoryServer = async (
process.env.REDIS_ENABLED = envsToReset.REDIS_ENABLED;
process.env.REDIS_URL = envsToReset.REDIS_URL;
};

export const estimateFileTransactionsFee = (
callDataSize: number,
fileChunkSize: number,
exchangeRateInCents: number,
) => {
const fileCreateTransactions = 1;
const fileCreateFeeInCents = constants.NETWORK_FEES_IN_CENTS.FILE_CREATE_PER_5_KB;

// The first chunk goes in with FileCreateTransaciton, the rest are FileAppendTransactions
const fileAppendTransactions = Math.floor(callDataSize / fileChunkSize) - 1;
const lastFileAppendChunkSize = callDataSize % fileChunkSize;

const fileAppendFeeInCents = constants.NETWORK_FEES_IN_CENTS.FILE_APPEND_PER_5_KB;
const lastFileAppendChunkFeeInCents =
constants.NETWORK_FEES_IN_CENTS.FILE_APPEND_BASE_FEE +
lastFileAppendChunkSize * constants.NETWORK_FEES_IN_CENTS.FILE_APPEND_RATE_PER_BYTE;

const totalTxFeeInCents =
fileCreateTransactions * fileCreateFeeInCents +
fileAppendFeeInCents * fileAppendTransactions +
lastFileAppendChunkFeeInCents;

const estimatedTxFee = Math.round((totalTxFeeInCents / exchangeRateInCents) * constants.HBAR_TO_TINYBAR_COEF);

return estimatedTxFee;
};
Loading
Loading