diff --git a/sdk/src/priorityFee/averageOverSlotsStrategy.ts b/sdk/src/priorityFee/averageOverSlotsStrategy.ts index 334100561..c4ba4a135 100644 --- a/sdk/src/priorityFee/averageOverSlotsStrategy.ts +++ b/sdk/src/priorityFee/averageOverSlotsStrategy.ts @@ -1,7 +1,8 @@ +import { SolanaPriorityFeeResponse } from './solanaPriorityFeeMethod'; import { PriorityFeeStrategy } from './types'; export class AverageOverSlotsStrategy implements PriorityFeeStrategy { - calculate(samples: { slot: number; prioritizationFee: number }[]): number { + calculate(samples: SolanaPriorityFeeResponse[]): number { if (samples.length === 0) { return 0; } diff --git a/sdk/src/priorityFee/averageStrategy.ts b/sdk/src/priorityFee/averageStrategy.ts index 024d9f315..d5dc99b60 100644 --- a/sdk/src/priorityFee/averageStrategy.ts +++ b/sdk/src/priorityFee/averageStrategy.ts @@ -1,7 +1,8 @@ +import { SolanaPriorityFeeResponse } from './solanaPriorityFeeMethod'; import { PriorityFeeStrategy } from './types'; export class AverageStrategy implements PriorityFeeStrategy { - calculate(samples: { slot: number; prioritizationFee: number }[]): number { + calculate(samples: SolanaPriorityFeeResponse[]): number { return ( samples.reduce((a, b) => { return a + b.prioritizationFee; diff --git a/sdk/src/priorityFee/ewmaStrategy.ts b/sdk/src/priorityFee/ewmaStrategy.ts index 7a2296135..d37a0216e 100644 --- a/sdk/src/priorityFee/ewmaStrategy.ts +++ b/sdk/src/priorityFee/ewmaStrategy.ts @@ -1,3 +1,4 @@ +import { SolanaPriorityFeeResponse } from './solanaPriorityFeeMethod'; import { PriorityFeeStrategy } from './types'; class EwmaStrategy implements PriorityFeeStrategy { @@ -11,7 +12,7 @@ class EwmaStrategy implements PriorityFeeStrategy { } // samples provided in desc slot order - calculate(samples: { slot: number; prioritizationFee: number }[]): number { + calculate(samples: SolanaPriorityFeeResponse[]): number { if (samples.length === 0) { return 0; } diff --git a/sdk/src/priorityFee/heliusPriorityFeeMethod.ts b/sdk/src/priorityFee/heliusPriorityFeeMethod.ts new file mode 100644 index 000000000..0621a3c92 --- /dev/null +++ b/sdk/src/priorityFee/heliusPriorityFeeMethod.ts @@ -0,0 +1,51 @@ +import fetch from 'node-fetch'; + +export enum HeliusPriorityLevel { + MIN = 'min', // 25th percentile + LOW = 'low', // 25th percentile + MEDIUM = 'medium', // 50th percentile + HIGH = 'high', // 75th percentile + VERY_HIGH = 'veryHigh', // 95th percentile + UNSAFE_MAX = 'unsafeMax', // 100th percentile +} + +export type HeliusPriorityFeeLevels = { + [key in HeliusPriorityLevel]: number; +}; + +export type HeliusPriorityFeeResponse = { + jsonrpc: string; + result: { + priorityFeeEstimate?: number; + priorityFeeLevels?: HeliusPriorityFeeLevels; + }; + id: string; +}; + +/// Fetches the priority fee from the Helius API +/// https://docs.helius.dev/solana-rpc-nodes/alpha-priority-fee-api +export async function fetchHeliusPriorityFee( + heliusRpcUrl: string, + lookbackDistance: number, + addresses: string[] +): Promise { + const response = await fetch(heliusRpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: '1', + method: 'getPriorityFeeEstimate', + params: [ + { + accountKeys: addresses, + options: { + includeAllPriorityFeeLevels: true, + lookbackSlots: lookbackDistance, + }, + }, + ], + }), + }); + return await response.json(); +} diff --git a/sdk/src/priorityFee/index.ts b/sdk/src/priorityFee/index.ts index 4772f9190..55221f834 100644 --- a/sdk/src/priorityFee/index.ts +++ b/sdk/src/priorityFee/index.ts @@ -4,4 +4,6 @@ export * from './ewmaStrategy'; export * from './maxOverSlotsStrategy'; export * from './maxStrategy'; export * from './priorityFeeSubscriber'; +export * from './solanaPriorityFeeMethod'; +export * from './heliusPriorityFeeMethod'; export * from './types'; diff --git a/sdk/src/priorityFee/maxOverSlotsStrategy.ts b/sdk/src/priorityFee/maxOverSlotsStrategy.ts index dfda6a0df..cbaa5f033 100644 --- a/sdk/src/priorityFee/maxOverSlotsStrategy.ts +++ b/sdk/src/priorityFee/maxOverSlotsStrategy.ts @@ -1,7 +1,8 @@ +import { SolanaPriorityFeeResponse } from './solanaPriorityFeeMethod'; import { PriorityFeeStrategy } from './types'; export class MaxOverSlotsStrategy implements PriorityFeeStrategy { - calculate(samples: { slot: number; prioritizationFee: number }[]): number { + calculate(samples: SolanaPriorityFeeResponse[]): number { if (samples.length === 0) { return 0; } diff --git a/sdk/src/priorityFee/priorityFeeSubscriber.ts b/sdk/src/priorityFee/priorityFeeSubscriber.ts index 433811daa..6ca3f1e2b 100644 --- a/sdk/src/priorityFee/priorityFeeSubscriber.ts +++ b/sdk/src/priorityFee/priorityFeeSubscriber.ts @@ -1,17 +1,31 @@ -import { Connection, PublicKey } from '@solana/web3.js'; -import { PriorityFeeStrategy } from './types'; +import { Connection } from '@solana/web3.js'; +import { + PriorityFeeMethod, + PriorityFeeStrategy, + PriorityFeeSubscriberConfig, +} from './types'; import { AverageOverSlotsStrategy } from './averageOverSlotsStrategy'; import { MaxOverSlotsStrategy } from './maxOverSlotsStrategy'; +import { fetchSolanaPriorityFee } from './solanaPriorityFeeMethod'; +import { + HeliusPriorityFeeLevels, + HeliusPriorityLevel, + fetchHeliusPriorityFee, +} from './heliusPriorityFeeMethod'; export class PriorityFeeSubscriber { connection: Connection; frequencyMs: number; - addresses: PublicKey[]; + addresses: string[]; customStrategy?: PriorityFeeStrategy; averageStrategy = new AverageOverSlotsStrategy(); maxStrategy = new MaxOverSlotsStrategy(); + priorityFeeMethod = PriorityFeeMethod.SOLANA; lookbackDistance: number; + heliusRpcUrl?: string; + lastHeliusSample?: HeliusPriorityFeeLevels; + intervalId?: ReturnType; latestPriorityFee = 0; @@ -20,32 +34,39 @@ export class PriorityFeeSubscriber { lastMaxStrategyResult = 0; lastSlotSeen = 0; - /** - * @param props - * customStrategy : strategy to return the priority fee to use based on recent samples. defaults to AVERAGE. - */ - public constructor({ - connection, - frequencyMs, - addresses, - customStrategy, - slotsToCheck = 10, - }: { - connection: Connection; - frequencyMs: number; - addresses: PublicKey[]; - customStrategy?: PriorityFeeStrategy; - slotsToCheck?: number; - }) { - this.connection = connection; - this.frequencyMs = frequencyMs; - this.addresses = addresses; - if (!customStrategy) { - this.customStrategy = new AverageOverSlotsStrategy(); + public constructor(config: PriorityFeeSubscriberConfig) { + this.connection = config.connection; + this.frequencyMs = config.frequencyMs; + this.addresses = config.addresses.map((address) => address.toBase58()); + if (config.customStrategy) { + this.customStrategy = config.customStrategy; } else { - this.customStrategy = customStrategy; + this.customStrategy = this.averageStrategy; + } + this.lookbackDistance = config.slotsToCheck ?? 50; + if (config.priorityFeeMethod) { + this.priorityFeeMethod = config.priorityFeeMethod; + + if (this.priorityFeeMethod === PriorityFeeMethod.HELIUS) { + if (config.heliusRpcUrl === undefined) { + if (this.connection.rpcEndpoint.includes('helius')) { + this.heliusRpcUrl = this.connection.rpcEndpoint; + } else { + throw new Error( + 'Connection must be helius, or heliusRpcUrl must be provided to use PriorityFeeMethod.HELIUS' + ); + } + } + } + } + + if (this.priorityFeeMethod === PriorityFeeMethod.SOLANA) { + if (this.connection === undefined) { + throw new Error( + 'connection must be provided to use SOLANA priority fee API' + ); + } } - this.lookbackDistance = slotsToCheck; } public async subscribe(): Promise { @@ -53,43 +74,72 @@ export class PriorityFeeSubscriber { return; } + await this.load(); this.intervalId = setInterval(this.load.bind(this), this.frequencyMs); } - public async load(): Promise { - try { - // @ts-ignore - const rpcJSONResponse: any = await this.connection._rpcRequest( - 'getRecentPrioritizationFees', - [this.addresses] - ); + private async loadForSolana(): Promise { + const samples = await fetchSolanaPriorityFee( + this.connection!, + this.lookbackDistance, + this.addresses + ); + this.latestPriorityFee = samples[0].prioritizationFee; + this.lastSlotSeen = samples[0].slot; - const results: { slot: number; prioritizationFee: number }[] = - rpcJSONResponse?.result; + this.lastAvgStrategyResult = this.averageStrategy.calculate(samples); + this.lastMaxStrategyResult = this.maxStrategy.calculate(samples); + if (this.customStrategy) { + this.lastCustomStrategyResult = this.customStrategy.calculate(samples); + } + } - if (!results.length) return; + private async loadForHelius(): Promise { + const sample = await fetchHeliusPriorityFee( + this.heliusRpcUrl, + this.lookbackDistance, + this.addresses + ); + this.lastHeliusSample = sample?.result?.priorityFeeLevels ?? undefined; + } - // # Sort and filter results based on the slot lookback setting - const descResults = results.sort((a, b) => b.slot - a.slot); - const mostRecentResult = descResults[0]; - const cutoffSlot = mostRecentResult.slot - this.lookbackDistance; + public getHeliusPriorityFeeLevel( + level: HeliusPriorityLevel = HeliusPriorityLevel.MEDIUM + ): number { + if (this.lastHeliusSample === undefined) { + return 0; + } + return this.lastHeliusSample[level]; + } - const resultsToUse = descResults.filter( - (result) => result.slot >= cutoffSlot - ); + public getCustomStrategyResult(): number { + return this.lastCustomStrategyResult; + } - // # Handle results - this.latestPriorityFee = mostRecentResult.prioritizationFee; - this.lastSlotSeen = mostRecentResult.slot; + public getAvgStrategyResult(): number { + return this.lastAvgStrategyResult; + } - this.lastAvgStrategyResult = this.averageStrategy.calculate(resultsToUse); - this.lastMaxStrategyResult = this.maxStrategy.calculate(resultsToUse); - if (this.customStrategy) { - this.lastCustomStrategyResult = - this.customStrategy.calculate(resultsToUse); + public getMaxStrategyResult(): number { + return this.lastMaxStrategyResult; + } + + public async load(): Promise { + try { + if (this.priorityFeeMethod === PriorityFeeMethod.SOLANA) { + await this.loadForSolana(); + } else if (this.priorityFeeMethod === PriorityFeeMethod.HELIUS) { + await this.loadForHelius(); + } else { + throw new Error(`${this.priorityFeeMethod} load not implemented`); } } catch (err) { - // It's possible to get here with "TypeError: failed to fetch" + const e = err as Error; + console.error( + `Error loading priority fee ${this.priorityFeeMethod}: ${e.message}\n${ + e.stack ? e.stack : '' + }` + ); return; } } diff --git a/sdk/src/priorityFee/solanaPriorityFeeMethod.ts b/sdk/src/priorityFee/solanaPriorityFeeMethod.ts new file mode 100644 index 000000000..9dbed72f1 --- /dev/null +++ b/sdk/src/priorityFee/solanaPriorityFeeMethod.ts @@ -0,0 +1,28 @@ +import { Connection } from '@solana/web3.js'; + +export type SolanaPriorityFeeResponse = { + slot: number; + prioritizationFee: number; +}; + +export async function fetchSolanaPriorityFee( + connection: Connection, + lookbackDistance: number, + addresses: string[] +): Promise { + // @ts-ignore + const rpcJSONResponse: any = await connection._rpcRequest( + 'getRecentPrioritizationFees', + [addresses] + ); + + const results: SolanaPriorityFeeResponse[] = rpcJSONResponse?.result; + + if (!results.length) return; + + // Sort and filter results based on the slot lookback setting + const descResults = results.sort((a, b) => b.slot - a.slot); + const cutoffSlot = descResults[0].slot - lookbackDistance; + + return descResults.filter((result) => result.slot >= cutoffSlot); +} diff --git a/sdk/src/priorityFee/types.ts b/sdk/src/priorityFee/types.ts index 84c4a9c6b..3d9c19fb9 100644 --- a/sdk/src/priorityFee/types.ts +++ b/sdk/src/priorityFee/types.ts @@ -1,5 +1,33 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { SolanaPriorityFeeResponse } from './solanaPriorityFeeMethod'; +import { HeliusPriorityFeeResponse } from './heliusPriorityFeeMethod'; + export interface PriorityFeeStrategy { // calculate the priority fee for a given set of samples. // expect samples to be sorted in descending order (by slot) - calculate(samples: { slot: number; prioritizationFee: number }[]): number; + calculate( + samples: SolanaPriorityFeeResponse[] | HeliusPriorityFeeResponse + ): number; } + +export enum PriorityFeeMethod { + SOLANA = 'solana', + HELIUS = 'helius', +} + +export type PriorityFeeSubscriberConfig = { + /// rpc connection, optional if using priorityFeeMethod.HELIUS + connection?: Connection; + /// frequency to make RPC calls to update priority fee samples, in milliseconds + frequencyMs: number; + /// addresses you plan to write lock, used to determine priority fees + addresses: PublicKey[]; + /// custom strategy to calculate priority fees, defaults to AVERAGE + customStrategy?: PriorityFeeStrategy; + /// method for fetching priority fee samples + priorityFeeMethod?: PriorityFeeMethod; + /// lookback window to determine priority fees, in slots. + slotsToCheck?: number; + /// url for helius rpc, required if using priorityFeeMethod.HELIUS + heliusRpcUrl?: string; +}; diff --git a/sdk/tests/tx/priorityFeeStrategy.ts b/sdk/tests/tx/priorityFeeStrategy.ts index 1296e0a89..4e09d08a5 100644 --- a/sdk/tests/tx/priorityFeeStrategy.ts +++ b/sdk/tests/tx/priorityFeeStrategy.ts @@ -76,7 +76,7 @@ describe('PriorityFeeStrategy', () => { { slot: 1, prioritizationFee: 1000 }, ]; const maxOverSlots = maxOverSlotsStrategy.calculate(samples); - expect(maxOverSlots).to.equal(832); + expect(maxOverSlots).to.equal(1000); }); it('AverageOverSlotsStrategy should calculate the average prioritization fee over slots', () => { @@ -90,6 +90,6 @@ describe('PriorityFeeStrategy', () => { { slot: 1, prioritizationFee: 1000 }, ]; const averageOverSlots = averageOverSlotsStrategy.calculate(samples); - expect(averageOverSlots).to.equal(454.4); + expect(averageOverSlots).to.approximately(545.33333, 0.00001); }); });