Skip to content

Commit

Permalink
init: Solana Client Factory
Browse files Browse the repository at this point in the history
- Creates a Solana Client Factory that allows creation of a client using v1 or v2 of Solana Web3.js following a standard interface
- Deprecated existing Solana Class (leaving this exposed for now)
- Since transactions are sent on mainnet, did not write tests -- need to talk to John about testing these
  • Loading branch information
amilz committed Oct 31, 2024
1 parent 9b4a9b6 commit 2005675
Show file tree
Hide file tree
Showing 21 changed files with 4,895 additions and 476 deletions.
3,480 changes: 3,480 additions & 0 deletions packages/libs/sdk/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/libs/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"types": "./index.d.ts",
"sideEffects": false,
"dependencies": {
"@solana-program/compute-budget": "^0.5.4",
"@solana/web3.js": "^1.91",
"@solana/web3.js-v2": "npm:@solana/web3.js@^2.0.0-rc.3",
"cross-fetch": "^3.1.6",
"tslib": "^2.5.3",
"viem": "^2.13.7",
Expand Down
22 changes: 22 additions & 0 deletions packages/libs/sdk/spec/solana/factory/solana.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ClientVersion, SolanaClientFactory } from '../../../src';

describe('solana client factory', () => {
const endpointUrl = process.env['QUICKNODE_SOLANA_ENDPOINT_URL'] ||
'https://thisisnotasolanaendpoint.example.com';

it('should create a v1 client', () => {
const client = SolanaClientFactory.createClient(ClientVersion.V1, {
endpointUrl
});
expect(client).toBeInstanceOf(Object);
});

it('should create a v2 client', () => {
const client = SolanaClientFactory.createClient(ClientVersion.V2, {
endpointUrl
});
expect(client).toBeInstanceOf(Object);
});
});

// TODO: Add more tests for all functions for both versions
8 changes: 5 additions & 3 deletions packages/libs/sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import QuickNode from './client';
import Core from './core';
import Solana from './solana';
import { Solana, SolanaClientFactory } from './solana';
import * as viem from 'viem';
// eslint-disable-next-line @nx/enforce-module-boundaries
import * as solanaWeb3 from '@solana/web3.js';

export { Core, viem, Solana, solanaWeb3 };
export { Core, viem, Solana, solanaWeb3, SolanaClientFactory };

export * from './core/exportedTypes';
export * from './solana/types';
export * from './solana/v1';
export * from './solana/v2';
export * from './solana/shared';
export * from './lib/errors';

export default QuickNode;
1 change: 1 addition & 0 deletions packages/libs/sdk/src/solana/deprecated/v0/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './solana';
167 changes: 167 additions & 0 deletions packages/libs/sdk/src/solana/deprecated/v0/solana.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// eslint-disable-next-line @nx/enforce-module-boundaries
import {
ComputeBudgetProgram,
Connection,
TransactionInstruction,
PublicKey,
TransactionMessage,
VersionedTransaction,
} from '@solana/web3.js';
import {
type EstimatePriorityFeesParams,
type PriorityFeeRequestPayload,
type PriorityFeeResponseData,
type PriorityFeeLevels,
type SolanaClientArgs,
type SendSmartTransactionArgs,
type PrepareSmartTransactionArgs,
} from './types';

export class Solana {
readonly endpointUrl: string;
readonly connection: Connection;

constructor({ endpointUrl }: SolanaClientArgs) {
this.endpointUrl = endpointUrl;
this.connection = new Connection(endpointUrl);
console.warn(
'Warning: The "Solana" class is deprecated and will be removed in future versions. ' +
'Please use SolanaClientFactory.createClient method and specify a v1 or v2 explicitly. ' +
'See migration guide at: <TODO>'
);

}

/**
* Sends a transaction with a dynamically generated priority fee based on the current network conditions and compute units needed by the transaction.
*/
async sendSmartTransaction(args: SendSmartTransactionArgs) {
const {
transaction,
keyPair,
feeLevel = 'medium',
sendTransactionOptions = {},
} = args;
const smartTransaction = await this.prepareSmartTransaction({
transaction,
payerPublicKey: keyPair.publicKey,
feeLevel,
});
smartTransaction.sign(keyPair);

const hash = await this.connection.sendRawTransaction(
transaction.serialize(),
{ skipPreflight: true, ...sendTransactionOptions }
);

return hash;
}

/**
* Prepares a transaction to be sent with a dynamically generated priority fee based
* on the current network conditions. It adds a `setComputeUnitPrice` instruction to the transaction
* and simulates the transaction to estimate the number of compute units it will consume.
* The returned transaction still needs to be signed and sent to the network.
*/
async prepareSmartTransaction(args: PrepareSmartTransactionArgs) {
const { transaction, payerPublicKey, feeLevel = 'medium' } = args;

// Send simulation with placeholders so the value calculated is accurate
// placeholders kept low to avoid InsufficientFundsForFee error with the high cu budget limit
const simulationInstructions = [
ComputeBudgetProgram.setComputeUnitPrice({
microLamports: 1,
}),
ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }),
...transaction.instructions,
];

// eslint-disable-next-line prefer-const
let [units, computeUnitPriceInstruction, recentBlockhash] =
await Promise.all([
this.getSimulationUnits(
this.connection,
simulationInstructions,
payerPublicKey
),
this.createDynamicPriorityFeeInstruction(feeLevel),
this.connection.getLatestBlockhash(),
]);

transaction.add(computeUnitPriceInstruction);
if (units) {
units = Math.ceil(units * 1.05); // margin of error
transaction.add(ComputeBudgetProgram.setComputeUnitLimit({ units }));
}
transaction.recentBlockhash = recentBlockhash.blockhash;

return transaction;
}

