Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/sequencer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions packages/sequencer/src/helpers/TTLCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { log } from "@proto-kit/common";

export type TtlCacheOptions<T> = {
ttlMs: number;
load: () => Promise<T | undefined>;
label?: string;
};

/**
* Simple TTL cache:
* - returns cached values if fresh
* - refreshes on demand when stale (awaited)
* - de-dupes concurrent refreshes
*/
export class TtlCache<T> {
private value?: T;

private fetchedAtMs?: number;

private inFlight?: Promise<T | undefined>;

public constructor(private readonly options: TtlCacheOptions<T>) {}

public getCached(): T | undefined {
return this.value;
}

public async get(nowMs: number = Date.now()): Promise<T | undefined> {
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;
}
}
1 change: 1 addition & 0 deletions packages/sequencer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
226 changes: 226 additions & 0 deletions packages/sequencer/src/protocol/baselayer/fees/AdaptiveFeeStrategy.ts
Original file line number Diff line number Diff line change
@@ -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<AdaptiveFeeStrategyConfig>
implements FeeStrategy
{
private mempoolFeesCache = new TtlCache<number[]>({
ttlMs: MEMPOOL_CACHE_TIMEOUT_MS,
label: "AdaptiveFeeStrategy(mempool)",
load: async () => {
const { client } = this;
if (client === undefined) return undefined;

const result = await client
.query<MempoolFeesQueryResult>(MempoolFeesQuery, {})
.toPromise();
return (
result.data?.pooledZkappCommands?.map((tx) =>
Number(tx?.zkappCommand?.feePayer?.body?.fee)
) ?? []
);
},
});

private lastBlockFeesCache = new TtlCache<number[]>({
ttlMs: BLOCK_CACHE_TIMEOUT_MS,
label: "AdaptiveFeeStrategy(last-block)",
load: async () => {
const { client } = this;
if (client === undefined) return undefined;

const result = await client
.query<BlockFeesQueryResult>(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<number> {
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class ConstantFeeStrategy
extends SequencerModule<ConstantFeeStrategyConfig>
implements FeeStrategy
{
getFee(): number {
public async getFee(): Promise<number> {
return this.config.fee ?? DEFAULT_FEE;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export interface FeeStrategy {
getFee(): number;
getFee(): Promise<number>;
}
6 changes: 3 additions & 3 deletions packages/sequencer/src/settlement/BridgingModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export class BridgingModule extends SequencerModule<BridgingModuleConfig> {
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);
Expand Down Expand Up @@ -512,7 +512,7 @@ export class BridgingModule extends SequencerModule<BridgingModuleConfig> {
sender: feepayer,
// eslint-disable-next-line no-plusplus
nonce: nonce++,
fee: this.feeStrategy.getFee(),
fee: await this.feeStrategy.getFee(),
memo: "pull state root",
},
async () => {
Expand Down Expand Up @@ -630,7 +630,7 @@ export class BridgingModule extends SequencerModule<BridgingModuleConfig> {
sender: feepayer,
// eslint-disable-next-line no-plusplus
nonce: nonce++,
fee: this.feeStrategy.getFee(),
fee: await this.feeStrategy.getFee(),
memo: "roll up actions",
},
async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading