From 21be072b6f1a993b92b1d18092ee89380fd1cc09 Mon Sep 17 00:00:00 2001 From: Raunaque97 Date: Mon, 26 Jan 2026 17:18:12 +0530 Subject: [PATCH] Add AdaptiveFeeStrategy with a TTLCache for dynamic txn fee calculation --- packages/sequencer/package.json | 1 + packages/sequencer/src/helpers/TTLCache.ts | 62 +++++ packages/sequencer/src/index.ts | 1 + .../baselayer/fees/AdaptiveFeeStrategy.ts | 226 ++++++++++++++++++ .../baselayer/fees/ConstantFeeStrategy.ts | 2 +- .../protocol/baselayer/fees/FeeStrategy.ts | 2 +- .../src/settlement/BridgingModule.ts | 6 +- .../bridging/BridgingDeployInteraction.ts | 2 +- .../bridging/BridgingSettlementInteraction.ts | 2 +- .../vanilla/VanillaDeployInteraction.ts | 2 +- .../vanilla/VanillaSettlementInteraction.ts | 2 +- .../sequencer/test/settlement/Settlement.ts | 6 +- 12 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 packages/sequencer/src/helpers/TTLCache.ts create mode 100644 packages/sequencer/src/protocol/baselayer/fees/AdaptiveFeeStrategy.ts diff --git a/packages/sequencer/package.json b/packages/sequencer/package.json index 79370f6cc..623dfaddc 100644 --- a/packages/sequencer/package.json +++ b/packages/sequencer/package.json @@ -32,6 +32,7 @@ "@types/node": "^20.2.5" }, "dependencies": { + "@urql/core": "^4.1.4", "ascii-table3": "^0.9.0", "compute-gcd": "^1.2.1", "lodash-es": "^4.17.21", diff --git a/packages/sequencer/src/helpers/TTLCache.ts b/packages/sequencer/src/helpers/TTLCache.ts new file mode 100644 index 000000000..93be93d8c --- /dev/null +++ b/packages/sequencer/src/helpers/TTLCache.ts @@ -0,0 +1,62 @@ +import { log } from "@proto-kit/common"; + +export type TtlCacheOptions = { + ttlMs: number; + load: () => Promise; + label?: string; +}; + +/** + * Simple TTL cache: + * - returns cached values if fresh + * - refreshes on demand when stale (awaited) + * - de-dupes concurrent refreshes + */ +export class TtlCache { + private value?: T; + + private fetchedAtMs?: number; + + private inFlight?: Promise; + + public constructor(private readonly options: TtlCacheOptions) {} + + public getCached(): T | undefined { + return this.value; + } + + public async get(nowMs: number = Date.now()): Promise { + if (!this.isStale(nowMs)) { + return this.value; + } + + if (this.inFlight !== undefined) { + return await this.inFlight; + } + + const { load, label } = this.options; + this.inFlight = (async () => { + try { + const next = await load(); + if (next !== undefined) { + this.value = next; + this.fetchedAtMs = Date.now(); + } + return this.value; + } catch (err) { + log.warn(`${label ?? "TtlCache"}: refresh failed (${String(err)})`); + return this.value; + } finally { + this.inFlight = undefined; + } + })(); + + return await this.inFlight; + } + + private isStale(nowMs: number): boolean { + const { ttlMs } = this.options; + if (this.fetchedAtMs === undefined) return true; + return nowMs - this.fetchedAtMs >= ttlMs; + } +} diff --git a/packages/sequencer/src/index.ts b/packages/sequencer/src/index.ts index 813bc66d5..dfcf67373 100644 --- a/packages/sequencer/src/index.ts +++ b/packages/sequencer/src/index.ts @@ -29,6 +29,7 @@ export * from "./protocol/baselayer/network-utils/RemoteNetworkUtils"; export * from "./protocol/baselayer/network-utils/LightnetUtils"; export * from "./protocol/baselayer/network-utils/LocalBlockchainUtils"; export * from "./protocol/baselayer/fees/ConstantFeeStrategy"; +export * from "./protocol/baselayer/fees/AdaptiveFeeStrategy"; export * from "./protocol/production/helpers/UntypedOption"; export * from "./protocol/production/helpers/UntypedStateTransition"; export * from "./protocol/production/tasks/TransactionProvingTask"; diff --git a/packages/sequencer/src/protocol/baselayer/fees/AdaptiveFeeStrategy.ts b/packages/sequencer/src/protocol/baselayer/fees/AdaptiveFeeStrategy.ts new file mode 100644 index 000000000..43d8819f0 --- /dev/null +++ b/packages/sequencer/src/protocol/baselayer/fees/AdaptiveFeeStrategy.ts @@ -0,0 +1,226 @@ +import { noop } from "@proto-kit/common"; +import { Client, fetchExchange, gql } from "@urql/core"; +import { inject } from "tsyringe"; + +import { TtlCache } from "../../../helpers/TTLCache"; +import { + sequencerModule, + SequencerModule, +} from "../../../sequencer/builder/SequencerModule"; +import { MinaBaseLayer } from "../MinaBaseLayer"; + +import { FeeStrategy } from "./FeeStrategy"; + +/** + * Adaptive fee strategy configuration. + * - If mempool is at/above `mempoolLargeSize`, + * use the `mempoolNthHighest` highest fee from the mempool. + * - When mempool is small, use the `lastBlockNthLowest` lowest fee from the recent block. + * - Clamp the fee between `minFee` and `maxFee`. + * - Use `fallbackFee` if no fees are available. + */ +export type AdaptiveFeeStrategyConfig = { + mempoolLargeSize?: number; + mempoolNthHighest?: number; + lastBlockNthLowest?: number; + minFee?: number; + maxFee?: number; + fallbackFee?: number; +}; + +const MEMPOOL_CACHE_TIMEOUT_MS = 30 * 1000; // 30 seconds +const BLOCK_CACHE_TIMEOUT_MS = 3 * 60_000; // 3 minutes + +const DEFAULT_MEMPOOL_LARGE_SIZE = 10; +const DEFAULT_MEMPOOL_NTH_HIGHEST = 10; +const DEFAULT_LAST_BLOCK_NTH_LOWEST = 1; +const DEFAULT_FALLBACK_FEE = 0.1 * 1e9; + +type BlockFeesQueryResult = { + bestChain?: Array<{ + transactions?: { + zkappCommands?: Array<{ + zkappCommand?: { + feePayer?: { + body?: { + fee?: string | number; + }; + }; + }; + }>; + }; + }>; +}; + +type MempoolFeesQueryResult = { + pooledZkappCommands?: Array<{ + zkappCommand?: { + feePayer?: { + body?: { + fee?: string | number; + }; + }; + }; + }>; +}; + +const MempoolFeesQuery = gql` + query MempoolFeesQuery { + pooledZkappCommands { + zkappCommand { + feePayer { + body { + fee + } + } + } + } + } +`; + +const LastBlockFeesQuery = gql` + query LastBlockFeesQuery { + bestChain(maxLength: 1) { + transactions { + zkappCommands { + zkappCommand { + feePayer { + body { + fee + } + } + } + } + } + } + } +`; + +function clamp(fee: number, min?: number, max?: number): number { + let result = fee; + if (min !== undefined) result = Math.max(result, min); + if (max !== undefined) result = Math.min(result, max); + return result; +} + +@sequencerModule() +export class AdaptiveFeeStrategy + extends SequencerModule + implements FeeStrategy +{ + private mempoolFeesCache = new TtlCache({ + ttlMs: MEMPOOL_CACHE_TIMEOUT_MS, + label: "AdaptiveFeeStrategy(mempool)", + load: async () => { + const { client } = this; + if (client === undefined) return undefined; + + const result = await client + .query(MempoolFeesQuery, {}) + .toPromise(); + return ( + result.data?.pooledZkappCommands?.map((tx) => + Number(tx?.zkappCommand?.feePayer?.body?.fee) + ) ?? [] + ); + }, + }); + + private lastBlockFeesCache = new TtlCache({ + ttlMs: BLOCK_CACHE_TIMEOUT_MS, + label: "AdaptiveFeeStrategy(last-block)", + load: async () => { + const { client } = this; + if (client === undefined) return undefined; + + const result = await client + .query(LastBlockFeesQuery, {}) + .toPromise(); + return ( + result.data?.bestChain?.[0]?.transactions?.zkappCommands?.map((cmd) => + Number(cmd?.zkappCommand?.feePayer?.body?.fee) + ) ?? [] + ); + }, + }); + + private initializedClient?: Client; + + public constructor( + // BaseLayer should provide a graphql URL on remote/lightnet Mina networks. + @inject("BaseLayer", { isOptional: true }) + private readonly baseLayer: unknown + ) { + super(); + } + + private get mempoolLargeSize(): number { + return this.config.mempoolLargeSize ?? DEFAULT_MEMPOOL_LARGE_SIZE; + } + + private get mempoolNthHighest(): number { + return this.config.mempoolNthHighest ?? DEFAULT_MEMPOOL_NTH_HIGHEST; + } + + private get lastBlockNthLowest(): number { + return this.config.lastBlockNthLowest ?? DEFAULT_LAST_BLOCK_NTH_LOWEST; + } + + private get fallbackFee(): number { + return this.config.fallbackFee ?? DEFAULT_FALLBACK_FEE; + } + + private resolveGraphqlUrlFromBaseLayer(): string | undefined { + if (!(this.baseLayer instanceof MinaBaseLayer)) { + return undefined; + } + const { network } = this.baseLayer.config; + return network.type === "local" ? undefined : network.graphql; + } + + private get client(): Client | undefined { + const url = this.resolveGraphqlUrlFromBaseLayer(); + if (url === undefined) return undefined; + + if (this.initializedClient === undefined) { + this.initializedClient = new Client({ + url, + exchanges: [fetchExchange], + }); + } + return this.initializedClient; + } + + private computeFee(mempoolFees: number[], lastBlockFees: number[]): number { + let baseline = this.fallbackFee; + if (mempoolFees.length >= this.mempoolLargeSize) { + // For large mempools, use the n-th highest fee from the mempool. + const feesDescending = [...mempoolFees].sort((a, b) => b - a); + baseline = + feesDescending[ + clamp(this.mempoolNthHighest - 1, 0, feesDescending.length - 1) + ]; + } else { + // For small mempools, use the n-th lowest fee from the last block. + const feesAscending = [...lastBlockFees].sort((a, b) => a - b); + baseline = + feesAscending[ + clamp(this.lastBlockNthLowest - 1, 0, feesAscending.length - 1) + ]; + } + return clamp(baseline, this.config.minFee, this.config.maxFee); + } + + public async getFee(): Promise { + const now = Date.now(); + const [mempoolFees, lastBlockFees] = await Promise.all([ + this.mempoolFeesCache.get(now), + this.lastBlockFeesCache.get(now), + ]); + return this.computeFee(mempoolFees ?? [], lastBlockFees ?? []); + } + + public async start() { + noop(); + } +} diff --git a/packages/sequencer/src/protocol/baselayer/fees/ConstantFeeStrategy.ts b/packages/sequencer/src/protocol/baselayer/fees/ConstantFeeStrategy.ts index 87fa21479..111377785 100644 --- a/packages/sequencer/src/protocol/baselayer/fees/ConstantFeeStrategy.ts +++ b/packages/sequencer/src/protocol/baselayer/fees/ConstantFeeStrategy.ts @@ -18,7 +18,7 @@ export class ConstantFeeStrategy extends SequencerModule implements FeeStrategy { - getFee(): number { + public async getFee(): Promise { return this.config.fee ?? DEFAULT_FEE; } diff --git a/packages/sequencer/src/protocol/baselayer/fees/FeeStrategy.ts b/packages/sequencer/src/protocol/baselayer/fees/FeeStrategy.ts index 1bc29cc05..4a26a8860 100644 --- a/packages/sequencer/src/protocol/baselayer/fees/FeeStrategy.ts +++ b/packages/sequencer/src/protocol/baselayer/fees/FeeStrategy.ts @@ -1,3 +1,3 @@ export interface FeeStrategy { - getFee(): number; + getFee(): Promise; } diff --git a/packages/sequencer/src/settlement/BridgingModule.ts b/packages/sequencer/src/settlement/BridgingModule.ts index a575c5e3a..55284cfcb 100644 --- a/packages/sequencer/src/settlement/BridgingModule.ts +++ b/packages/sequencer/src/settlement/BridgingModule.ts @@ -249,7 +249,7 @@ export class BridgingModule extends SequencerModule { sender: feepayer, nonce: nonce, memo: `Deploy token bridge for ${truncate(tokenId.toString(), { length: 6 })}`, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), }, async () => { AccountUpdate.fundNewAccount(feepayer, 1); @@ -512,7 +512,7 @@ export class BridgingModule extends SequencerModule { sender: feepayer, // eslint-disable-next-line no-plusplus nonce: nonce++, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), memo: "pull state root", }, async () => { @@ -630,7 +630,7 @@ export class BridgingModule extends SequencerModule { sender: feepayer, // eslint-disable-next-line no-plusplus nonce: nonce++, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), memo: "roll up actions", }, async () => { diff --git a/packages/sequencer/src/settlement/interactions/bridging/BridgingDeployInteraction.ts b/packages/sequencer/src/settlement/interactions/bridging/BridgingDeployInteraction.ts index c1c24d4a4..af6281aff 100644 --- a/packages/sequencer/src/settlement/interactions/bridging/BridgingDeployInteraction.ts +++ b/packages/sequencer/src/settlement/interactions/bridging/BridgingDeployInteraction.ts @@ -87,7 +87,7 @@ export class BridgingDeployInteraction implements DeployInteraction { { sender: feepayer, nonce, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), memo: "Protokit settlement deploy", }, async () => { diff --git a/packages/sequencer/src/settlement/interactions/bridging/BridgingSettlementInteraction.ts b/packages/sequencer/src/settlement/interactions/bridging/BridgingSettlementInteraction.ts index ee8b74983..0475d2a4d 100644 --- a/packages/sequencer/src/settlement/interactions/bridging/BridgingSettlementInteraction.ts +++ b/packages/sequencer/src/settlement/interactions/bridging/BridgingSettlementInteraction.ts @@ -93,7 +93,7 @@ export class BridgingSettlementInteraction implements SettleInteraction { { sender: feepayer, nonce: options?.nonce, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), memo: "Protokit settle", }, async () => { diff --git a/packages/sequencer/src/settlement/interactions/vanilla/VanillaDeployInteraction.ts b/packages/sequencer/src/settlement/interactions/vanilla/VanillaDeployInteraction.ts index c5fb126fe..925723552 100644 --- a/packages/sequencer/src/settlement/interactions/vanilla/VanillaDeployInteraction.ts +++ b/packages/sequencer/src/settlement/interactions/vanilla/VanillaDeployInteraction.ts @@ -85,7 +85,7 @@ export class VanillaDeployInteraction implements DeployInteraction { { sender: feepayer, nonce, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), memo: "Protokit settlement deploy", }, async () => { diff --git a/packages/sequencer/src/settlement/interactions/vanilla/VanillaSettlementInteraction.ts b/packages/sequencer/src/settlement/interactions/vanilla/VanillaSettlementInteraction.ts index 096438ab5..3fa30d091 100644 --- a/packages/sequencer/src/settlement/interactions/vanilla/VanillaSettlementInteraction.ts +++ b/packages/sequencer/src/settlement/interactions/vanilla/VanillaSettlementInteraction.ts @@ -85,7 +85,7 @@ export class VanillaSettlementInteraction implements SettleInteraction { { sender: feepayer, nonce: options?.nonce, - fee: this.feeStrategy.getFee(), + fee: await this.feeStrategy.getFee(), memo: "Protokit settle", }, async () => { diff --git a/packages/sequencer/test/settlement/Settlement.ts b/packages/sequencer/test/settlement/Settlement.ts index 0b89e8871..03f87461e 100644 --- a/packages/sequencer/test/settlement/Settlement.ts +++ b/packages/sequencer/test/settlement/Settlement.ts @@ -386,7 +386,7 @@ export const settlementTestFn = ( sender: sequencerKey.toPublicKey(), memo: "Deploy custom token", nonce: nonceCounter++, - fee: feeStrategy.getFee(), + fee: await feeStrategy.getFee(), }, async () => { AccountUpdate.fundNewAccount(sequencerKey.toPublicKey(), 3); @@ -455,7 +455,7 @@ export const settlementTestFn = ( sender: sequencerKey.toPublicKey(), memo: "Mint custom token", nonce: nonceCounter++, - fee: feeStrategy.getFee(), + fee: await feeStrategy.getFee(), }, async () => { AccountUpdate.fundNewAccount(sequencerKey.toPublicKey(), 1); @@ -771,7 +771,7 @@ export const settlementTestFn = ( const amount = BigInt(1e9 * 10); - const fee = feeStrategy.getFee(); + const fee = await feeStrategy.getFee(); const tx = await Mina.transaction( { sender: userKey.toPublicKey(),