// Get the priority fee averages based on fee data from the latest blocks
async fetchEstimatePriorityFees(
args: EstimatePriorityFeesParams = {}
): Promise<PriorityFeeResponseData> {
const payload: PriorityFeeRequestPayload = {
method: 'qn_estimatePriorityFees',
params: args,
id: 1,
jsonrpc: '2.0',
};

const response = await fetch(this.endpointUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});

if (!response.ok) {
if (response.status === 404) {
throw new Error(
`The RPC method qn_estimatePriorityFees was not found on your endpoint! Your endpoint likely does not have the Priority Fee API add-on installed. Please visit https://marketplace.quicknode.com/add-on/solana-priority-fee to install the Priority Fee API and use this method to send your transactions with priority fees calculated with real-time data.`
);
}
throw new Error('Failed to fetch priority fee estimates');
}

const data: PriorityFeeResponseData = await response.json();
return data;
}

private async createDynamicPriorityFeeInstruction(
feeType: PriorityFeeLevels = 'medium'
) {
const { result } = await this.fetchEstimatePriorityFees({});
const priorityFee = result.per_compute_unit[feeType];
const priorityFeeInstruction = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: priorityFee,
});
return priorityFeeInstruction;
}

private async getSimulationUnits(
connection: Connection,
instructions: TransactionInstruction[],
publicKey: PublicKey
): Promise<number | undefined> {
const testVersionedTxn = new VersionedTransaction(
new TransactionMessage({
instructions: instructions,
payerKey: publicKey,
recentBlockhash: PublicKey.default.toString(), // just a placeholder
}).compileToV0Message()
);

const simulation = await connection.simulateTransaction(testVersionedTxn, {
replaceRecentBlockhash: true,
sigVerify: false,
});
if (simulation.value.err) {
console.error('Simulation error:', simulation.value.err);
throw new Error(`Failed to simulate transaction ${simulation.value.err}`);
}
return simulation.value.unitsConsumed;
}
}
File renamed without changes.
16 changes: 16 additions & 0 deletions packages/libs/sdk/src/solana/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { SolanaV1 } from './v1';
import { SolanaV2 } from './v2';
import { BaseSolanaClientArgs, ClientVersion } from './shared';

export class SolanaClientFactory {
static createClient(version: ClientVersion, args: BaseSolanaClientArgs) {
switch (version) {
case 'v1':
return new SolanaV1(args);
case 'v2':
return new SolanaV2(args);
default:
throw new Error(`Unsupported version: ${version}`);
}
}
}
10 changes: 7 additions & 3 deletions packages/libs/sdk/src/solana/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export * from './types';
import { Solana } from './solana';
export * from './v1';
export * from './v2';
export * from './shared';
export * from './factory';
import { SolanaClientFactory } from './factory';
import { Solana } from './deprecated/v0';

export default Solana;
export { Solana, SolanaClientFactory };
7 changes: 7 additions & 0 deletions packages/libs/sdk/src/solana/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const DEFAULTS = {
PLACEHOLDER_COMPUTE_UNIT: 1_400_000,
PLACEHOLDER_PRIORITY_FEE: 1,
COMPUTE_MARGIN: 1.05,
FEE_LEVEL: 'medium' as const,
PRIORITY_FEE_PARAMS: {} as const,
} as const;
2 changes: 2 additions & 0 deletions packages/libs/sdk/src/solana/shared/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types';
export * from './constants';
101 changes: 101 additions & 0 deletions packages/libs/sdk/src/solana/shared/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
type PercentileRangeUnion =
| '0'
| '5'
| '10'
| '15'
| '20'
| '25'
| '30'
| '35'
| '40'
| '45'
| '50'
| '55'
| '60'
| '65'
| '70'
| '75'
| '80'
| '85'
| '90'
| '95'
| '100';

export type PriorityFeeLevels = 'low' | 'medium' | 'high' | 'extreme';

export interface PriorityFeeRequestPayload {
method: string;
params: {
last_n_blocks?: number;
account?: string;
};
id: number;
jsonrpc: string;
}

export interface PriorityFeeEstimates {
extreme: number;
high: number;
low: number;
medium: number;
percentiles: {
[key in PercentileRangeUnion]: number;
};
}

export interface PriorityFeeResponseData {
jsonrpc: string;
result: {
context: {
slot: number;
};
per_compute_unit: PriorityFeeEstimates;
per_transaction: PriorityFeeEstimates;
};
id: number;
}

export enum PriorityFeeApiVersion {
V1 = 1,
V2 = 2,
}

export interface EstimatePriorityFeesParams {
// The number of blocks to consider for the fee estimate
last_n_blocks?: number;
// The program account to use for fetching the local estimate (e.g., Jupiter: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4)
account?: string;
// API version
api_version?: PriorityFeeApiVersion;
}

export interface SolanaClientArgs {
endpointUrl: string;
}

export interface BaseSolanaClientArgs {
endpointUrl: string;
wssEndpointUrl?: string;
}

export enum ClientVersion {
V1 = 'v1',
V2 = 'v2'
}

export interface TransactionArgsBase {
feeLevel?: PriorityFeeLevels;
priorityFeeParams?: EstimatePriorityFeesParams;
computeUnitMargin?: number;
}

export interface PrepareTransactionResponseBase { }

export interface SolanaClient {
readonly endpointUrl: string;
fetchEstimatePriorityFees(args?: EstimatePriorityFeesParams): Promise<PriorityFeeResponseData>;
sendSmartTransaction(args: TransactionArgsBase): Promise<string>;
prepareSmartTransaction(args: TransactionArgsBase): Promise<PrepareTransactionResponseBase>;
}


Loading

0 comments on commit 2005675

Please sign in to comment.