From 639cc7665a0207d5f1b42029fae3ecb13b87e7d1 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 21 Dec 2025 12:32:18 -0800 Subject: [PATCH 01/12] feat: Integrate Kit compute-budget provisory pattern and remove custom packing --- packages/core/README.md | 18 +- packages/core/package.json | 7 +- .../src/builder/__tests__/builder-cu.test.ts | 107 ++++++++++ packages/core/src/builder/builder.ts | 112 +++++----- .../__tests__/compute-units.test.ts | 154 ++++++++++++++ packages/core/src/compute-budget/index.ts | 9 + packages/core/src/index.ts | 3 - packages/core/src/packing/index.ts | 195 ------------------ .../src/plans/__tests__/execute-plan.test.ts | 79 +++++++ packages/core/src/plans/execute-plan.ts | 24 ++- pnpm-lock.yaml | 12 ++ 11 files changed, 453 insertions(+), 267 deletions(-) create mode 100644 packages/core/src/builder/__tests__/builder-cu.test.ts create mode 100644 packages/core/src/compute-budget/__tests__/compute-units.test.ts delete mode 100644 packages/core/src/packing/index.ts create mode 100644 packages/core/src/plans/__tests__/execute-plan.test.ts diff --git a/packages/core/README.md b/packages/core/README.md index 9a41740..beea3fe 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -118,9 +118,6 @@ builder.addInstruction(instruction); // Multiple instructions builder.addInstructions([ix1, ix2, ix3]); - -// With auto-packing (returns overflow instructions) -const { builder: packed, overflow } = await builder.addInstructionsWithPacking(manyInstructions); ``` #### Building @@ -218,15 +215,28 @@ new TransactionBuilder({ computeUnits: 'auto' }); // Fixed limit new TransactionBuilder({ computeUnits: 300_000 }); -// Custom strategy +// Fixed strategy with custom units new TransactionBuilder({ computeUnits: { strategy: 'fixed', units: 400_000, }, }); + +// Simulate strategy - estimates CU via simulation before sending +// Uses Kit's provisory instruction pattern for accurate estimation +new TransactionBuilder({ + computeUnits: { + strategy: 'simulate', + }, +}); ``` +The `'simulate'` strategy uses Kit's `@solana-program/compute-budget` helpers to: +1. Add a provisory compute unit limit instruction during message building +2. Simulate the transaction to get accurate CU consumption +3. Update the instruction with the estimated value before signing and sending + ### Address Lookup Tables Address lookup tables automatically compress transactions for version 0 transactions: diff --git a/packages/core/package.json b/packages/core/package.json index 14dd8af..6c150e3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -60,7 +60,8 @@ "@solana/rpc-types": "*", "@solana/signers": "*", "@solana/transaction-messages": "*", - "@solana/transactions": "*" + "@solana/transactions": "*", + "@solana-program/compute-budget": "*" }, "peerDependenciesMeta": { "@solana/addresses": { @@ -95,6 +96,9 @@ }, "@solana/transactions": { "optional": false + }, + "@solana-program/compute-budget": { + "optional": false } }, "dependencies": {}, @@ -113,6 +117,7 @@ "@solana/signers": "*", "@solana/transaction-messages": "*", "@solana/transactions": "*", + "@solana-program/compute-budget": "*", "@types/node": "^24", "tsup": "^8.5.0", "typescript": "^5.8.3", diff --git a/packages/core/src/builder/__tests__/builder-cu.test.ts b/packages/core/src/builder/__tests__/builder-cu.test.ts new file mode 100644 index 0000000..5150e8a --- /dev/null +++ b/packages/core/src/builder/__tests__/builder-cu.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for TransactionBuilder compute unit estimation. + * + * Note: Full integration tests require mocking RPC connections. + * These tests verify the configuration and basic behavior. + */ + +import { describe, it, expect } from 'vitest'; +import { TransactionBuilder, type TransactionBuilderConfig } from '../builder.js'; + +describe('TransactionBuilder CU configuration', () => { + describe('computeUnits config', () => { + it('should accept "auto" as computeUnits value', () => { + const builder = new TransactionBuilder({ + computeUnits: 'auto', + }); + expect(builder).toBeDefined(); + }); + + it('should accept number as computeUnits value', () => { + const builder = new TransactionBuilder({ + computeUnits: 300_000, + }); + expect(builder).toBeDefined(); + }); + + it('should accept fixed strategy config', () => { + const builder = new TransactionBuilder({ + computeUnits: { + strategy: 'fixed', + units: 400_000, + }, + }); + expect(builder).toBeDefined(); + }); + + it('should accept simulate strategy config', () => { + const builder = new TransactionBuilder({ + computeUnits: { + strategy: 'simulate', + }, + }); + expect(builder).toBeDefined(); + }); + + it('should accept simulate strategy with buffer', () => { + const builder = new TransactionBuilder({ + computeUnits: { + strategy: 'simulate', + buffer: 1.2, + }, + }); + expect(builder).toBeDefined(); + }); + }); +}); + +describe('TransactionBuilder CU simulation strategy', () => { + it('should document the simulate strategy behavior', () => { + // This test documents the expected behavior of the simulate strategy: + // 1. build() adds a provisory CU instruction via fillProvisorySetComputeUnitLimitInstruction + // 2. execute() estimates and updates the CU via estimateAndUpdateProvisoryComputeUnitLimitFactory + // 3. export() also estimates and updates the CU before signing + const simulateStrategyBehavior = { + build: 'Adds provisory CU instruction using fillProvisorySetComputeUnitLimitInstruction', + execute: 'Estimates via simulation and updates provisory instruction before signing', + export: 'Estimates via simulation and updates provisory instruction before signing', + }; + + expect(simulateStrategyBehavior.build).toContain('provisory'); + expect(simulateStrategyBehavior.execute).toContain('simulation'); + expect(simulateStrategyBehavior.export).toContain('simulation'); + }); + + it('should use Kit compute-budget helpers', () => { + // Verify the compute-budget helpers are available + // The actual integration is tested via the re-exports + const helpers = { + fillProvisory: 'fillProvisorySetComputeUnitLimitInstruction', + estimate: 'estimateComputeUnitLimitFactory', + estimateAndUpdate: 'estimateAndUpdateProvisoryComputeUnitLimitFactory', + }; + + expect(helpers.fillProvisory).toBeDefined(); + expect(helpers.estimate).toBeDefined(); + expect(helpers.estimateAndUpdate).toBeDefined(); + }); +}); + +describe('TransactionBuilderConfig types', () => { + it('should accept all valid computeUnits configurations', () => { + // Type-level tests - verify the config types are correct + const configs: TransactionBuilderConfig[] = [ + { computeUnits: 'auto' }, + { computeUnits: 200_000 }, + { computeUnits: { strategy: 'auto' } }, + { computeUnits: { strategy: 'fixed', units: 300_000 } }, + { computeUnits: { strategy: 'simulate' } }, + { computeUnits: { strategy: 'simulate', buffer: 1.15 } }, + ]; + + configs.forEach(config => { + const builder = new TransactionBuilder(config); + expect(builder).toBeDefined(); + }); + }); +}); diff --git a/packages/core/src/builder/builder.ts b/packages/core/src/builder/builder.ts index 8234498..63cafb7 100644 --- a/packages/core/src/builder/builder.ts +++ b/packages/core/src/builder/builder.ts @@ -80,7 +80,6 @@ import { getBase58Decoder } from '@solana/codecs-strings'; import { SolanaError, SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING } from '@solana/errors'; import type { BuilderState, RequiredState, LifetimeConstraint, ExecuteConfig } from '../types.js'; import { validateTransaction, validateTransactionSize } from '../validation/index.js'; -import { packInstructions } from '../packing/index.js'; // Import new modules import { @@ -92,6 +91,11 @@ import { PRIORITY_FEE_LEVELS, type PriorityFeeLevel, } from '../compute-budget/index.js'; +import { + fillProvisorySetComputeUnitLimitInstruction, + estimateComputeUnitLimitFactory, + estimateAndUpdateProvisoryComputeUnitLimitFactory, +} from '@solana-program/compute-budget'; import { fetchNonceValue, type DurableNonceConfig } from '../nonce/index.js'; import { type AddressesByLookupTableAddress, @@ -450,7 +454,10 @@ export class TransactionBuilder { // 1. Compute unit limit const computeUnits = await this.resolveComputeUnits(); - if (computeUnits !== null) { + if (computeUnits === TransactionBuilder.PROVISORY_CU_SENTINEL) { + // Use provisory instruction - will be estimated via simulation during execute() + message = fillProvisorySetComputeUnitLimitInstruction(message); + } else if (computeUnits !== null) { message = appendTransactionMessageInstruction( createSetComputeUnitLimitInstruction(computeUnits), message @@ -519,9 +526,28 @@ export class TransactionBuilder { return PRIORITY_FEE_LEVELS.medium; } + /** + * Sentinel value indicating provisory CU instruction should be used. + * The actual CU limit will be estimated via simulation during execute(). + */ + private static readonly PROVISORY_CU_SENTINEL = -1; + + /** + * Check if the current compute units config uses the simulate strategy. + */ + private isSimulateCUStrategy(): boolean { + const { computeUnits } = this.config; + if (typeof computeUnits === 'object' && computeUnits.strategy === 'simulate') { + return true; + } + return false; + } + /** * Resolve compute units based on configuration. * Returns null if no compute unit instruction should be added. + * Returns PROVISORY_CU_SENTINEL if a provisory instruction should be added + * (will be updated via simulation during execute()). */ private async resolveComputeUnits(): Promise { const { computeUnits } = this.config; @@ -545,12 +571,10 @@ export class TransactionBuilder { return computeUnits.units ?? 200_000; } - // Simulate strategy - would need simulation first - // For now, return a sensible default + // Simulate strategy - use provisory pattern + // The actual CU limit will be estimated via simulation during execute() if (computeUnits.strategy === 'simulate') { - // Simulation-based compute unit estimation would be done during execute() - // For build(), return a safe default - return computeUnits.units ?? 200_000; + return TransactionBuilder.PROVISORY_CU_SENTINEL; } return null; @@ -634,7 +658,15 @@ export class TransactionBuilder { } // Build message using the unified build method - const message = await (this as any).build(); + let message = await (this as any).build(); + + // If using simulate strategy, estimate and update the provisory CU instruction + if (this.isSimulateCUStrategy()) { + const rpcWithSim = this.config.rpc as unknown as Rpc; + const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: rpcWithSim }); + const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); + message = await estimateAndSetCULimit(message); + } // Sign transaction const signedTransaction: any = await signTransactionMessageWithSigners(message); @@ -742,7 +774,19 @@ export class TransactionBuilder { } // Build message using the unified build method - const message = await (builderToUse as any).build(); + let message = await (builderToUse as any).build(); + + // If using simulate strategy, estimate and update the provisory CU instruction + if (builderToUse.isSimulateCUStrategy()) { + const rpcWithSim = this.config.rpc as unknown as Rpc; + const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: rpcWithSim }); + const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); + message = await estimateAndSetCULimit(message); + + if (this.config.logLevel !== 'silent') { + console.log(`[Pipeit] Estimated compute units via simulation`); + } + } // Sign transaction const signedTransaction: any = await signTransactionMessageWithSigners(message); @@ -986,56 +1030,6 @@ export class TransactionBuilder { }; } - /** - * Add instructions with auto-packing. Returns overflow instructions that did not fit. - * Useful for batching large instruction sets across multiple transactions. - * - * @param instructions - Array of instructions to add - * @returns Object with the new builder (with packed instructions) and overflow array - * - * @example - * ```ts - * const { builder: packed, overflow } = await baseBuilder - * .addInstructionsWithPacking(manyInstructions); - * - * // Execute the first batch - * await packed.execute({ rpcSubscriptions }); - * - * // Handle overflow in another transaction - * if (overflow.length > 0) { - * const { builder: packed2 } = await baseBuilder - * .addInstructionsWithPacking(overflow); - * await packed2.execute({ rpcSubscriptions }); - * } - * ``` - */ - async addInstructionsWithPacking( - instructions: readonly Instruction[] - ): Promise<{ builder: TransactionBuilder; overflow: Instruction[] }> { - if (!this.feePayer) { - throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); - } - - if (!this.config.rpc) { - throw new Error('RPC required for packing. Pass rpc in constructor.'); - } - - // Build a base message to calculate sizes against - const baseMessage = await (this as any).build(); - - // Pack instructions - const result = packInstructions(baseMessage, [...instructions]); - - // Create a new builder with the packed instructions - const builder = this.clone(); - builder.instructions = [...this.instructions, ...result.packed]; - - return { - builder, - overflow: result.overflow, - }; - } - /** * Execute transaction with retry logic. */ diff --git a/packages/core/src/compute-budget/__tests__/compute-units.test.ts b/packages/core/src/compute-budget/__tests__/compute-units.test.ts new file mode 100644 index 0000000..67b94bd --- /dev/null +++ b/packages/core/src/compute-budget/__tests__/compute-units.test.ts @@ -0,0 +1,154 @@ +/** + * Tests for compute unit estimation and configuration. + */ + +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_COMPUTE_UNIT_LIMIT, + MAX_COMPUTE_UNIT_LIMIT, + DEFAULT_COMPUTE_BUFFER, + createSetComputeUnitLimitInstruction, + estimateComputeUnits, + shouldAddComputeUnitInstruction, + getComputeUnitLimit, +} from '../compute-units.js'; +import { COMPUTE_BUDGET_PROGRAM } from '../priority-fees.js'; + +describe('createSetComputeUnitLimitInstruction', () => { + it('should create instruction with specified units', () => { + const instruction = createSetComputeUnitLimitInstruction(300_000); + + expect(instruction.programAddress).toBe(COMPUTE_BUDGET_PROGRAM); + expect(instruction.accounts).toHaveLength(0); + expect(instruction.data[0]).toBe(2); // SetComputeUnitLimit discriminator + }); + + it('should clamp units to MAX_COMPUTE_UNIT_LIMIT', () => { + const instruction = createSetComputeUnitLimitInstruction(2_000_000); + + // Read the u32 from bytes 1-4 + const dataView = new DataView(instruction.data.buffer); + const units = dataView.getUint32(1, true); + + expect(units).toBe(MAX_COMPUTE_UNIT_LIMIT); + }); + + it('should encode units as little-endian u32', () => { + const instruction = createSetComputeUnitLimitInstruction(400_000); + + const dataView = new DataView(instruction.data.buffer); + const units = dataView.getUint32(1, true); + + expect(units).toBe(400_000); + }); +}); + +describe('estimateComputeUnits', () => { + it('should return fixed units when strategy is fixed', () => { + const estimate = estimateComputeUnits(undefined, { + strategy: 'fixed', + units: 250_000, + }); + + expect(estimate.units).toBe(250_000); + expect(estimate.buffer).toBe(1); + }); + + it('should return default when strategy is fixed without units', () => { + const estimate = estimateComputeUnits(undefined, { + strategy: 'fixed', + }); + + expect(estimate.units).toBe(DEFAULT_COMPUTE_UNIT_LIMIT); + }); + + it('should return default when strategy is auto', () => { + const estimate = estimateComputeUnits(undefined, { + strategy: 'auto', + }); + + expect(estimate.units).toBe(DEFAULT_COMPUTE_UNIT_LIMIT); + expect(estimate.buffer).toBe(1); + }); + + it('should apply buffer to simulated units for simulate strategy', () => { + const simulatedUnits = 150_000n; + const buffer = 1.2; + + const estimate = estimateComputeUnits(simulatedUnits, { + strategy: 'simulate', + buffer, + }); + + expect(estimate.units).toBe(Math.ceil(150_000 * 1.2)); + expect(estimate.simulatedUnits).toBe(simulatedUnits); + expect(estimate.buffer).toBe(buffer); + }); + + it('should use default buffer for simulate strategy', () => { + const simulatedUnits = 100_000n; + + const estimate = estimateComputeUnits(simulatedUnits, { + strategy: 'simulate', + }); + + expect(estimate.units).toBe(Math.ceil(100_000 * DEFAULT_COMPUTE_BUFFER)); + expect(estimate.buffer).toBe(DEFAULT_COMPUTE_BUFFER); + }); + + it('should return default when simulate strategy but no simulation data', () => { + const estimate = estimateComputeUnits(undefined, { + strategy: 'simulate', + }); + + expect(estimate.units).toBe(DEFAULT_COMPUTE_UNIT_LIMIT); + }); + + it('should clamp simulated units to MAX_COMPUTE_UNIT_LIMIT', () => { + const simulatedUnits = 1_500_000n; + + const estimate = estimateComputeUnits(simulatedUnits, { + strategy: 'simulate', + buffer: 1.1, + }); + + expect(estimate.units).toBe(MAX_COMPUTE_UNIT_LIMIT); + }); +}); + +describe('shouldAddComputeUnitInstruction', () => { + it('should return false for auto strategy', () => { + expect(shouldAddComputeUnitInstruction({ strategy: 'auto' })).toBe(false); + }); + + it('should return true for fixed strategy', () => { + expect(shouldAddComputeUnitInstruction({ strategy: 'fixed' })).toBe(true); + }); + + it('should return true for simulate strategy', () => { + expect(shouldAddComputeUnitInstruction({ strategy: 'simulate' })).toBe(true); + }); +}); + +describe('getComputeUnitLimit', () => { + it('should return configured units for fixed strategy', () => { + const limit = getComputeUnitLimit({ strategy: 'fixed', units: 500_000 }); + expect(limit).toBe(500_000); + }); + + it('should include simulated units with buffer', () => { + const limit = getComputeUnitLimit( + { strategy: 'simulate', buffer: 1.1 }, + 200_000n, + ); + expect(limit).toBe(Math.ceil(200_000 * 1.1)); + }); +}); + +describe('constants', () => { + it('should have correct default values', () => { + expect(DEFAULT_COMPUTE_UNIT_LIMIT).toBe(200_000); + expect(MAX_COMPUTE_UNIT_LIMIT).toBe(1_400_000); + expect(DEFAULT_COMPUTE_BUFFER).toBe(1.1); + }); +}); diff --git a/packages/core/src/compute-budget/index.ts b/packages/core/src/compute-budget/index.ts index d4433cc..952ce56 100644 --- a/packages/core/src/compute-budget/index.ts +++ b/packages/core/src/compute-budget/index.ts @@ -36,3 +36,12 @@ export { shouldAddComputeUnitInstruction, getComputeUnitLimit, } from './compute-units.js'; + +// Re-export Kit's compute-budget helpers for convenience +export { + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, + estimateComputeUnitLimitFactory, + fillProvisorySetComputeUnitLimitInstruction, + estimateAndUpdateProvisoryComputeUnitLimitFactory, +} from '@solana-program/compute-budget'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dced8be..f9b9432 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,9 +73,6 @@ export * from './helpers.js'; // Signers - re-exports from Kit export * from './signers/index.js'; -// Packing - message packing utilities -export * from './packing/index.js'; - // Compute Budget - priority fees and compute units export * from './compute-budget/index.js'; diff --git a/packages/core/src/packing/index.ts b/packages/core/src/packing/index.ts deleted file mode 100644 index 14a3229..0000000 --- a/packages/core/src/packing/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Message packing utilities for fitting instructions within transaction size limits. - * - * These utilities help you pack as many instructions as possible into a single - * transaction, and identify which instructions need to be sent in follow-up - * transactions. - * - * @packageDocumentation - */ - -import type { Instruction } from '@solana/instructions'; -import type { TransactionMessage, TransactionMessageWithFeePayer } from '@solana/transaction-messages'; -import { appendTransactionMessageInstruction } from '@solana/transaction-messages'; -import { getTransactionMessageSize, TRANSACTION_SIZE_LIMIT } from '@solana/transactions'; - -/** - * A transaction message that is ready for size calculation. - * Must have a fee payer set. - */ -export type SizeableTransactionMessage = TransactionMessage & TransactionMessageWithFeePayer; - -/** - * Result of packing instructions into a transaction message. - */ -export interface PackResult { - /** Instructions that fit in the message */ - packed: Instruction[]; - /** Instructions that did not fit (overflow) */ - overflow: Instruction[]; - /** The message with packed instructions */ - message: T; - /** Size information */ - sizeInfo: { - size: number; - limit: number; - remaining: number; - }; -} - -/** - * Pack as many instructions as possible into a transaction message. - * - * This function iterates through the provided instructions and adds them - * to the message until no more can fit within the transaction size limit. - * It returns both the packed instructions and any overflow that didn't fit. - * - * @param baseMessage - The transaction message to pack instructions into (must have fee payer set) - * @param instructions - Array of instructions to pack - * @param options - Optional configuration - * @returns PackResult with packed/overflow instructions and updated message - * - * @example - * ```ts - * const result = packInstructions(baseMessage, [ix1, ix2, ix3, ix4, ix5]); - * - * // First transaction with packed instructions - * const tx1 = result.message; - * - * // Send overflow in next transaction - * if (result.overflow.length > 0) { - * const result2 = packInstructions(newBaseMessage, result.overflow); - * const tx2 = result2.message; - * } - * ``` - */ -export function packInstructions( - baseMessage: T, - instructions: Instruction[], - options?: { - /** Reserve bytes for future instructions (default: 0) */ - reserveBytes?: number; - }, -): PackResult { - const reserveBytes = options?.reserveBytes ?? 0; - const effectiveLimit = TRANSACTION_SIZE_LIMIT - reserveBytes; - - const packed: Instruction[] = []; - let message = baseMessage as SizeableTransactionMessage; - - for (const instruction of instructions) { - const testMessage = appendTransactionMessageInstruction(instruction, message); - const testSize = getTransactionMessageSize(testMessage as SizeableTransactionMessage); - - if (testSize <= effectiveLimit) { - packed.push(instruction); - message = testMessage as SizeableTransactionMessage; - } else { - // This instruction doesn't fit, stop here - break; - } - } - - const overflow = instructions.slice(packed.length); - const size = getTransactionMessageSize(message); - - return { - packed, - overflow, - message: message as T, - sizeInfo: { - size, - limit: TRANSACTION_SIZE_LIMIT, - remaining: TRANSACTION_SIZE_LIMIT - size, - }, - }; -} - -/** - * Check if an instruction can fit in a transaction message. - * - * @param message - The transaction message to check against (must have fee payer set) - * @param instruction - The instruction to check - * @returns true if the instruction can fit, false otherwise - * - * @example - * ```ts - * if (canFitInstruction(message, newInstruction)) { - * message = appendTransactionMessageInstruction(newInstruction, message); - * } else { - * // Need to create a new transaction - * } - * ``` - */ -export function canFitInstruction(message: SizeableTransactionMessage, instruction: Instruction): boolean { - const testMessage = appendTransactionMessageInstruction(instruction, message); - return getTransactionMessageSize(testMessage as SizeableTransactionMessage) <= TRANSACTION_SIZE_LIMIT; -} - -/** - * Get remaining bytes available in a transaction message. - * - * @param message - The transaction message to check (must have fee payer set) - * @returns Number of bytes remaining before hitting the size limit - * - * @example - * ```ts - * const remaining = getRemainingBytes(message); - * console.log(`Can add approximately ${remaining} more bytes`); - * ``` - */ -export function getRemainingBytes(message: SizeableTransactionMessage): number { - return TRANSACTION_SIZE_LIMIT - getTransactionMessageSize(message); -} - -/** - * Split an array of instructions into multiple chunks, each fitting within - * the transaction size limit when added to a base message. - * - * This is useful for batching large sets of instructions across multiple - * transactions. - * - * @param createBaseMessage - Factory function to create fresh base messages (must have fee payer set) - * @param instructions - All instructions to split - * @returns Array of instruction arrays, each fitting in one transaction - * - * @example - * ```ts - * const chunks = splitInstructionsIntoChunks( - * () => createBaseMessage(), - * manyInstructions - * ); - * - * for (const chunk of chunks) { - * const message = createBaseMessage(); - * for (const ix of chunk) { - * message = appendTransactionMessageInstruction(ix, message); - * } - * await sendTransaction(message); - * } - * ``` - */ -export function splitInstructionsIntoChunks( - createBaseMessage: () => T, - instructions: Instruction[], -): Instruction[][] { - const chunks: Instruction[][] = []; - let remaining = [...instructions]; - - while (remaining.length > 0) { - const baseMessage = createBaseMessage(); - const result = packInstructions(baseMessage, remaining); - - if (result.packed.length === 0) { - // Single instruction is too large to fit - throw new Error( - `Instruction at index ${instructions.length - remaining.length} is too large to fit in a transaction`, - ); - } - - chunks.push(result.packed); - remaining = result.overflow; - } - - return chunks; -} diff --git a/packages/core/src/plans/__tests__/execute-plan.test.ts b/packages/core/src/plans/__tests__/execute-plan.test.ts new file mode 100644 index 0000000..342041c --- /dev/null +++ b/packages/core/src/plans/__tests__/execute-plan.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for executePlan function. + * + * Note: Full integration tests require mocking RPC connections. + * These tests verify the exports and basic structure. + */ + +import { describe, it, expect } from 'vitest'; +import { executePlan, type ExecutePlanConfig } from '../execute-plan.js'; +import { + sequentialInstructionPlan, + parallelInstructionPlan, + createTransactionPlanner, + createTransactionPlanExecutor, +} from '../index.js'; + +describe('executePlan exports', () => { + it('should export executePlan function', () => { + expect(typeof executePlan).toBe('function'); + }); + + it('should export ExecutePlanConfig type (verifiable via usage)', () => { + // Type-level test - if this compiles, the type is exported correctly + const _config: Partial = { + commitment: 'confirmed', + }; + expect(_config.commitment).toBe('confirmed'); + }); +}); + +describe('Kit re-exports', () => { + it('should re-export sequentialInstructionPlan', () => { + expect(typeof sequentialInstructionPlan).toBe('function'); + }); + + it('should re-export parallelInstructionPlan', () => { + expect(typeof parallelInstructionPlan).toBe('function'); + }); + + it('should re-export createTransactionPlanner', () => { + expect(typeof createTransactionPlanner).toBe('function'); + }); + + it('should re-export createTransactionPlanExecutor', () => { + expect(typeof createTransactionPlanExecutor).toBe('function'); + }); +}); + +describe('ExecutePlanConfig', () => { + it('should require SimulateTransactionApi for CU estimation', () => { + // This is a compile-time check - the RPC type now includes SimulateTransactionApi + // The test documents that CU estimation via simulation is integrated + const configDescription = ` + ExecutePlanConfig now requires: + - GetEpochInfoApi + - GetSignatureStatusesApi + - SendTransactionApi + - GetLatestBlockhashApi + - SimulateTransactionApi (for CU estimation) + `; + expect(configDescription).toContain('SimulateTransactionApi'); + }); +}); + +describe('CU estimation integration', () => { + it('should document the provisory CU pattern', () => { + // This test documents the CU estimation pattern used in executePlan: + // 1. fillProvisorySetComputeUnitLimitInstruction adds a placeholder in planner + // 2. estimateAndUpdateProvisoryComputeUnitLimitFactory updates it in executor + const cuPattern = { + planner: 'fillProvisorySetComputeUnitLimitInstruction', + executor: 'estimateAndUpdateProvisoryComputeUnitLimitFactory', + source: '@solana-program/compute-budget', + }; + + expect(cuPattern.planner).toBe('fillProvisorySetComputeUnitLimitInstruction'); + expect(cuPattern.executor).toBe('estimateAndUpdateProvisoryComputeUnitLimitFactory'); + }); +}); diff --git a/packages/core/src/plans/execute-plan.ts b/packages/core/src/plans/execute-plan.ts index 97b3fe8..ba93ad2 100644 --- a/packages/core/src/plans/execute-plan.ts +++ b/packages/core/src/plans/execute-plan.ts @@ -11,6 +11,7 @@ import type { GetEpochInfoApi, GetSignatureStatusesApi, SendTransactionApi, + SimulateTransactionApi, } from '@solana/rpc'; import type { RpcSubscriptions, SignatureNotificationsApi, SlotNotificationsApi } from '@solana/rpc-subscriptions'; import { @@ -27,6 +28,11 @@ import { signTransactionMessageWithSigners, sendAndConfirmTransactionFactory, } from '@solana/kit'; +import { + fillProvisorySetComputeUnitLimitInstruction, + estimateComputeUnitLimitFactory, + estimateAndUpdateProvisoryComputeUnitLimitFactory, +} from '@solana-program/compute-budget'; /** * Configuration for executing an instruction plan. @@ -35,7 +41,7 @@ export interface ExecutePlanConfig { /** * RPC client. */ - rpc: Rpc; + rpc: Rpc; /** * RPC subscriptions client. @@ -113,17 +119,18 @@ export interface ExecutePlanConfig { export async function executePlan(plan: InstructionPlan, config: ExecutePlanConfig): Promise { const { rpc, rpcSubscriptions, signer, commitment = 'confirmed', abortSignal } = config; - // Create transaction planner + // Create transaction planner with provisory CU instruction const planner = createTransactionPlanner({ createTransactionMessage: async () => { // Fetch latest blockhash const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); - // Create transaction message with fee payer and blockhash + // Create transaction message with fee payer, blockhash, and provisory CU instruction return pipe( createTransactionMessage({ version: 0 }), tx => setTransactionMessageFeePayer(signer.address, tx), tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + tx => fillProvisorySetComputeUnitLimitInstruction(tx), ); }, }); @@ -134,11 +141,18 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf // Create send and confirm factory const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); - // Create transaction executor + // Create CU estimation helpers + const estimateCULimit = estimateComputeUnitLimitFactory({ rpc }); + const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); + + // Create transaction executor with CU estimation const executor = createTransactionPlanExecutor({ executeTransactionMessage: async message => { + // Estimate and update the provisory CU instruction with actual value + const estimatedMessage = await estimateAndSetCULimit(message); + // Sign the transaction - const signedTransaction = await signTransactionMessageWithSigners(message); + const signedTransaction = await signTransactionMessageWithSigners(estimatedMessage); // Send and confirm - cast to expected type since we know it has blockhash lifetime await sendAndConfirm(signedTransaction as Parameters[0], { commitment }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b552a7..517cfc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: packages/core: devDependencies: + '@solana-program/compute-budget': + specifier: '*' + version: 0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana/addresses': specifier: '*' version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -1691,6 +1694,11 @@ packages: '@solana-mobile/wallet-standard-mobile@0.4.3': resolution: {integrity: sha512-LLMQs/KgRZpftIhwOLCM2VZLMdA2vIghJjKsYUIiy1FBJS9GEkGDLJdbujb92lfAdmYwbyTuolIRik7JMPH3Kg==} + '@solana-program/compute-budget@0.11.0': + resolution: {integrity: sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==} + peerDependencies: + '@solana/kit': ^5.0 + '@solana-program/system@0.9.1': resolution: {integrity: sha512-2N30CgYJw0qX8jKU8vW808yLmx5oRoDSM+FC6tqhsLQiph7agK9eRXJlnrq6OUfTAZd5yCYQHQvGtx0S8I9SAA==} peerDependencies: @@ -6726,6 +6734,10 @@ snapshots: - react-native - typescript + '@solana-program/compute-budget@0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + dependencies: + '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/system@0.9.1(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) From cf76fe3b9206b4dc4be72faecddbf99a58a2a6c8 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 23 Dec 2025 17:58:50 -0800 Subject: [PATCH 02/12] chore: update deps --- examples/next-js/package.json | 2 +- packages/fastlane/Cargo.lock | 2 +- packages/fastlane/index.d.ts | 1 + pnpm-lock.yaml | 10 +++++----- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/next-js/package.json b/examples/next-js/package.json index 18f563e..9420a5f 100644 --- a/examples/next-js/package.json +++ b/examples/next-js/package.json @@ -13,7 +13,7 @@ "@phosphor-icons/react": "^2.1.10", "@pipeit/actions": "workspace:*", "@pipeit/core": "^0.2.5", - "@pipeit/fastlane": "^0.1.2", + "@pipeit/fastlane": "^0.1.3", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/fastlane/Cargo.lock b/packages/fastlane/Cargo.lock index ab4780a..78dab4f 100644 --- a/packages/fastlane/Cargo.lock +++ b/packages/fastlane/Cargo.lock @@ -2058,7 +2058,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pipeit-fastlane" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "dashmap 6.1.0", diff --git a/packages/fastlane/index.d.ts b/packages/fastlane/index.d.ts index 2a8601a..dcb7f83 100644 --- a/packages/fastlane/index.d.ts +++ b/packages/fastlane/index.d.ts @@ -85,6 +85,7 @@ export declare class TpuClient { /** * Sends a serialized transaction to TPU endpoints (single attempt). * + * Uses slot-aware leader selection when available, falling back to fanout. * Returns detailed per-leader results including retry statistics. * For higher landing rates, use `send_until_confirmed` instead. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 517cfc3..4003588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^0.2.5 version: 0.2.5(59e1c826774811eacd0a7dd223ad1500) '@pipeit/fastlane': - specifier: ^0.1.2 - version: 0.1.2 + specifier: ^0.1.3 + version: 0.1.3 '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1144,8 +1144,8 @@ packages: '@solana/transaction-messages': '*' '@solana/transactions': '*' - '@pipeit/fastlane@0.1.2': - resolution: {integrity: sha512-c4WJuDmfazLBHVHHqvfeZrmIiPBTWtldT/hAzZnZQ/8qqWx8hz8Jm9cJ2I9cXemtwryn9z1SIil0m45cV6azjA==} + '@pipeit/fastlane@0.1.3': + resolution: {integrity: sha512-jg4Ydzu0NA9RSJivEVV/jUdC9MNj7ebQ6od2/ouTXu2JKGEju1K2TBh+5JKbzPhHcYLSzH30b1v1gL+CIwj1aA==} engines: {node: '>= 18'} '@pkgjs/parseargs@0.11.0': @@ -6207,7 +6207,7 @@ snapshots: '@solana/transaction-messages': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/transactions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@pipeit/fastlane@0.1.2': {} + '@pipeit/fastlane@0.1.3': {} '@pkgjs/parseargs@0.11.0': optional: true From 60d32b941e2daa6fe353872df534598746fd68f4 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 23 Dec 2025 18:35:05 -0800 Subject: [PATCH 03/12] feat: Add ALT compression support to executePlan --- packages/core/README.md | 35 +++++ .../src/plans/__tests__/execute-plan.test.ts | 57 +++++++ packages/core/src/plans/execute-plan.ts | 142 +++++++++++++++++- 3 files changed, 226 insertions(+), 8 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index beea3fe..998fdbb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -64,6 +64,41 @@ const plan = sequentialInstructionPlan([ix1, ix2, ix3, ix4, ix5]); const result = await executePlan(plan, { rpc, rpcSubscriptions, signer }); ``` +#### With Address Lookup Tables + +`executePlan` supports address lookup table (ALT) compression for reducing transaction size: + +```typescript +import { sequentialInstructionPlan, executePlan } from '@pipeit/core'; +import { address } from '@solana/addresses'; + +const plan = sequentialInstructionPlan([swapInstruction, transferInstruction]); + +// Option 1: Provide ALT addresses (will be fetched automatically) +// Note: RPC must include GetAccountInfoApi when using lookupTableAddresses +const result = await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + lookupTableAddresses: [ + address('ALT1111111111111111111111111111111111111111'), + address('ALT2222222222222222222222222222222222222222'), + ], +}); + +// Option 2: Provide pre-fetched lookup table data (no additional RPC requirements) +const result = await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + addressesByLookupTable: { + [altAddress]: [addr1, addr2, addr3], + }, +}); +``` + +> **Note:** For single-transaction workflows, `TransactionBuilder.execute()` also supports ALTs and provides additional features like priority fees and auto-retry. Use `executePlan` when you need Kit's transaction planner to automatically batch instructions across multiple transactions. + ## TransactionBuilder API ### Configuration diff --git a/packages/core/src/plans/__tests__/execute-plan.test.ts b/packages/core/src/plans/__tests__/execute-plan.test.ts index 342041c..bf083db 100644 --- a/packages/core/src/plans/__tests__/execute-plan.test.ts +++ b/packages/core/src/plans/__tests__/execute-plan.test.ts @@ -6,6 +6,7 @@ */ import { describe, it, expect } from 'vitest'; +import { address } from '@solana/addresses'; import { executePlan, type ExecutePlanConfig } from '../execute-plan.js'; import { sequentialInstructionPlan, @@ -13,6 +14,7 @@ import { createTransactionPlanner, createTransactionPlanExecutor, } from '../index.js'; +import type { AddressesByLookupTableAddress } from '../../lookup-tables/index.js'; describe('executePlan exports', () => { it('should export executePlan function', () => { @@ -60,6 +62,41 @@ describe('ExecutePlanConfig', () => { `; expect(configDescription).toContain('SimulateTransactionApi'); }); + + it('should document conditional GetAccountInfoApi requirement for lookup table fetching', () => { + // This documents that GetAccountInfoApi is only required when using lookupTableAddresses. + // When using addressesByLookupTable (pre-fetched data), GetAccountInfoApi is not required. + const altConfigDescription = ` + ExecutePlanConfig ALT support: + - lookupTableAddresses: requires GetAccountInfoApi on RPC (tables will be fetched) + - addressesByLookupTable: no additional RPC requirements (pre-fetched data) + - omit both: original behavior without ALT compression + `; + expect(altConfigDescription).toContain('GetAccountInfoApi'); + expect(altConfigDescription).toContain('addressesByLookupTable'); + expect(altConfigDescription).toContain('lookupTableAddresses'); + }); + + it('should accept config with addressesByLookupTable (type-level verification)', () => { + // Type-level test - if this compiles, the config union correctly allows pre-fetched ALT data + // without requiring GetAccountInfoApi on the RPC type + const testAltAddress = address('ALT1111111111111111111111111111111111111111'); + const testAddress1 = address('11111111111111111111111111111111'); + const testAddress2 = address('22222222222222222222222222222222222222222222'); + + const prefetchedData: AddressesByLookupTableAddress = { + [testAltAddress]: [testAddress1, testAddress2], + }; + + // This config shape should be valid - addressesByLookupTable without lookupTableAddresses + const _configWithPrefetchedData: Pick = { + commitment: 'confirmed', + addressesByLookupTable: prefetchedData, + }; + + expect(_configWithPrefetchedData.addressesByLookupTable).toBeDefined(); + expect(Object.keys(_configWithPrefetchedData.addressesByLookupTable!)).toHaveLength(1); + }); }); describe('CU estimation integration', () => { @@ -77,3 +114,23 @@ describe('CU estimation integration', () => { expect(cuPattern.executor).toBe('estimateAndUpdateProvisoryComputeUnitLimitFactory'); }); }); + +describe('ALT compression integration', () => { + it('should document the ALT compression flow', () => { + // This test documents how ALT compression is integrated into executePlan: + // 1. Lookup table data is resolved once at the start (prefetched or fetched) + // 2. compressTransactionMessage is applied BEFORE CU estimation + // 3. This ensures simulation uses the same compressed message that will be sent + const altFlow = { + step1: 'resolveLookupTableData (prefetched or fetch via lookupTableAddresses)', + step2: 'compressTransactionMessage (before CU estimation)', + step3: 'estimateAndSetCULimit (on compressed message)', + step4: 'signTransactionMessageWithSigners', + step5: 'sendAndConfirm', + }; + + expect(altFlow.step1).toContain('resolveLookupTableData'); + expect(altFlow.step2).toContain('compressTransactionMessage'); + expect(altFlow.step3).toContain('estimateAndSetCULimit'); + }); +}); diff --git a/packages/core/src/plans/execute-plan.ts b/packages/core/src/plans/execute-plan.ts index ba93ad2..59a529d 100644 --- a/packages/core/src/plans/execute-plan.ts +++ b/packages/core/src/plans/execute-plan.ts @@ -4,10 +4,12 @@ * @packageDocumentation */ +import type { Address } from '@solana/addresses'; import type { TransactionSigner } from '@solana/signers'; import type { Rpc, GetLatestBlockhashApi, + GetAccountInfoApi, GetEpochInfoApi, GetSignatureStatusesApi, SendTransactionApi, @@ -33,16 +35,26 @@ import { estimateComputeUnitLimitFactory, estimateAndUpdateProvisoryComputeUnitLimitFactory, } from '@solana-program/compute-budget'; +import { + type AddressesByLookupTableAddress, + fetchAddressLookupTables, + compressTransactionMessage, +} from '../lookup-tables/index.js'; /** - * Configuration for executing an instruction plan. + * Base RPC API required for executing instruction plans. */ -export interface ExecutePlanConfig { - /** - * RPC client. - */ - rpc: Rpc; +type BaseRpcApi = GetEpochInfoApi & GetSignatureStatusesApi & SendTransactionApi & GetLatestBlockhashApi & SimulateTransactionApi; + +/** + * RPC API required when fetching lookup tables (includes GetAccountInfoApi). + */ +type RpcApiWithAccountInfo = BaseRpcApi & GetAccountInfoApi; +/** + * Base configuration for executing an instruction plan (no ALT support). + */ +interface ExecutePlanConfigBase { /** * RPC subscriptions client. */ @@ -64,6 +76,83 @@ export interface ExecutePlanConfig { abortSignal?: AbortSignal; } +/** + * Configuration without any ALT support (original behavior). + */ +interface ExecutePlanConfigNoAlt extends ExecutePlanConfigBase { + /** + * RPC client. + */ + rpc: Rpc; + + /** + * Not used in this variant. + */ + lookupTableAddresses?: undefined; + + /** + * Not used in this variant. + */ + addressesByLookupTable?: undefined; +} + +/** + * Configuration with lookup table addresses to fetch. + * Requires RPC client with GetAccountInfoApi. + */ +interface ExecutePlanConfigWithLookupAddresses extends ExecutePlanConfigBase { + /** + * RPC client with GetAccountInfoApi for fetching lookup tables. + */ + rpc: Rpc; + + /** + * Address lookup table addresses to fetch and use for transaction compression. + * Tables will be fetched once and used to compress all transaction messages. + */ + lookupTableAddresses: Address[]; + + /** + * Not used when lookupTableAddresses is provided. + */ + addressesByLookupTable?: undefined; +} + +/** + * Configuration with pre-fetched lookup table data. + * Does not require GetAccountInfoApi since tables are already fetched. + */ +interface ExecutePlanConfigWithLookupData extends ExecutePlanConfigBase { + /** + * RPC client. + */ + rpc: Rpc; + + /** + * Not used when addressesByLookupTable is provided. + */ + lookupTableAddresses?: undefined; + + /** + * Pre-fetched lookup table data for transaction compression. + * Use this to avoid fetching tables if you already have the data. + */ + addressesByLookupTable: AddressesByLookupTableAddress; +} + +/** + * Configuration for executing an instruction plan. + * + * Supports optional address lookup table (ALT) compression: + * - Provide `lookupTableAddresses` to fetch and use ALTs (requires `GetAccountInfoApi` on RPC) + * - Provide `addressesByLookupTable` with pre-fetched data (no additional RPC requirements) + * - Omit both for original behavior without ALT compression + */ +export type ExecutePlanConfig = + | ExecutePlanConfigNoAlt + | ExecutePlanConfigWithLookupAddresses + | ExecutePlanConfigWithLookupData; + /** * Execute a Kit instruction plan using TransactionBuilder features. * @@ -119,6 +208,9 @@ export interface ExecutePlanConfig { export async function executePlan(plan: InstructionPlan, config: ExecutePlanConfig): Promise { const { rpc, rpcSubscriptions, signer, commitment = 'confirmed', abortSignal } = config; + // Resolve lookup table data once (prefetched or fetched from addresses) + const lookupTableData = await resolveLookupTableData(config); + // Create transaction planner with provisory CU instruction const planner = createTransactionPlanner({ createTransactionMessage: async () => { @@ -145,11 +237,16 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf const estimateCULimit = estimateComputeUnitLimitFactory({ rpc }); const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); - // Create transaction executor with CU estimation + // Create transaction executor with CU estimation and ALT compression const executor = createTransactionPlanExecutor({ executeTransactionMessage: async message => { + // Apply ALT compression before CU estimation (if lookup tables provided) + const compressedMessage = lookupTableData + ? compressTransactionMessage(message, lookupTableData) + : message; + // Estimate and update the provisory CU instruction with actual value - const estimatedMessage = await estimateAndSetCULimit(message); + const estimatedMessage = await estimateAndSetCULimit(compressedMessage); // Sign the transaction const signedTransaction = await signTransactionMessageWithSigners(estimatedMessage); @@ -166,3 +263,32 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf // Execute the plan return executor(transactionPlan, abortSignal ? { abortSignal } : {}); } + +/** + * Resolve lookup table data from config. + * - If `addressesByLookupTable` is provided, use it directly. + * - If `lookupTableAddresses` is provided, fetch the tables. + * - Otherwise, return undefined (no ALT compression). + */ +async function resolveLookupTableData( + config: ExecutePlanConfig, +): Promise { + // Use pre-fetched data if provided + if (config.addressesByLookupTable) { + return config.addressesByLookupTable; + } + + // Fetch tables if addresses provided + if (config.lookupTableAddresses && config.lookupTableAddresses.length > 0) { + // TypeScript knows rpc has GetAccountInfoApi when lookupTableAddresses is provided + const rpcWithAccountInfo = config.rpc as Rpc; + return fetchAddressLookupTables( + rpcWithAccountInfo, + config.lookupTableAddresses, + config.commitment ?? 'confirmed', + ); + } + + // No ALT compression + return undefined; +} From 3b90810aa9756b0970f954ae4a6d1d5ca8c3de94 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 23 Dec 2025 21:22:48 -0800 Subject: [PATCH 04/12] feat: add actions-v2 titan swap example --- .../next-js/components/landing/playground.tsx | 14 +- .../components/pipeline/examples/index.ts | 1 + .../pipeline/examples/jupiter-swap.tsx | 4 +- .../pipeline/examples/pipe-multi-swap.tsx | 4 +- .../pipeline/examples/titan-swap.tsx | 99 ++++++ examples/next-js/package.json | 5 +- packages/actions-v2/README.md | 288 +++++++++++++++ packages/actions-v2/package.json | 71 ++++ packages/actions-v2/src/index.ts | 35 ++ .../src/titan/__tests__/convert.test.ts | 141 ++++++++ .../src/titan/__tests__/plan-swap.test.ts | 189 ++++++++++ packages/actions-v2/src/titan/client.ts | 198 +++++++++++ packages/actions-v2/src/titan/convert.ts | 140 ++++++++ packages/actions-v2/src/titan/index.ts | 32 ++ packages/actions-v2/src/titan/plan-swap.ts | 332 ++++++++++++++++++ packages/actions-v2/src/titan/types.ts | 279 +++++++++++++++ packages/actions-v2/tsconfig.json | 13 + packages/actions-v2/tsup.config.ts | 30 ++ .../src/plans/__tests__/execute-plan.test.ts | 25 ++ packages/core/src/plans/execute-plan.ts | 13 +- pnpm-lock.yaml | 104 +++--- 21 files changed, 1958 insertions(+), 59 deletions(-) create mode 100644 examples/next-js/components/pipeline/examples/titan-swap.tsx create mode 100644 packages/actions-v2/README.md create mode 100644 packages/actions-v2/package.json create mode 100644 packages/actions-v2/src/index.ts create mode 100644 packages/actions-v2/src/titan/__tests__/convert.test.ts create mode 100644 packages/actions-v2/src/titan/__tests__/plan-swap.test.ts create mode 100644 packages/actions-v2/src/titan/client.ts create mode 100644 packages/actions-v2/src/titan/convert.ts create mode 100644 packages/actions-v2/src/titan/index.ts create mode 100644 packages/actions-v2/src/titan/plan-swap.ts create mode 100644 packages/actions-v2/src/titan/types.ts create mode 100644 packages/actions-v2/tsconfig.json create mode 100644 packages/actions-v2/tsup.config.ts diff --git a/examples/next-js/components/landing/playground.tsx b/examples/next-js/components/landing/playground.tsx index ef6ac4b..e5f55d8 100644 --- a/examples/next-js/components/landing/playground.tsx +++ b/examples/next-js/components/landing/playground.tsx @@ -16,8 +16,8 @@ import { mixedPipelineCode, jupiterSwapCode, useJupiterSwapPipeline, - usePipeMultiSwapPipeline, - pipeMultiSwapCode, + useTitanSwapPipeline, + titanSwapCode, useJitoBundlePipeline, jitoBundleCode, useTpuDirectPipeline, @@ -68,11 +68,11 @@ const pipelineExamples: PipelineExampleConfig[] = [ code: jupiterSwapCode, }, { - id: 'pipe-multi-swap', - name: 'Pipe Multi-Swap', - description: 'SOL → USDC → BONK sequential swaps with Flow orchestration', - hook: usePipeMultiSwapPipeline, - code: pipeMultiSwapCode, + id: 'titan-swap', + name: 'Titan Swap', + description: 'Swap tokens using Titan aggregator with InstructionPlan API', + hook: useTitanSwapPipeline, + code: titanSwapCode, }, { id: 'jito-bundle', diff --git a/examples/next-js/components/pipeline/examples/index.ts b/examples/next-js/components/pipeline/examples/index.ts index af1d4e1..860712a 100644 --- a/examples/next-js/components/pipeline/examples/index.ts +++ b/examples/next-js/components/pipeline/examples/index.ts @@ -3,6 +3,7 @@ export { useBatchedTransfersPipeline, batchedTransfersCode } from './batched-tra export { useMixedPipeline, mixedPipelineCode } from './mixed-pipeline'; export { useInstructionPlanPipeline, instructionPlanCode } from './instruction-plan'; export { useJupiterSwapPipeline, jupiterSwapCode } from './jupiter-swap'; +export { useTitanSwapPipeline, titanSwapCode } from './titan-swap'; export { usePipeMultiSwapPipeline, pipeMultiSwapCode } from './pipe-multi-swap'; export { useJitoBundlePipeline, jitoBundleCode } from './jito-bundle'; export { diff --git a/examples/next-js/components/pipeline/examples/jupiter-swap.tsx b/examples/next-js/components/pipeline/examples/jupiter-swap.tsx index 34faebd..f13d768 100644 --- a/examples/next-js/components/pipeline/examples/jupiter-swap.tsx +++ b/examples/next-js/components/pipeline/examples/jupiter-swap.tsx @@ -43,11 +43,11 @@ export function useJupiterSwapPipeline() { // Get lookup table addresses from Jupiter response and convert to Address types const lookupTables = result.addressLookupTableAddresses ?? []; - const lookupTableAddrs = lookupTables.map(addr => address(addr)); + const lookupTableAddrs = lookupTables.map((addr: string) => address(addr)); const signature = await new TransactionBuilder({ rpc: ctx.rpc as any, - computeUnits: result.computeUnits ?? 400_000, + computeUnits: 400_000, // Use lookup tables to compress the transaction lookupTableAddresses: lookupTableAddrs.length > 0 ? lookupTableAddrs : undefined, }) diff --git a/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx b/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx index f457120..c33c6ed 100644 --- a/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx +++ b/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx @@ -46,7 +46,7 @@ export function usePipeMultiSwapPipeline() { const { address } = await import('@solana/kit'); const lookupTables = result.addressLookupTableAddresses ?? []; - const lookupTableAddrs = lookupTables.map(addr => address(addr)); + const lookupTableAddrs = lookupTables.map((addr: string) => address(addr)); const { value: blockhash } = await (ctx.rpc as any).getLatestBlockhash().send(); @@ -88,7 +88,7 @@ export function usePipeMultiSwapPipeline() { const { address } = await import('@solana/kit'); const lookupTables = result.addressLookupTableAddresses ?? []; - const lookupTableAddrs = lookupTables.map(addr => address(addr)); + const lookupTableAddrs = lookupTables.map((addr: string) => address(addr)); const { value: blockhash } = await (ctx.rpc as any).getLatestBlockhash().send(); diff --git a/examples/next-js/components/pipeline/examples/titan-swap.tsx b/examples/next-js/components/pipeline/examples/titan-swap.tsx new file mode 100644 index 0000000..e2c68f8 --- /dev/null +++ b/examples/next-js/components/pipeline/examples/titan-swap.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useMemo } from 'react'; +import { executePlan, createFlow, type FlowConfig } from '@pipeit/core'; +import { VisualPipeline } from '@/lib/visual-pipeline'; +import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; + +// Token addresses +const SOL_MINT = 'So11111111111111111111111111111111111111112'; +const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +/** + * Example: Titan Swap using @pipeit/actions-v2 + * + * This demonstrates using the new InstructionPlan-first approach with Titan, + * including ALT (Address Lookup Table) support for optimal transaction packing. + */ +export function useTitanSwapPipeline() { + const visualPipeline = useMemo(() => { + const flowFactory = (config: FlowConfig) => + createFlow(config).transaction('titan-swap', async ctx => { + // Get swap plan from Titan + // This returns an InstructionPlan + ALT addresses + const swapResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 10_000_000n, // 0.01 SOL + slippageBps: 50, + }, + transaction: { + userPublicKey: ctx.signer.address, + createOutputTokenAccount: true, + }, + }); + + console.log( + `Titan quote: ${swapResult.quote.inputAmount} -> ${swapResult.quote.outputAmount} via ${swapResult.providerId}`, + ); + + // Execute the plan with ALT support + // The ALTs enable optimal transaction packing and compression + const result = await executePlan(swapResult.plan, { + rpc: ctx.rpc as any, + rpcSubscriptions: ctx.rpcSubscriptions as any, + signer: ctx.signer, + commitment: 'confirmed', + // Pass ALT addresses for compression + lookupTableAddresses: swapResult.lookupTableAddresses, + }); + + return { + signature: 'titan-swap-executed', + quote: swapResult.quote, + providerId: swapResult.providerId, + }; + }); + + return new VisualPipeline('titan-swap', flowFactory, [{ name: 'titan-swap', type: 'transaction' }]); + }, []); + + return visualPipeline; +} + +export const titanSwapCode = `import { getTitanSwapPlan } from '@pipeit/actions-v2/titan' +import { executePlan } from '@pipeit/core' + +// Token addresses +const SOL = 'So11111111111111111111111111111111111111112' +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + +// Get a swap plan from Titan +// Returns an InstructionPlan + ALT addresses for compression +const { plan, lookupTableAddresses, quote, providerId } = await getTitanSwapPlan({ + swap: { + inputMint: SOL, + outputMint: USDC, + amount: 10_000_000n, // 0.01 SOL in lamports + slippageBps: 50, // 0.5% slippage tolerance + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + }, +}) + +console.log(\`Swapping for ~\${quote.outputAmount} USDC via \${providerId}\`) + +// Execute using Kit's InstructionPlan system with ALT support +// ALTs enable optimal transaction packing and compression +const result = await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + commitment: 'confirmed', + lookupTableAddresses, // Pass ALTs for compression +}) + +console.log('Swap executed:', result.type)`; diff --git a/examples/next-js/package.json b/examples/next-js/package.json index 9420a5f..c682625 100644 --- a/examples/next-js/package.json +++ b/examples/next-js/package.json @@ -12,8 +12,9 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@pipeit/actions": "workspace:*", - "@pipeit/core": "^0.2.5", - "@pipeit/fastlane": "^0.1.3", + "@pipeit/actions-v2": "workspace:*", + "@pipeit/core": "workspace:*", + "@pipeit/fastlane": "workspace:*", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/actions-v2/README.md b/packages/actions-v2/README.md new file mode 100644 index 0000000..855189f --- /dev/null +++ b/packages/actions-v2/README.md @@ -0,0 +1,288 @@ +# @pipeit/actions-v2 + +Composable InstructionPlan factories for Solana DeFi, starting with Titan integration. + +This package provides Kit-compatible `InstructionPlan` factories that can be: +- Executed directly with `@pipeit/core`'s `executePlan` +- Composed with other InstructionPlans using Kit's plan combinators +- Used by anyone in the Kit ecosystem + +## Installation + +```bash +pnpm install @pipeit/actions-v2 @pipeit/core @solana/kit +``` + +## Quick Start + +```typescript +import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; +import { executePlan } from '@pipeit/core'; +import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit'; + +const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com'); +const rpcSubscriptions = createSolanaRpcSubscriptions('wss://api.mainnet-beta.solana.com'); + +// Get a swap plan from Titan +const { plan, lookupTableAddresses, quote } = await getTitanSwapPlan({ + swap: { + inputMint: 'So11111111111111111111111111111111111111112', // SOL + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC + amount: 1_000_000_000n, // 1 SOL + slippageBps: 50, // 0.5% + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + }, +}); + +console.log(`Swapping 1 SOL for ~${quote.outputAmount / 1_000_000n} USDC`); + +// Execute with ALT support for optimal transaction packing +await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + lookupTableAddresses, +}); +``` + +## Titan API + +### `getTitanSwapPlan` + +The main entry point that fetches a quote, selects the best route, and returns a composable plan. + +```typescript +import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; + +const { plan, lookupTableAddresses, quote, providerId, route } = await getTitanSwapPlan({ + swap: { + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: 1_000_000_000n, + slippageBps: 50, + // Optional filters + dexes: ['Raydium', 'Orca'], // Only use these DEXes + excludeDexes: ['Phoenix'], // Exclude these DEXes + onlyDirectRoutes: false, // Allow multi-hop routes + providers: ['titan'], // Only use specific providers + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + closeInputTokenAccount: false, + }, +}, { + // Optional: specify a provider + providerId: 'titan', +}); +``` + +### Lower-Level APIs + +For more control, you can use the individual functions: + +```typescript +import { + createTitanClient, + getTitanSwapQuote, + selectTitanRoute, + getTitanSwapInstructionPlanFromRoute, +} from '@pipeit/actions-v2/titan'; + +// Create a client +const client = createTitanClient({ + baseUrl: 'https://api.titan.ag/api/v1', + authToken: 'optional-jwt-for-fees', +}); + +// Get quotes from all providers +const quotes = await getTitanSwapQuote(client, { + swap: { inputMint, outputMint, amount }, + transaction: { userPublicKey }, +}); + +// Select the best route (or a specific provider) +const { providerId, route } = selectTitanRoute(quotes, { + providerId: 'titan', // Optional: use specific provider +}); + +// Build the instruction plan +const plan = getTitanSwapInstructionPlanFromRoute(route); + +// Extract ALT addresses +const lookupTableAddresses = route.addressLookupTables.map(titanPubkeyToAddress); +``` + +## Composing Plans + +The real power of InstructionPlans is composition. Combine multiple plans: + +```typescript +import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; +import { + sequentialInstructionPlan, + parallelInstructionPlan, + singleInstructionPlan, +} from '@solana/instruction-plans'; +import { executePlan } from '@pipeit/core'; + +// Swap SOL → USDC +const swapResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 10_000_000_000n, // 10 SOL + }, + transaction: { userPublicKey: signer.address }, +}); + +// Add a transfer instruction +const transferPlan = singleInstructionPlan(transferInstruction); + +// Combine: swap then transfer +const combinedPlan = sequentialInstructionPlan([ + swapResult.plan, + transferPlan, +]); + +// Execute with all ALTs +await executePlan(combinedPlan, { + rpc, + rpcSubscriptions, + signer, + lookupTableAddresses: swapResult.lookupTableAddresses, +}); +``` + +## ALT (Address Lookup Table) Support + +Titan swaps often require Address Lookup Tables to stay under transaction size limits. The `@pipeit/core` `executePlan` function handles this automatically: + +1. **Planner-time compression**: ALTs are used during transaction planning, so Kit can pack more instructions per transaction. +2. **Executor-time compression**: Messages are compressed before simulation and signing, ensuring what you simulate is what you send. + +```typescript +// Option 1: Pass ALT addresses (core will fetch them) +await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + lookupTableAddresses: swapResult.lookupTableAddresses, +}); + +// Option 2: Pre-fetch ALT data yourself +import { fetchAddressLookupTables } from '@pipeit/core'; + +const addressesByLookupTable = await fetchAddressLookupTables( + rpc, + swapResult.lookupTableAddresses, +); + +await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + addressesByLookupTable, +}); +``` + +## Swap Modes + +Titan supports two swap modes: + +- **ExactIn** (default): Swap exactly N input tokens, get variable output +- **ExactOut**: Get exactly N output tokens, use variable input + +```typescript +// ExactIn: Swap exactly 1 SOL, get as much USDC as possible +const exactInResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 1_000_000_000n, // 1 SOL + swapMode: 'ExactIn', + }, + transaction: { userPublicKey: signer.address }, +}); + +// ExactOut: Get exactly 100 USDC, use as little SOL as possible +const exactOutResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 100_000_000n, // 100 USDC + swapMode: 'ExactOut', + }, + transaction: { userPublicKey: signer.address }, +}); +``` + +## Error Handling + +```typescript +import { + TitanApiError, + NoRoutesError, + ProviderNotFoundError, + NoInstructionsError, +} from '@pipeit/actions-v2/titan'; + +try { + const result = await getTitanSwapPlan({ ... }); +} catch (error) { + if (error instanceof TitanApiError) { + console.error(`API error (${error.statusCode}): ${error.responseBody}`); + } else if (error instanceof NoRoutesError) { + console.error(`No routes available for quote ${error.quoteId}`); + } else if (error instanceof ProviderNotFoundError) { + console.error(`Provider ${error.providerId} not found. Available: ${error.availableProviders}`); + } else if (error instanceof NoInstructionsError) { + console.error('Route has no instructions (may only provide pre-built transaction)'); + } +} +``` + +## Type Exports + +### Client + +- `createTitanClient` - Create a Titan REST API client +- `TitanClient` - Client interface +- `TitanClientConfig` - Client configuration + +### Plan Building + +- `getTitanSwapPlan` - Main entry point +- `getTitanSwapQuote` - Fetch raw quotes +- `selectTitanRoute` - Select best route from quotes +- `getTitanSwapInstructionPlanFromRoute` - Build plan from route +- `TitanSwapPlanResult` - Result type +- `TitanSwapPlanOptions` - Options type + +### Types + +- `SwapQuoteParams` - Quote request parameters +- `SwapQuotes` - Quote response +- `SwapRoute` - Individual route +- `RoutePlanStep` - Step in a route +- `SwapMode` - 'ExactIn' | 'ExactOut' + +### Errors + +- `TitanApiError` - API request failed +- `NoRoutesError` - No routes available +- `ProviderNotFoundError` - Requested provider not found +- `NoInstructionsError` - Route has no instructions + +### Conversion Utilities + +- `titanInstructionToKit` - Convert Titan instruction to Kit +- `titanPubkeyToAddress` - Convert Titan pubkey to Kit Address +- `encodeBase58` - Encode bytes as base58 + +## License + +MIT diff --git a/packages/actions-v2/package.json b/packages/actions-v2/package.json new file mode 100644 index 0000000..33ed845 --- /dev/null +++ b/packages/actions-v2/package.json @@ -0,0 +1,71 @@ +{ + "name": "@pipeit/actions-v2", + "version": "0.1.0", + "description": "Composable DeFi InstructionPlan factories for Solana (Titan-first)", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "typings": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./titan": { + "types": "./dist/titan/index.d.ts", + "import": "./dist/titan/index.js", + "require": "./dist/titan/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest" + }, + "keywords": [ + "solana", + "defi", + "swap", + "titan", + "instruction-plans", + "transaction" + ], + "license": "MIT", + "peerDependencies": { + "@pipeit/core": "^0.2.5", + "@solana/kit": "^5.0.0", + "@solana/addresses": "*", + "@solana/instruction-plans": "*", + "@solana/instructions": "*", + "@solana/rpc": "*", + "@solana/rpc-subscriptions": "*", + "@solana/signers": "*", + "@solana/transactions": "*" + }, + "dependencies": { + "@msgpack/msgpack": "^3.0.0" + }, + "devDependencies": { + "@pipeit/core": "workspace:*", + "@solana/addresses": "*", + "@solana/instruction-plans": "*", + "@solana/instructions": "*", + "@solana/kit": "^5.0.0", + "@solana/rpc": "*", + "@solana/rpc-subscriptions": "*", + "@solana/signers": "*", + "@solana/transactions": "*", + "@types/node": "^24", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/actions-v2/src/index.ts b/packages/actions-v2/src/index.ts new file mode 100644 index 0000000..e229017 --- /dev/null +++ b/packages/actions-v2/src/index.ts @@ -0,0 +1,35 @@ +/** + * @pipeit/actions-v2 - Composable InstructionPlan factories for Solana DeFi. + * + * This package provides Kit-compatible InstructionPlan factories that can be: + * - Executed directly with `@pipeit/core`'s executePlan + * - Composed with other InstructionPlans using Kit's plan combinators + * - Used by anyone in the Kit ecosystem + * + * @example + * ```ts + * import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; + * import { executePlan } from '@pipeit/core'; + * + * // Get a swap plan from Titan + * const { plan, lookupTableAddresses } = await getTitanSwapPlan({ + * inputMint: SOL_MINT, + * outputMint: USDC_MINT, + * amount: 1_000_000_000n, + * user: signer.address, + * }); + * + * // Execute with ALT support + * await executePlan(plan, { + * rpc, + * rpcSubscriptions, + * signer, + * lookupTableAddresses, + * }); + * ``` + * + * @packageDocumentation + */ + +// Re-export Titan module +export * from './titan/index.js'; diff --git a/packages/actions-v2/src/titan/__tests__/convert.test.ts b/packages/actions-v2/src/titan/__tests__/convert.test.ts new file mode 100644 index 0000000..26cd790 --- /dev/null +++ b/packages/actions-v2/src/titan/__tests__/convert.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for Titan conversion utilities. + */ + +import { describe, it, expect } from 'vitest'; +import { + encodeBase58, + titanPubkeyToAddress, + titanInstructionToKit, + titanPubkeysToAddresses, +} from '../convert.js'; +import type { TitanInstruction, TitanAccountMeta } from '../types.js'; + +describe('encodeBase58', () => { + it('should encode empty bytes to empty string', () => { + expect(encodeBase58(new Uint8Array([]))).toBe(''); + }); + + it('should encode all zeros to leading ones', () => { + // 32 zeros should become 32 ones (the minimum Solana address) + const zeros = new Uint8Array(32); + const result = encodeBase58(zeros); + expect(result).toBe('11111111111111111111111111111111'); + }); + + it('should encode system program address correctly', () => { + // System program: 11111111111111111111111111111111 + // This is 32 bytes of 0x00 + const systemProgram = new Uint8Array(32); + expect(encodeBase58(systemProgram)).toBe('11111111111111111111111111111111'); + }); + + it('should encode non-zero bytes correctly', () => { + // A simple test case: [1] should encode to '2' (second character in base58) + expect(encodeBase58(new Uint8Array([1]))).toBe('2'); + + // [58] should encode to '21' (58 in base58 is 1*58 + 0 = '21') + expect(encodeBase58(new Uint8Array([58]))).toBe('21'); + }); + + it('should handle leading zeros followed by data', () => { + // [0, 1] should be '12' (one leading 1, then 2) + expect(encodeBase58(new Uint8Array([0, 1]))).toBe('12'); + + // [0, 0, 1] should be '112' + expect(encodeBase58(new Uint8Array([0, 0, 1]))).toBe('112'); + }); +}); + +describe('titanPubkeyToAddress', () => { + it('should convert 32-byte pubkey to base58 address', () => { + // System program + const pubkey = new Uint8Array(32); + const address = titanPubkeyToAddress(pubkey); + expect(address).toBe('11111111111111111111111111111111'); + }); + + it('should return a valid Kit Address type', () => { + const pubkey = new Uint8Array(32); + const address = titanPubkeyToAddress(pubkey); + // Address is a branded string type, should be usable as string + expect(typeof address).toBe('string'); + expect(address.length).toBe(32); // System program is exactly 32 chars + }); +}); + +describe('titanInstructionToKit', () => { + it('should convert a simple instruction', () => { + const titanIx: TitanInstruction = { + p: new Uint8Array(32), // System program + a: [], + d: new Uint8Array([1, 2, 3, 4]), + }; + + const kitIx = titanInstructionToKit(titanIx); + + expect(kitIx.programAddress).toBe('11111111111111111111111111111111'); + expect(kitIx.accounts).toEqual([]); + expect(kitIx.data).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it('should convert instruction with accounts', () => { + const account1: TitanAccountMeta = { + p: new Uint8Array(32), + s: false, + w: false, + }; + const account2: TitanAccountMeta = { + p: new Uint8Array(32), + s: true, + w: false, + }; + const account3: TitanAccountMeta = { + p: new Uint8Array(32), + s: false, + w: true, + }; + const account4: TitanAccountMeta = { + p: new Uint8Array(32), + s: true, + w: true, + }; + + const titanIx: TitanInstruction = { + p: new Uint8Array(32), + a: [account1, account2, account3, account4], + d: new Uint8Array([]), + }; + + const kitIx = titanInstructionToKit(titanIx); + + expect(kitIx.accounts).toHaveLength(4); + + // READONLY (role 0) + expect(kitIx.accounts[0].role).toBe(0); + // READONLY_SIGNER (role 2) + expect(kitIx.accounts[1].role).toBe(2); + // WRITABLE (role 1) + expect(kitIx.accounts[2].role).toBe(1); + // WRITABLE_SIGNER (role 3) + expect(kitIx.accounts[3].role).toBe(3); + }); +}); + +describe('titanPubkeysToAddresses', () => { + it('should convert empty array', () => { + expect(titanPubkeysToAddresses([])).toEqual([]); + }); + + it('should convert multiple pubkeys', () => { + const pubkeys = [ + new Uint8Array(32), + new Uint8Array(32), + ]; + const addresses = titanPubkeysToAddresses(pubkeys); + + expect(addresses).toHaveLength(2); + expect(addresses[0]).toBe('11111111111111111111111111111111'); + expect(addresses[1]).toBe('11111111111111111111111111111111'); + }); +}); diff --git a/packages/actions-v2/src/titan/__tests__/plan-swap.test.ts b/packages/actions-v2/src/titan/__tests__/plan-swap.test.ts new file mode 100644 index 0000000..a653f5d --- /dev/null +++ b/packages/actions-v2/src/titan/__tests__/plan-swap.test.ts @@ -0,0 +1,189 @@ +/** + * Tests for Titan swap plan builder. + */ + +import { describe, it, expect } from 'vitest'; +import { + selectTitanRoute, + getTitanSwapInstructionPlanFromRoute, + NoRoutesError, + ProviderNotFoundError, + NoInstructionsError, +} from '../plan-swap.js'; +import type { SwapQuotes, SwapRoute, TitanInstruction } from '../types.js'; + +/** + * Create a mock swap route for testing. + */ +function createMockRoute(overrides: Partial = {}): SwapRoute { + return { + inAmount: 1_000_000_000n, + outAmount: 100_000_000n, + slippageBps: 50, + steps: [], + instructions: [ + { + p: new Uint8Array(32), + a: [], + d: new Uint8Array([1, 2, 3, 4]), + }, + ], + addressLookupTables: [], + ...overrides, + }; +} + +/** + * Create mock swap quotes for testing. + */ +function createMockQuotes(overrides: Partial = {}): SwapQuotes { + return { + id: 'test-quote-id', + inputMint: new Uint8Array(32), + outputMint: new Uint8Array(32), + swapMode: 'ExactIn', + amount: 1_000_000_000n, + quotes: { + 'provider-a': createMockRoute({ outAmount: 100_000_000n }), + 'provider-b': createMockRoute({ outAmount: 120_000_000n }), + 'provider-c': createMockRoute({ outAmount: 95_000_000n }), + }, + ...overrides, + }; +} + +describe('selectTitanRoute', () => { + describe('ExactIn mode', () => { + it('should select the route with maximum outAmount', () => { + const quotes = createMockQuotes(); + const { providerId, route } = selectTitanRoute(quotes); + + expect(providerId).toBe('provider-b'); + expect(route.outAmount).toBe(120_000_000n); + }); + + it('should select first route if all have same outAmount', () => { + const quotes = createMockQuotes({ + quotes: { + 'provider-a': createMockRoute({ outAmount: 100_000_000n }), + 'provider-b': createMockRoute({ outAmount: 100_000_000n }), + }, + }); + const { providerId } = selectTitanRoute(quotes); + + // Should select first one + expect(providerId).toBe('provider-a'); + }); + }); + + describe('ExactOut mode', () => { + it('should select the route with minimum inAmount', () => { + const quotes = createMockQuotes({ + swapMode: 'ExactOut', + quotes: { + 'provider-a': createMockRoute({ inAmount: 1_000_000_000n }), + 'provider-b': createMockRoute({ inAmount: 900_000_000n }), + 'provider-c': createMockRoute({ inAmount: 1_100_000_000n }), + }, + }); + const { providerId, route } = selectTitanRoute(quotes); + + expect(providerId).toBe('provider-b'); + expect(route.inAmount).toBe(900_000_000n); + }); + }); + + describe('specific provider selection', () => { + it('should select the specified provider', () => { + const quotes = createMockQuotes(); + const { providerId, route } = selectTitanRoute(quotes, { providerId: 'provider-c' }); + + expect(providerId).toBe('provider-c'); + expect(route.outAmount).toBe(95_000_000n); + }); + + it('should throw ProviderNotFoundError for unknown provider', () => { + const quotes = createMockQuotes(); + + expect(() => selectTitanRoute(quotes, { providerId: 'unknown-provider' })) + .toThrow(ProviderNotFoundError); + }); + + it('should include available providers in error', () => { + const quotes = createMockQuotes(); + + try { + selectTitanRoute(quotes, { providerId: 'unknown-provider' }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProviderNotFoundError); + const providerError = error as ProviderNotFoundError; + expect(providerError.providerId).toBe('unknown-provider'); + expect(providerError.availableProviders).toContain('provider-a'); + expect(providerError.availableProviders).toContain('provider-b'); + expect(providerError.availableProviders).toContain('provider-c'); + } + }); + }); + + describe('error handling', () => { + it('should throw NoRoutesError when no quotes available', () => { + const quotes = createMockQuotes({ quotes: {} }); + + expect(() => selectTitanRoute(quotes)).toThrow(NoRoutesError); + }); + + it('should include quote ID in error', () => { + const quotes = createMockQuotes({ id: 'my-quote-id', quotes: {} }); + + try { + selectTitanRoute(quotes); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(NoRoutesError); + expect((error as NoRoutesError).quoteId).toBe('my-quote-id'); + } + }); + }); +}); + +describe('getTitanSwapInstructionPlanFromRoute', () => { + it('should create a single instruction plan for one instruction', () => { + const route = createMockRoute({ + instructions: [ + { + p: new Uint8Array(32), + a: [], + d: new Uint8Array([1]), + }, + ], + }); + + const plan = getTitanSwapInstructionPlanFromRoute(route); + + expect(plan.kind).toBe('single'); + }); + + it('should create a sequential plan for multiple instructions', () => { + const route = createMockRoute({ + instructions: [ + { p: new Uint8Array(32), a: [], d: new Uint8Array([1]) }, + { p: new Uint8Array(32), a: [], d: new Uint8Array([2]) }, + { p: new Uint8Array(32), a: [], d: new Uint8Array([3]) }, + ], + }); + + const plan = getTitanSwapInstructionPlanFromRoute(route); + + expect(plan.kind).toBe('sequential'); + if (plan.kind === 'sequential') { + expect(plan.plans).toHaveLength(3); + } + }); + + it('should throw NoInstructionsError when route has no instructions', () => { + const route = createMockRoute({ instructions: [] }); + + expect(() => getTitanSwapInstructionPlanFromRoute(route)).toThrow(NoInstructionsError); + }); +}); diff --git a/packages/actions-v2/src/titan/client.ts b/packages/actions-v2/src/titan/client.ts new file mode 100644 index 0000000..67847d3 --- /dev/null +++ b/packages/actions-v2/src/titan/client.ts @@ -0,0 +1,198 @@ +/** + * Titan REST API client. + * + * Provides a minimal client for Titan's REST API with MessagePack decoding. + * + * @packageDocumentation + */ + +import { decode } from '@msgpack/msgpack'; +import type { + SwapQuoteParams, + SwapQuotes, + ServerInfo, + ProviderInfo, + VenueInfo, + TitanPubkey, +} from './types.js'; +import { encodeBase58 } from './convert.js'; + +/** + * Configuration for the Titan client. + */ +export interface TitanClientConfig { + /** REST API base URL (default: https://api.titan.ag/api/v1) */ + baseUrl?: string; + /** Authentication token (optional, for fee collection) */ + authToken?: string; + /** Custom fetch implementation (for testing or environments without global fetch) */ + fetch?: typeof globalThis.fetch; +} + +/** + * Titan REST API client. + */ +export interface TitanClient { + /** Get a swap quote */ + getSwapQuote(params: SwapQuoteParams): Promise; + /** Get server info */ + getInfo(): Promise; + /** List available providers */ + listProviders(includeIcons?: boolean): Promise; + /** Get available venues (DEXes) */ + getVenues(includeProgramIds?: boolean): Promise; +} + +/** + * Convert a pubkey (Uint8Array or string) to base58 string for URL params. + */ +function pubkeyToString(pubkey: TitanPubkey | string): string { + if (typeof pubkey === 'string') { + return pubkey; + } + return encodeBase58(pubkey); +} + +/** + * Create a Titan REST API client. + * + * @example + * ```ts + * const client = createTitanClient(); + * + * const quotes = await client.getSwapQuote({ + * swap: { + * inputMint: 'So11111111111111111111111111111111111111112', + * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * amount: 1_000_000_000n, + * }, + * transaction: { + * userPublicKey: 'YourWalletAddress...', + * }, + * }); + * ``` + */ +export function createTitanClient(config: TitanClientConfig = {}): TitanClient { + const { + baseUrl = 'https://api.titan.ag/api/v1', + authToken, + fetch: customFetch = globalThis.fetch, + } = config; + + /** + * Make a GET request to the Titan API. + */ + async function get(path: string, params?: URLSearchParams): Promise { + const url = params ? `${baseUrl}${path}?${params}` : `${baseUrl}${path}`; + + const headers: Record = {}; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await customFetch(url, { headers }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new TitanApiError(response.status, errorText, path); + } + + // Titan returns MessagePack-encoded responses + const buffer = await response.arrayBuffer(); + // Use useBigInt64 to decode u64 values as BigInt (prevents precision loss) + return decode(new Uint8Array(buffer), { useBigInt64: true }) as T; + } + + return { + async getSwapQuote(params: SwapQuoteParams): Promise { + const { swap, transaction } = params; + + const urlParams = new URLSearchParams(); + + // Required parameters + urlParams.set('inputMint', pubkeyToString(swap.inputMint)); + urlParams.set('outputMint', pubkeyToString(swap.outputMint)); + urlParams.set('amount', swap.amount.toString()); + urlParams.set('userPublicKey', pubkeyToString(transaction.userPublicKey)); + + // Optional swap parameters + if (swap.swapMode !== undefined) { + urlParams.set('swapMode', swap.swapMode); + } + if (swap.slippageBps !== undefined) { + urlParams.set('slippageBps', swap.slippageBps.toString()); + } + if (swap.dexes !== undefined && swap.dexes.length > 0) { + urlParams.set('dexes', swap.dexes.join(',')); + } + if (swap.excludeDexes !== undefined && swap.excludeDexes.length > 0) { + urlParams.set('excludeDexes', swap.excludeDexes.join(',')); + } + if (swap.onlyDirectRoutes !== undefined) { + urlParams.set('onlyDirectRoutes', String(swap.onlyDirectRoutes)); + } + if (swap.providers !== undefined && swap.providers.length > 0) { + urlParams.set('providers', swap.providers.join(',')); + } + + // Optional transaction parameters + if (transaction.closeInputTokenAccount !== undefined) { + urlParams.set('closeInputTokenAccount', String(transaction.closeInputTokenAccount)); + } + if (transaction.createOutputTokenAccount !== undefined) { + urlParams.set('createOutputTokenAccount', String(transaction.createOutputTokenAccount)); + } + if (transaction.feeAccount !== undefined) { + urlParams.set('feeAccount', pubkeyToString(transaction.feeAccount)); + } + if (transaction.feeBps !== undefined) { + urlParams.set('feeBps', transaction.feeBps.toString()); + } + if (transaction.feeFromInputMint !== undefined) { + urlParams.set('feeFromInputMint', String(transaction.feeFromInputMint)); + } + if (transaction.outputAccount !== undefined) { + urlParams.set('outputAccount', pubkeyToString(transaction.outputAccount)); + } + + return get('/quote/swap', urlParams); + }, + + async getInfo(): Promise { + return get('/info'); + }, + + async listProviders(includeIcons = false): Promise { + const params = new URLSearchParams(); + if (includeIcons) { + params.set('includeIcons', 'true'); + } + return get('/providers', params); + }, + + async getVenues(includeProgramIds = false): Promise { + const params = new URLSearchParams(); + if (includeProgramIds) { + params.set('includeProgramIds', 'true'); + } + return get('/venues', params); + }, + }; +} + +/** + * Error thrown when the Titan API returns an error response. + */ +export class TitanApiError extends Error { + readonly statusCode: number; + readonly responseBody: string; + readonly path: string; + + constructor(statusCode: number, responseBody: string, path: string) { + super(`Titan API error (${statusCode}) at ${path}: ${responseBody}`); + this.name = 'TitanApiError'; + this.statusCode = statusCode; + this.responseBody = responseBody; + this.path = path; + } +} diff --git a/packages/actions-v2/src/titan/convert.ts b/packages/actions-v2/src/titan/convert.ts new file mode 100644 index 0000000..02d44b2 --- /dev/null +++ b/packages/actions-v2/src/titan/convert.ts @@ -0,0 +1,140 @@ +/** + * Conversion utilities for Titan types to Kit types. + * + * @packageDocumentation + */ + +import { address, type Address } from '@solana/addresses'; +import type { Instruction, AccountMeta, AccountRole } from '@solana/instructions'; +import type { TitanPubkey, TitanInstruction, TitanAccountMeta } from './types.js'; + +/** + * Base58 alphabet used by Solana. + */ +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +/** + * Encode bytes as a base58 string. + * + * @param bytes - Uint8Array to encode + * @returns Base58 encoded string + * + * @example + * ```ts + * const address = encodeBase58(pubkeyBytes); + * // => 'So11111111111111111111111111111111111111112' + * ``` + */ +export function encodeBase58(bytes: Uint8Array): string { + if (bytes.length === 0) return ''; + + // Count leading zeros + let leadingZeros = 0; + for (const byte of bytes) { + if (byte === 0) leadingZeros++; + else break; + } + + // Convert to BigInt + let num = 0n; + for (const byte of bytes) { + num = num * 256n + BigInt(byte); + } + + // Convert to base58 + let result = ''; + while (num > 0n) { + const remainder = Number(num % 58n); + num = num / 58n; + result = BASE58_ALPHABET[remainder] + result; + } + + // Add leading '1's for zeros + return '1'.repeat(leadingZeros) + result; +} + +/** + * Convert a Titan pubkey (Uint8Array) to a Kit Address (base58 string). + * + * @param pubkey - Titan pubkey bytes (32 bytes) + * @returns Kit Address + * + * @example + * ```ts + * const kitAddress = titanPubkeyToAddress(titanPubkey); + * ``` + */ +export function titanPubkeyToAddress(pubkey: TitanPubkey): Address { + return address(encodeBase58(pubkey)); +} + +/** + * Map Titan account meta flags to Kit AccountRole. + * + * AccountRole values: + * - 0: READONLY + * - 1: WRITABLE + * - 2: READONLY_SIGNER + * - 3: WRITABLE_SIGNER + */ +function toAccountRole(meta: TitanAccountMeta): AccountRole { + if (meta.s && meta.w) { + return 3 as AccountRole; // WRITABLE_SIGNER + } + if (meta.s) { + return 2 as AccountRole; // READONLY_SIGNER + } + if (meta.w) { + return 1 as AccountRole; // WRITABLE + } + return 0 as AccountRole; // READONLY +} + +/** + * Convert a Titan account meta to Kit AccountMeta. + */ +function titanAccountMetaToKit(meta: TitanAccountMeta): AccountMeta { + return { + address: titanPubkeyToAddress(meta.p), + role: toAccountRole(meta), + }; +} + +/** + * Convert a Titan instruction to a Kit Instruction. + * + * @param instruction - Titan instruction + * @returns Kit instruction + * + * @example + * ```ts + * const kitInstruction = titanInstructionToKit(titanInstruction); + * ``` + */ +export function titanInstructionToKit(instruction: TitanInstruction): Instruction { + return { + programAddress: titanPubkeyToAddress(instruction.p), + accounts: instruction.a.map(titanAccountMetaToKit), + data: instruction.d, + }; +} + +/** + * Convert multiple Titan instructions to Kit instructions. + * + * @param instructions - Array of Titan instructions + * @returns Array of Kit instructions + */ +export function titanInstructionsToKit(instructions: TitanInstruction[]): Instruction[] { + return instructions.map(titanInstructionToKit); +} + +/** + * Convert Titan pubkey array to Kit Address array. + * + * @param pubkeys - Array of Titan pubkeys + * @returns Array of Kit addresses + */ +export function titanPubkeysToAddresses(pubkeys: TitanPubkey[]): Address[] { + return pubkeys.map(titanPubkeyToAddress); +} diff --git a/packages/actions-v2/src/titan/index.ts b/packages/actions-v2/src/titan/index.ts new file mode 100644 index 0000000..25e2d1d --- /dev/null +++ b/packages/actions-v2/src/titan/index.ts @@ -0,0 +1,32 @@ +/** + * Titan DEX aggregator integration. + * + * Provides InstructionPlan factories for swaps via Titan's API. + * + * @packageDocumentation + */ + +// Client +export { createTitanClient, type TitanClient, type TitanClientConfig } from './client.js'; + +// Types +export type { + SwapQuoteParams, + SwapQuotes, + SwapRoute, + RoutePlanStep, + SwapMode, +} from './types.js'; + +// Plan builders +export { + getTitanSwapPlan, + getTitanSwapQuote, + selectTitanRoute, + getTitanSwapInstructionPlanFromRoute, + type TitanSwapPlanResult, + type TitanSwapPlanOptions, +} from './plan-swap.js'; + +// Conversion utilities +export { titanInstructionToKit, titanPubkeyToAddress, encodeBase58 } from './convert.js'; diff --git a/packages/actions-v2/src/titan/plan-swap.ts b/packages/actions-v2/src/titan/plan-swap.ts new file mode 100644 index 0000000..3847cd0 --- /dev/null +++ b/packages/actions-v2/src/titan/plan-swap.ts @@ -0,0 +1,332 @@ +/** + * Titan swap plan builder. + * + * Converts Titan quotes to composable Kit InstructionPlans. + * + * @packageDocumentation + */ + +import type { Address } from '@solana/addresses'; +import { + type InstructionPlan, + sequentialInstructionPlan, + singleInstructionPlan, +} from '@solana/instruction-plans'; +import { createTitanClient, type TitanClient, type TitanClientConfig } from './client.js'; +import type { + SwapQuoteParams, + SwapQuotes, + SwapRoute, + SwapMode, +} from './types.js'; +import { + titanInstructionsToKit, + titanPubkeysToAddresses, +} from './convert.js'; + +/** + * Result of selecting a route from quotes. + */ +export interface SelectedRoute { + /** Provider ID that offered this route */ + providerId: string; + /** The selected route */ + route: SwapRoute; +} + +/** + * Result of building a Titan swap plan. + */ +export interface TitanSwapPlanResult { + /** The instruction plan for the swap */ + plan: InstructionPlan; + /** Address lookup tables used by the swap (pass to executePlan) */ + lookupTableAddresses: Address[]; + /** Quote ID for reference */ + quoteId: string; + /** Provider ID that offered the selected route */ + providerId: string; + /** The selected route with full details */ + route: SwapRoute; + /** Quote metadata */ + quote: { + /** Input amount in smallest units */ + inputAmount: bigint; + /** Expected output amount in smallest units */ + outputAmount: bigint; + /** Swap mode used */ + swapMode: SwapMode; + }; +} + +/** + * Options for building a Titan swap plan. + */ +export interface TitanSwapPlanOptions { + /** Titan client instance (creates new one if not provided) */ + client?: TitanClient; + /** Titan client config (used if client not provided) */ + clientConfig?: TitanClientConfig; + /** Specific provider ID to use (default: best available) */ + providerId?: string; +} + +/** + * Get a swap quote from Titan. + * + * @param client - Titan client + * @param params - Quote parameters + * @returns Swap quotes from all providers + * + * @example + * ```ts + * const client = createTitanClient(); + * const quotes = await getTitanSwapQuote(client, { + * swap: { + * inputMint: 'So11111111111111111111111111111111111111112', + * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * amount: 1_000_000_000n, + * }, + * transaction: { + * userPublicKey: wallet.publicKey, + * }, + * }); + * ``` + */ +export async function getTitanSwapQuote( + client: TitanClient, + params: SwapQuoteParams, +): Promise { + return client.getSwapQuote(params); +} + +/** + * Select the best route from a set of quotes. + * + * Selection logic: + * - ExactIn: choose the route with maximum outAmount + * - ExactOut: choose the route with minimum inAmount + * + * @param quotes - Swap quotes from Titan + * @param options - Selection options + * @returns Selected route with provider ID + * + * @example + * ```ts + * const { providerId, route } = selectTitanRoute(quotes); + * console.log(`Best route from ${providerId}: ${route.outAmount}`); + * ``` + */ +export function selectTitanRoute( + quotes: SwapQuotes, + options?: { providerId?: string }, +): SelectedRoute { + const providerIds = Object.keys(quotes.quotes); + + if (providerIds.length === 0) { + throw new NoRoutesError(quotes.id); + } + + // If specific provider requested, use it + if (options?.providerId) { + const route = quotes.quotes[options.providerId]; + if (!route) { + throw new ProviderNotFoundError(options.providerId, providerIds); + } + return { providerId: options.providerId, route }; + } + + // Select best route based on swap mode + const isExactIn = quotes.swapMode === 'ExactIn'; + + let bestProviderId = providerIds[0]; + let bestRoute = quotes.quotes[bestProviderId]; + + for (const providerId of providerIds.slice(1)) { + const route = quotes.quotes[providerId]; + + if (isExactIn) { + // ExactIn: maximize output + if (route.outAmount > bestRoute.outAmount) { + bestProviderId = providerId; + bestRoute = route; + } + } else { + // ExactOut: minimize input + if (route.inAmount < bestRoute.inAmount) { + bestProviderId = providerId; + bestRoute = route; + } + } + } + + return { providerId: bestProviderId, route: bestRoute }; +} + +/** + * Build an InstructionPlan from a Titan swap route. + * + * @param route - Selected swap route + * @returns Kit InstructionPlan + * + * @example + * ```ts + * const plan = getTitanSwapInstructionPlanFromRoute(route); + * await executePlan(plan, { rpc, rpcSubscriptions, signer }); + * ``` + */ +export function getTitanSwapInstructionPlanFromRoute(route: SwapRoute): InstructionPlan { + const instructions = titanInstructionsToKit(route.instructions); + + if (instructions.length === 0) { + throw new NoInstructionsError(); + } + + if (instructions.length === 1) { + return singleInstructionPlan(instructions[0]); + } + + // Multiple instructions are sequential (setup → swap → cleanup) + return sequentialInstructionPlan( + instructions.map(ix => singleInstructionPlan(ix)), + ); +} + +/** + * Get a complete Titan swap plan from quote parameters. + * + * This is the main entry point that combines: + * 1. Fetching a quote from Titan + * 2. Selecting the best route + * 3. Building an InstructionPlan + * 4. Extracting ALT addresses for executePlan + * + * @param params - Quote parameters + * @param options - Plan building options + * @returns Complete swap plan result + * + * @example + * ```ts + * import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; + * import { executePlan } from '@pipeit/core'; + * + * const { plan, lookupTableAddresses, quote } = await getTitanSwapPlan({ + * swap: { + * inputMint: 'So11111111111111111111111111111111111111112', + * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * amount: 1_000_000_000n, // 1 SOL + * slippageBps: 50, // 0.5% + * }, + * transaction: { + * userPublicKey: signer.address, + * createOutputTokenAccount: true, + * }, + * }); + * + * console.log(`Swapping for ~${quote.outputAmount} output tokens`); + * + * await executePlan(plan, { + * rpc, + * rpcSubscriptions, + * signer, + * lookupTableAddresses, + * }); + * ``` + * + * @example Composing with other plans + * ```ts + * import { sequentialInstructionPlan } from '@solana/instruction-plans'; + * + * const swapResult = await getTitanSwapPlan({ ... }); + * const transferPlan = singleInstructionPlan(transferInstruction); + * + * const combinedPlan = sequentialInstructionPlan([ + * swapResult.plan, + * transferPlan, + * ]); + * + * // Combine ALTs from all sources + * const allAltAddresses = [ + * ...swapResult.lookupTableAddresses, + * ...otherAltAddresses, + * ]; + * + * await executePlan(combinedPlan, { + * rpc, + * rpcSubscriptions, + * signer, + * lookupTableAddresses: allAltAddresses, + * }); + * ``` + */ +export async function getTitanSwapPlan( + params: SwapQuoteParams, + options?: TitanSwapPlanOptions, +): Promise { + // Get or create client + const client = options?.client ?? createTitanClient(options?.clientConfig); + + // Fetch quote + const quotes = await getTitanSwapQuote(client, params); + + // Select best route + const selectOptions = options?.providerId ? { providerId: options.providerId } : undefined; + const { providerId, route } = selectTitanRoute(quotes, selectOptions); + + // Build instruction plan + const plan = getTitanSwapInstructionPlanFromRoute(route); + + // Extract ALT addresses + const lookupTableAddresses = titanPubkeysToAddresses(route.addressLookupTables); + + return { + plan, + lookupTableAddresses, + quoteId: quotes.id, + providerId, + route, + quote: { + inputAmount: route.inAmount, + outputAmount: route.outAmount, + swapMode: quotes.swapMode, + }, + }; +} + +/** + * Error thrown when no routes are available for a swap. + */ +export class NoRoutesError extends Error { + readonly quoteId: string; + + constructor(quoteId: string) { + super(`No routes available for quote ${quoteId}`); + this.name = 'NoRoutesError'; + this.quoteId = quoteId; + } +} + +/** + * Error thrown when a requested provider is not found. + */ +export class ProviderNotFoundError extends Error { + readonly providerId: string; + readonly availableProviders: string[]; + + constructor(providerId: string, availableProviders: string[]) { + super(`Provider ${providerId} not found. Available: ${availableProviders.join(', ')}`); + this.name = 'ProviderNotFoundError'; + this.providerId = providerId; + this.availableProviders = availableProviders; + } +} + +/** + * Error thrown when a route has no instructions. + */ +export class NoInstructionsError extends Error { + constructor() { + super('Route has no instructions. The route may only provide a pre-built transaction.'); + this.name = 'NoInstructionsError'; + } +} diff --git a/packages/actions-v2/src/titan/types.ts b/packages/actions-v2/src/titan/types.ts new file mode 100644 index 0000000..db032e8 --- /dev/null +++ b/packages/actions-v2/src/titan/types.ts @@ -0,0 +1,279 @@ +/** + * Titan API types. + * + * These types match the Titan API v1 wire format after MessagePack decoding. + * All u64 values are decoded as bigint using useBigInt64: true. + * + * @packageDocumentation + */ + +/** + * Swap mode - how the amount should be interpreted. + */ +export type SwapMode = 'ExactIn' | 'ExactOut'; + +/** + * Titan wire format for a public key (32 bytes). + */ +export type TitanPubkey = Uint8Array; + +/** + * Titan wire format for account metadata. + * Uses short field names to save space. + */ +export interface TitanAccountMeta { + /** Public key */ + p: TitanPubkey; + /** Is signer */ + s: boolean; + /** Is writable */ + w: boolean; +} + +/** + * Titan wire format for an instruction. + * Uses short field names to save space. + */ +export interface TitanInstruction { + /** Program ID */ + p: TitanPubkey; + /** Accounts */ + a: TitanAccountMeta[]; + /** Data */ + d: Uint8Array; +} + +/** + * Parameters for swap portion of a quote request. + */ +export interface SwapParams { + /** Address of input mint for the swap */ + inputMint: TitanPubkey | string; + /** Address of output mint for the swap */ + outputMint: TitanPubkey | string; + /** Raw number of tokens to swap, not scaled by decimals */ + amount: bigint; + /** Swap mode (ExactIn or ExactOut), defaults to ExactIn */ + swapMode?: SwapMode; + /** Allowed slippage in basis points */ + slippageBps?: number; + /** If set, constrain quotes to the given set of DEXes */ + dexes?: string[]; + /** If set, exclude the following DEXes */ + excludeDexes?: string[]; + /** If true, only direct routes between input and output */ + onlyDirectRoutes?: boolean; + /** If set, limit quotes to the given set of provider IDs */ + providers?: string[]; +} + +/** + * Parameters for transaction generation. + */ +export interface TransactionParams { + /** Public key of the user requesting the swap */ + userPublicKey: TitanPubkey | string; + /** If true, close the input token account as part of the transaction */ + closeInputTokenAccount?: boolean; + /** If true, an idempotent ATA will be created for output token */ + createOutputTokenAccount?: boolean; + /** The address of a token account for the output mint to collect fees */ + feeAccount?: TitanPubkey | string; + /** Fee amount to take, in basis points */ + feeBps?: number; + /** Whether the fee should be taken from input mint */ + feeFromInputMint?: boolean; + /** Address of token account for swap output */ + outputAccount?: TitanPubkey | string; +} + +/** + * Combined swap quote request parameters. + */ +export interface SwapQuoteParams { + /** Swap parameters */ + swap: SwapParams; + /** Transaction parameters */ + transaction: TransactionParams; +} + +/** + * A single step in a swap route. + */ +export interface RoutePlanStep { + /** Which AMM is being executed on at this step */ + ammKey: TitanPubkey; + /** Label for the protocol being used */ + label: string; + /** Address of the input mint for this swap */ + inputMint: TitanPubkey; + /** Address of the output mint for this swap */ + outputMint: TitanPubkey; + /** How many input tokens are expected to go through this step */ + inAmount: bigint; + /** How many output tokens are expected to come out of this step */ + outAmount: bigint; + /** Proportion in parts per billion allocated to this pool */ + allocPpb: number; + /** Address of the mint in which the fee is charged */ + feeMint?: TitanPubkey; + /** The amount of tokens charged as a fee for this swap */ + feeAmount?: bigint; + /** Context slot for the pool data, if known */ + contextSlot?: bigint; +} + +/** + * Platform fee information. + */ +export interface PlatformFee { + /** Amount of tokens taken as a fee */ + amount: bigint; + /** Fee percentage, in basis points */ + fee_bps: number; +} + +/** + * A complete swap route from a provider. + */ +export interface SwapRoute { + /** How many input tokens are expected */ + inAmount: bigint; + /** How many output tokens are expected */ + outAmount: bigint; + /** Amount of slippage incurred, in basis points */ + slippageBps: number; + /** Platform fee information */ + platformFee?: PlatformFee; + /** Steps that comprise this route */ + steps: RoutePlanStep[]; + /** Instructions needed to execute the route (may be empty if transaction provided) */ + instructions: TitanInstruction[]; + /** Address lookup tables necessary to load */ + addressLookupTables: TitanPubkey[]; + /** Context slot for the route */ + contextSlot?: bigint; + /** Amount of time taken to generate the quote in nanoseconds */ + timeTakenNs?: bigint; + /** If this route expires, the time at which it expires (millisecond UNIX timestamp) */ + expiresAtMs?: bigint; + /** If this route expires by slot, the last valid slot */ + expiresAfterSlot?: bigint; + /** Number of compute units expected */ + computeUnits?: bigint; + /** Recommended compute units for safe execution */ + computeUnitsSafe?: bigint; + /** Transaction for the user to sign, if instructions not provided */ + transaction?: Uint8Array; + /** Provider-specific reference ID for this quote */ + referenceId?: string; +} + +/** + * A set of quotes for a swap transaction. + */ +export interface SwapQuotes { + /** Unique identifier for the quote */ + id: string; + /** Address of the input mint */ + inputMint: TitanPubkey; + /** Address of the output mint */ + outputMint: TitanPubkey; + /** Swap mode used for the quotes */ + swapMode: SwapMode; + /** Amount used for the quotes */ + amount: bigint; + /** Mapping of provider identifier to their quoted route */ + quotes: Record; +} + +/** + * Version information for the server. + */ +export interface VersionInfo { + major: number; + minor: number; + patch: number; +} + +/** + * Server settings bounds. + */ +export interface BoundedValueWithDefault { + min: T; + max: T; + default: T; +} + +/** + * Quote update settings. + */ +export interface QuoteUpdateSettings { + intervalMs: BoundedValueWithDefault; + numQuotes: BoundedValueWithDefault; +} + +/** + * Swap settings. + */ +export interface SwapSettings { + slippageBps: BoundedValueWithDefault; + onlyDirectRoutes: boolean; + addSizeConstraint: boolean; +} + +/** + * Transaction settings. + */ +export interface TransactionSettings { + closeInputTokenAccount: boolean; + createOutputTokenAccount: boolean; +} + +/** + * Connection settings. + */ +export interface ConnectionSettings { + concurrentStreams: number; +} + +/** + * Server settings. + */ +export interface ServerSettings { + quoteUpdate: QuoteUpdateSettings; + swap: SwapSettings; + transaction: TransactionSettings; + connection: ConnectionSettings; +} + +/** + * Server info response. + */ +export interface ServerInfo { + protocolVersion: VersionInfo; + settings: ServerSettings; +} + +/** + * Provider kind. + */ +export type ProviderKind = 'DexAggregator' | 'RFQ'; + +/** + * Provider information. + */ +export interface ProviderInfo { + id: string; + name: string; + kind: ProviderKind; + iconUri48?: string; +} + +/** + * Venue information. + */ +export interface VenueInfo { + labels: string[]; + programIds?: TitanPubkey[]; +} diff --git a/packages/actions-v2/tsconfig.json b/packages/actions-v2/tsconfig.json new file mode 100644 index 0000000..5544585 --- /dev/null +++ b/packages/actions-v2/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "incremental": false, + "composite": false, + "module": "ES2022", + "moduleResolution": "bundler" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/actions-v2/tsup.config.ts b/packages/actions-v2/tsup.config.ts new file mode 100644 index 0000000..1916781 --- /dev/null +++ b/packages/actions-v2/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig(options => ({ + entry: { + index: 'src/index.ts', + 'titan/index': 'src/titan/index.ts', + }, + format: ['cjs', 'esm'], + dts: options.watch + ? false + : { + resolve: true, + }, + tsconfig: './tsconfig.json', + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + external: [ + '@pipeit/core', + '@solana/kit', + '@solana/addresses', + '@solana/instruction-plans', + '@solana/instructions', + '@solana/rpc', + '@solana/rpc-subscriptions', + '@solana/signers', + '@solana/transactions', + ], +})); diff --git a/packages/core/src/plans/__tests__/execute-plan.test.ts b/packages/core/src/plans/__tests__/execute-plan.test.ts index bf083db..9c9be8f 100644 --- a/packages/core/src/plans/__tests__/execute-plan.test.ts +++ b/packages/core/src/plans/__tests__/execute-plan.test.ts @@ -133,4 +133,29 @@ describe('ALT compression integration', () => { expect(altFlow.step2).toContain('compressTransactionMessage'); expect(altFlow.step3).toContain('estimateAndSetCULimit'); }); + + it('should document planner-time ALT compression for optimal packing', () => { + // This test documents how ALT compression is applied during transaction planning: + // 1. When lookup table data is available, onTransactionMessageUpdated hook is configured + // 2. The hook applies compressTransactionMessage after each instruction is added + // 3. This allows the planner's size checks to account for ALT-compressed size + // 4. Result: more instructions can fit per transaction when ALTs reduce account references + // + // Without planner-time compression: + // - Planner uses uncompressed size for packing decisions + // - May create more transactions than necessary + // + // With planner-time compression: + // - Planner uses compressed size for packing decisions + // - Optimal transaction packing when ALTs are provided + const plannerAltFlow = { + hook: 'onTransactionMessageUpdated', + action: 'compressTransactionMessage(message, lookupTableData)', + benefit: 'Planner size checks use compressed size, allowing optimal packing', + }; + + expect(plannerAltFlow.hook).toBe('onTransactionMessageUpdated'); + expect(plannerAltFlow.action).toContain('compressTransactionMessage'); + expect(plannerAltFlow.benefit).toContain('optimal packing'); + }); }); diff --git a/packages/core/src/plans/execute-plan.ts b/packages/core/src/plans/execute-plan.ts index 59a529d..af0af3e 100644 --- a/packages/core/src/plans/execute-plan.ts +++ b/packages/core/src/plans/execute-plan.ts @@ -30,6 +30,10 @@ import { signTransactionMessageWithSigners, sendAndConfirmTransactionFactory, } from '@solana/kit'; +import type { + BaseTransactionMessage, + TransactionMessageWithFeePayer, +} from '@solana/transaction-messages'; import { fillProvisorySetComputeUnitLimitInstruction, estimateComputeUnitLimitFactory, @@ -211,7 +215,7 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf // Resolve lookup table data once (prefetched or fetched from addresses) const lookupTableData = await resolveLookupTableData(config); - // Create transaction planner with provisory CU instruction + // Create transaction planner with provisory CU instruction and optional ALT compression hook const planner = createTransactionPlanner({ createTransactionMessage: async () => { // Fetch latest blockhash @@ -225,6 +229,13 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf tx => fillProvisorySetComputeUnitLimitInstruction(tx), ); }, + // Apply ALT compression during planning so size checks account for compressed size. + // This allows the planner to pack more instructions per transaction when ALTs are used. + ...(lookupTableData && { + onTransactionMessageUpdated: ( + message: TMessage, + ): TMessage => compressTransactionMessage(message, lookupTableData), + }), }); // Plan the instructions into transactions diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4003588..bb79e75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,12 +38,15 @@ importers: '@pipeit/actions': specifier: workspace:* version: link:../../packages/actions + '@pipeit/actions-v2': + specifier: workspace:* + version: link:../../packages/actions-v2 '@pipeit/core': - specifier: ^0.2.5 - version: 0.2.5(59e1c826774811eacd0a7dd223ad1500) + specifier: workspace:* + version: link:../../packages/core '@pipeit/fastlane': - specifier: ^0.1.3 - version: 0.1.3 + specifier: workspace:* + version: link:../../packages/fastlane '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -217,6 +220,52 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@24.10.0)(@vitest/ui@3.2.4)(happy-dom@20.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + packages/actions-v2: + dependencies: + '@msgpack/msgpack': + specifier: ^3.0.0 + version: 3.1.2 + devDependencies: + '@pipeit/core': + specifier: workspace:* + version: link:../core + '@solana/addresses': + specifier: '*' + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/instruction-plans': + specifier: '*' + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/instructions': + specifier: '*' + version: 5.0.0(typescript@5.9.3) + '@solana/kit': + specifier: ^5.0.0 + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc': + specifier: '*' + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-subscriptions': + specifier: '*' + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/signers': + specifier: '*' + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/transactions': + specifier: '*' + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@types/node': + specifier: ^24 + version: 24.10.0 + tsup: + specifier: ^8.5.0 + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.10.0)(@vitest/ui@3.2.4)(happy-dom@20.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + packages/core: devDependencies: '@solana-program/compute-budget': @@ -1018,6 +1067,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@msgpack/msgpack@3.1.2': + resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==} + engines: {node: '>= 18'} + '@nanostores/persistent@1.1.0': resolution: {integrity: sha512-e6vfv7H99VkCfSoNTR/qNVMj6vXwWcsEL+LCQQamej5GK9iDefKxPCJjdOpBi1p4lNCFIQ+9VjYF1spvvc2p6A==} engines: {node: ^20.0.0 || >=22.0.0} @@ -1126,28 +1179,6 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' - '@pipeit/core@0.2.5': - resolution: {integrity: sha512-wsl4tB7+2l2eDc5ZUx42Y5bXyIMdkbG4b+sZU+JFy20MwIuEwkNdJNVgoq+Eb/dbM3NrglvP7z1wd8+QPTIWNg==} - peerDependencies: - '@solana/addresses': '*' - '@solana/codecs-strings': '*' - '@solana/errors': '*' - '@solana/functional': '*' - '@solana/instruction-plans': '*' - '@solana/instructions': '*' - '@solana/kit': ^5.0.0 - '@solana/programs': '*' - '@solana/rpc': '*' - '@solana/rpc-subscriptions': '*' - '@solana/rpc-types': '*' - '@solana/signers': '*' - '@solana/transaction-messages': '*' - '@solana/transactions': '*' - - '@pipeit/fastlane@0.1.3': - resolution: {integrity: sha512-jg4Ydzu0NA9RSJivEVV/jUdC9MNj7ebQ6od2/ouTXu2JKGEju1K2TBh+5JKbzPhHcYLSzH30b1v1gL+CIwj1aA==} - engines: {node: '>= 18'} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -6116,6 +6147,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@msgpack/msgpack@3.1.2': {} + '@nanostores/persistent@1.1.0(nanostores@1.0.1)': dependencies: nanostores: 1.0.1 @@ -6190,25 +6223,6 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@pipeit/core@0.2.5(59e1c826774811eacd0a7dd223ad1500)': - dependencies: - '@solana/addresses': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/codecs-strings': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/errors': 5.0.0(typescript@5.9.3) - '@solana/functional': 5.0.0(typescript@5.9.3) - '@solana/instruction-plans': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/instructions': 5.0.0(typescript@5.9.3) - '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana/programs': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/signers': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transaction-messages': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transactions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - - '@pipeit/fastlane@0.1.3': {} - '@pkgjs/parseargs@0.11.0': optional: true From a503b0ffb4cd45ceae13b5af79c20a216ed0e447 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 24 Dec 2025 14:47:48 -0800 Subject: [PATCH 05/12] chore: working titan swap in new v2 package --- .../next-js/app/api/titan/[...path]/route.ts | 84 ++++++++++++++++ .../pipeline/examples/titan-swap.tsx | 96 ++++++++++++++----- package.json | 2 +- packages/actions-v2/README.md | 7 +- packages/actions-v2/src/titan/client.ts | 60 ++++++++++-- packages/actions-v2/src/titan/index.ts | 12 ++- packages/actions-v2/tsup.config.ts | 8 +- packages/actions/tsup.config.ts | 8 +- packages/core/src/plans/execute-plan.ts | 13 ++- packages/core/tsup.config.ts | 8 +- 10 files changed, 244 insertions(+), 54 deletions(-) create mode 100644 examples/next-js/app/api/titan/[...path]/route.ts diff --git a/examples/next-js/app/api/titan/[...path]/route.ts b/examples/next-js/app/api/titan/[...path]/route.ts new file mode 100644 index 0000000..a06d240 --- /dev/null +++ b/examples/next-js/app/api/titan/[...path]/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Demo endpoints by region +const TITAN_DEMO_URLS: Record = { + us1: 'https://us1.api.demo.titan.exchange', + jp1: 'https://jp1.api.demo.titan.exchange', + de1: 'https://de1.api.demo.titan.exchange', +}; + +/** + * Next.js API route proxy for Titan API. + * Proxies all requests to Titan's API to work around CORS restrictions. + * + * Usage: /api/titan/api/v1/quote/swap?inputMint=...®ion=us1 + * → proxied to https://us1.api.demo.titan.exchange/api/v1/quote/swap?inputMint=... + * + * Environment: + * - TITAN_API_TOKEN: Your Titan API JWT token (required for demo endpoints) + * + * Query params: + * - region: 'us1' | 'jp1' | 'de1' (default: 'us1') - selects Titan endpoint + * - ...all other params forwarded to Titan + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + const titanPath = '/' + path.join('/'); + const searchParams = request.nextUrl.searchParams; + + // Extract region (used for routing, not forwarded) + const region = searchParams.get('region') || 'us1'; + const baseUrl = TITAN_DEMO_URLS[region] || TITAN_DEMO_URLS.us1; + + // Build params to forward (exclude 'region') + const forwardParams = new URLSearchParams(); + searchParams.forEach((value, key) => { + if (key !== 'region') { + forwardParams.set(key, value); + } + }); + + const url = forwardParams.toString() + ? `${baseUrl}${titanPath}?${forwardParams}` + : `${baseUrl}${titanPath}`; + + try { + // Use server-side token from env, or forward client header as fallback + const authToken = process.env.TITAN_API_TOKEN || request.headers.get('Authorization')?.replace('Bearer ', ''); + const headers: Record = { + Accept: 'application/msgpack', + }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(url, { headers }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + console.error(`Titan API error (${response.status}) at ${titanPath}:`, errorText); + return new NextResponse(errorText, { + status: response.status, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + // Return MessagePack binary response as-is + const buffer = await response.arrayBuffer(); + return new NextResponse(buffer, { + status: 200, + headers: { + 'Content-Type': 'application/msgpack', + }, + }); + } catch (error) { + console.error('Titan API proxy error:', error); + return new NextResponse( + error instanceof Error ? error.message : 'Failed to proxy Titan request', + { status: 500, headers: { 'Content-Type': 'text/plain' } }, + ); + } +} diff --git a/examples/next-js/components/pipeline/examples/titan-swap.tsx b/examples/next-js/components/pipeline/examples/titan-swap.tsx index e2c68f8..619da74 100644 --- a/examples/next-js/components/pipeline/examples/titan-swap.tsx +++ b/examples/next-js/components/pipeline/examples/titan-swap.tsx @@ -1,14 +1,18 @@ 'use client'; import { useMemo } from 'react'; -import { executePlan, createFlow, type FlowConfig } from '@pipeit/core'; +import { executePlan, createFlow, type FlowConfig, type TransactionPlanResult } from '@pipeit/core'; import { VisualPipeline } from '@/lib/visual-pipeline'; import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; +import { getSignatureFromTransaction } from '@solana/kit'; // Token addresses const SOL_MINT = 'So11111111111111111111111111111111111111112'; const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +// Proxy through Next.js API route to avoid CORS (add ?region=jp1 or ?region=de1 to switch) +const TITAN_PROXY_URL = '/api/titan'; + /** * Example: Titan Swap using @pipeit/actions-v2 * @@ -21,18 +25,23 @@ export function useTitanSwapPipeline() { createFlow(config).transaction('titan-swap', async ctx => { // Get swap plan from Titan // This returns an InstructionPlan + ALT addresses - const swapResult = await getTitanSwapPlan({ - swap: { - inputMint: SOL_MINT, - outputMint: USDC_MINT, - amount: 10_000_000n, // 0.01 SOL - slippageBps: 50, + const swapResult = await getTitanSwapPlan( + { + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 10_000_000n, // 0.01 SOL + slippageBps: 50, + }, + transaction: { + userPublicKey: ctx.signer.address, + createOutputTokenAccount: true, + }, }, - transaction: { - userPublicKey: ctx.signer.address, - createOutputTokenAccount: true, + { + clientConfig: { baseUrl: TITAN_PROXY_URL }, }, - }); + ); console.log( `Titan quote: ${swapResult.quote.inputAmount} -> ${swapResult.quote.outputAmount} via ${swapResult.providerId}`, @@ -40,7 +49,7 @@ export function useTitanSwapPipeline() { // Execute the plan with ALT support // The ALTs enable optimal transaction packing and compression - const result = await executePlan(swapResult.plan, { + const transactionPlanResult = await executePlan(swapResult.plan, { rpc: ctx.rpc as any, rpcSubscriptions: ctx.rpcSubscriptions as any, signer: ctx.signer, @@ -49,8 +58,11 @@ export function useTitanSwapPipeline() { lookupTableAddresses: swapResult.lookupTableAddresses, }); + const signatures = getSignaturesFromTransactionPlanResult(transactionPlanResult); + const signature = signatures.at(-1) ?? ''; + return { - signature: 'titan-swap-executed', + signature, quote: swapResult.quote, providerId: swapResult.providerId, }; @@ -62,33 +74,53 @@ export function useTitanSwapPipeline() { return visualPipeline; } -export const titanSwapCode = `import { getTitanSwapPlan } from '@pipeit/actions-v2/titan' +export const titanSwapCode = `import { getTitanSwapPlan, TITAN_DEMO_BASE_URLS } from '@pipeit/actions-v2/titan' import { executePlan } from '@pipeit/core' +import { getSignatureFromTransaction } from '@solana/kit' // Token addresses const SOL = 'So11111111111111111111111111111111111111112' const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' +// Server-side: use Titan directly +// const titanBaseUrl = TITAN_DEMO_BASE_URLS.us1 +// Browser: proxy through your API to avoid CORS +const titanBaseUrl = '/api/titan' + // Get a swap plan from Titan // Returns an InstructionPlan + ALT addresses for compression -const { plan, lookupTableAddresses, quote, providerId } = await getTitanSwapPlan({ - swap: { - inputMint: SOL, - outputMint: USDC, - amount: 10_000_000n, // 0.01 SOL in lamports - slippageBps: 50, // 0.5% slippage tolerance +const { plan, lookupTableAddresses, quote, providerId } = await getTitanSwapPlan( + { + swap: { + inputMint: SOL, + outputMint: USDC, + amount: 10_000_000n, // 0.01 SOL in lamports + slippageBps: 50, // 0.5% slippage tolerance + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + }, }, - transaction: { - userPublicKey: signer.address, - createOutputTokenAccount: true, + { + clientConfig: { baseUrl: titanBaseUrl }, }, -}) +) console.log(\`Swapping for ~\${quote.outputAmount} USDC via \${providerId}\`) +function getSignaturesFromTransactionPlanResult(result) { + if (result.kind === 'single') { + return result.status.kind === 'successful' + ? [getSignatureFromTransaction(result.status.transaction)] + : [] + } + return result.plans.flatMap(getSignaturesFromTransactionPlanResult) +} + // Execute using Kit's InstructionPlan system with ALT support // ALTs enable optimal transaction packing and compression -const result = await executePlan(plan, { +const transactionPlanResult = await executePlan(plan, { rpc, rpcSubscriptions, signer, @@ -96,4 +128,16 @@ const result = await executePlan(plan, { lookupTableAddresses, // Pass ALTs for compression }) -console.log('Swap executed:', result.type)`; +const signatures = getSignaturesFromTransactionPlanResult(transactionPlanResult) +console.log('Swap executed:', signatures[signatures.length - 1])`; + +function getSignaturesFromTransactionPlanResult( + result: TransactionPlanResult, +): string[] { + if (result.kind === 'single') { + if (result.status.kind !== 'successful') return []; + return [getSignatureFromTransaction(result.status.transaction)]; + } + + return result.plans.flatMap(getSignaturesFromTransactionPlanResult); +} diff --git a/package.json b/package.json index ac0bec8..9821b12 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@pipeit/monorepo", "private": true, "type": "module", - "packageManager": "pnpm@10.26.0", + "packageManager": "pnpm@10.26.2", "scripts": { "build": "turbo run build", "dev": "turbo run dev", diff --git a/packages/actions-v2/README.md b/packages/actions-v2/README.md index 855189f..287700b 100644 --- a/packages/actions-v2/README.md +++ b/packages/actions-v2/README.md @@ -87,6 +87,7 @@ For more control, you can use the individual functions: ```typescript import { createTitanClient, + TITAN_DEMO_BASE_URLS, getTitanSwapQuote, selectTitanRoute, getTitanSwapInstructionPlanFromRoute, @@ -94,7 +95,11 @@ import { // Create a client const client = createTitanClient({ - baseUrl: 'https://api.titan.ag/api/v1', + // Option A: pick a demo region (us1 | jp1 | de1) + demoRegion: 'us1', + // Option B: specify a full base URL (demo or production) + // baseUrl: TITAN_DEMO_BASE_URLS.jp1, + // baseUrl: 'https://api.titan.ag/api/v1', authToken: 'optional-jwt-for-fees', }); diff --git a/packages/actions-v2/src/titan/client.ts b/packages/actions-v2/src/titan/client.ts index 67847d3..58bf46f 100644 --- a/packages/actions-v2/src/titan/client.ts +++ b/packages/actions-v2/src/titan/client.ts @@ -17,12 +17,54 @@ import type { } from './types.js'; import { encodeBase58 } from './convert.js'; +/** + * Demo REST API base URLs by region. + */ +export const TITAN_DEMO_BASE_URLS = { + /** Ohio, USA */ + us1: 'https://us1.api.demo.titan.exchange', + /** Tokyo, Japan */ + jp1: 'https://jp1.api.demo.titan.exchange', + /** Frankfurt, Germany */ + de1: 'https://de1.api.demo.titan.exchange', +} as const; + +export type TitanDemoRegion = keyof typeof TITAN_DEMO_BASE_URLS; + +function normalizeBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return trimmed; + } + + // Relative paths (e.g. /api/titan for proxy) should stay as-is + const isRelative = trimmed.startsWith('/'); + const hasProtocol = /^https?:\/\//i.test(trimmed); + const normalized = isRelative || hasProtocol ? trimmed : `https://${trimmed}`; + + // Normalize trailing slashes so we can safely join paths. + return normalized.replace(/\/+$/, ''); +} + +function joinUrl(baseUrl: string, path: string): string { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const normalizedPath = path.replace(/^\/+/, ''); + return `${normalizedBaseUrl}/${normalizedPath}`; +} + /** * Configuration for the Titan client. */ export interface TitanClientConfig { - /** REST API base URL (default: https://api.titan.ag/api/v1) */ + /** + * REST API base URL. + * + * If not provided, defaults to the demo endpoint for `demoRegion` (us1). + * You may pass a hostname without a protocol (https:// will be assumed). + */ baseUrl?: string; + /** Demo region to use when baseUrl is not provided (default: us1) */ + demoRegion?: TitanDemoRegion; /** Authentication token (optional, for fee collection) */ authToken?: string; /** Custom fetch implementation (for testing or environments without global fetch) */ @@ -74,16 +116,20 @@ function pubkeyToString(pubkey: TitanPubkey | string): string { */ export function createTitanClient(config: TitanClientConfig = {}): TitanClient { const { - baseUrl = 'https://api.titan.ag/api/v1', + baseUrl: baseUrlInput, + demoRegion = 'us1', authToken, fetch: customFetch = globalThis.fetch, } = config; + const baseUrl = normalizeBaseUrl(baseUrlInput ?? TITAN_DEMO_BASE_URLS[demoRegion]); + /** * Make a GET request to the Titan API. */ async function get(path: string, params?: URLSearchParams): Promise { - const url = params ? `${baseUrl}${path}?${params}` : `${baseUrl}${path}`; + const base = joinUrl(baseUrl, path); + const url = params ? `${base}?${params}` : base; const headers: Record = {}; if (authToken) { @@ -155,11 +201,11 @@ export function createTitanClient(config: TitanClientConfig = {}): TitanClient { urlParams.set('outputAccount', pubkeyToString(transaction.outputAccount)); } - return get('/quote/swap', urlParams); + return get('/api/v1/quote/swap', urlParams); }, async getInfo(): Promise { - return get('/info'); + return get('/api/v1/info'); }, async listProviders(includeIcons = false): Promise { @@ -167,7 +213,7 @@ export function createTitanClient(config: TitanClientConfig = {}): TitanClient { if (includeIcons) { params.set('includeIcons', 'true'); } - return get('/providers', params); + return get('/api/v1/providers', params); }, async getVenues(includeProgramIds = false): Promise { @@ -175,7 +221,7 @@ export function createTitanClient(config: TitanClientConfig = {}): TitanClient { if (includeProgramIds) { params.set('includeProgramIds', 'true'); } - return get('/venues', params); + return get('/api/v1/venues', params); }, }; } diff --git a/packages/actions-v2/src/titan/index.ts b/packages/actions-v2/src/titan/index.ts index 25e2d1d..c4e4ce2 100644 --- a/packages/actions-v2/src/titan/index.ts +++ b/packages/actions-v2/src/titan/index.ts @@ -7,7 +7,14 @@ */ // Client -export { createTitanClient, type TitanClient, type TitanClientConfig } from './client.js'; +export { + createTitanClient, + TITAN_DEMO_BASE_URLS, + TitanApiError, + type TitanClient, + type TitanClientConfig, + type TitanDemoRegion, +} from './client.js'; // Types export type { @@ -24,6 +31,9 @@ export { getTitanSwapQuote, selectTitanRoute, getTitanSwapInstructionPlanFromRoute, + NoInstructionsError, + NoRoutesError, + ProviderNotFoundError, type TitanSwapPlanResult, type TitanSwapPlanOptions, } from './plan-swap.js'; diff --git a/packages/actions-v2/tsup.config.ts b/packages/actions-v2/tsup.config.ts index 1916781..2cf951a 100644 --- a/packages/actions-v2/tsup.config.ts +++ b/packages/actions-v2/tsup.config.ts @@ -6,11 +6,9 @@ export default defineConfig(options => ({ 'titan/index': 'src/titan/index.ts', }, format: ['cjs', 'esm'], - dts: options.watch - ? false - : { - resolve: true, - }, + dts: { + resolve: true, + }, tsconfig: './tsconfig.json', splitting: false, sourcemap: true, diff --git a/packages/actions/tsup.config.ts b/packages/actions/tsup.config.ts index 5d5ef07..de8d373 100644 --- a/packages/actions/tsup.config.ts +++ b/packages/actions/tsup.config.ts @@ -7,11 +7,9 @@ export default defineConfig(options => ({ 'adapters/jupiter': 'src/adapters/jupiter.ts', }, format: ['cjs', 'esm'], - dts: options.watch - ? false - : { - resolve: true, - }, + dts: { + resolve: true, + }, tsconfig: './tsconfig.json', splitting: false, sourcemap: true, diff --git a/packages/core/src/plans/execute-plan.ts b/packages/core/src/plans/execute-plan.ts index af0af3e..ca30dbf 100644 --- a/packages/core/src/plans/execute-plan.ts +++ b/packages/core/src/plans/execute-plan.ts @@ -5,7 +5,6 @@ */ import type { Address } from '@solana/addresses'; -import type { TransactionSigner } from '@solana/signers'; import type { Rpc, GetLatestBlockhashApi, @@ -34,6 +33,7 @@ import type { BaseTransactionMessage, TransactionMessageWithFeePayer, } from '@solana/transaction-messages'; +import { addSignersToTransactionMessage, type TransactionSigner } from '@solana/signers'; import { fillProvisorySetComputeUnitLimitInstruction, estimateComputeUnitLimitFactory, @@ -227,6 +227,8 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf tx => setTransactionMessageFeePayer(signer.address, tx), tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), tx => fillProvisorySetComputeUnitLimitInstruction(tx), + // Attach signer so CU simulation + signing works (Kit requires this metadata) + tx => addSignersToTransactionMessage([signer], tx), ); }, // Apply ALT compression during planning so size checks account for compressed size. @@ -256,11 +258,16 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf ? compressTransactionMessage(message, lookupTableData) : message; + // Ensure signer is attached for CU simulation (and any later signing) + const messageWithSigners = addSignersToTransactionMessage([signer], compressedMessage); + // Estimate and update the provisory CU instruction with actual value - const estimatedMessage = await estimateAndSetCULimit(compressedMessage); + const estimatedMessage = await estimateAndSetCULimit(messageWithSigners); // Sign the transaction - const signedTransaction = await signTransactionMessageWithSigners(estimatedMessage); + const signedTransaction = await signTransactionMessageWithSigners( + addSignersToTransactionMessage([signer], estimatedMessage), + ); // Send and confirm - cast to expected type since we know it has blockhash lifetime await sendAndConfirm(signedTransaction as Parameters[0], { commitment }); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 15b4c22..ed24fed 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -3,11 +3,9 @@ import { defineConfig } from 'tsup'; export default defineConfig(options => ({ entry: ['src/index.ts', 'src/server/index.ts'], format: ['cjs', 'esm'], - dts: options.watch - ? false - : { - resolve: true, - }, + dts: { + resolve: true, + }, tsconfig: './tsconfig.json', splitting: false, sourcemap: true, From d4cdce7aaf4befc602ec56afabf7ae34857e8806 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 26 Dec 2025 18:16:29 -0800 Subject: [PATCH 06/12] feat: Add Jupiter Metis swap integration and utilities to new actions package Introduces a new Metis module in actions-v2 for Jupiter's swap API, including a REST client, type definitions, conversion utilities, and instruction plan builders. Updates Next.js Jupiter API routes and swap pipeline example to use the new Metis client and types. Adds comprehensive tests for Metis conversion and plan logic. Also updates dependencies in package.json files and exports the new module entry point. --- .../next-js/app/api/jupiter/quote/route.ts | 79 ++++- .../api/jupiter/swap-instructions/route.ts | 26 +- .../pipeline/examples/jupiter-swap.tsx | 209 +++++++++---- examples/next-js/package.json | 2 +- package.json | 2 +- packages/actions-v2/package.json | 7 + packages/actions-v2/src/index.ts | 4 + .../src/metis/__tests__/convert.test.ts | 166 ++++++++++ .../src/metis/__tests__/plan-swap.test.ts | 201 ++++++++++++ packages/actions-v2/src/metis/client.ts | 218 ++++++++++++++ packages/actions-v2/src/metis/convert.ts | 94 ++++++ packages/actions-v2/src/metis/index.ts | 53 ++++ packages/actions-v2/src/metis/plan-swap.ts | 285 ++++++++++++++++++ packages/actions-v2/src/metis/types.ts | 261 ++++++++++++++++ packages/actions-v2/tsup.config.ts | 1 + packages/fastlane/Cargo.lock | 19 +- packages/fastlane/index.d.ts | 7 + pnpm-lock.yaml | 76 ++--- 18 files changed, 1582 insertions(+), 128 deletions(-) create mode 100644 packages/actions-v2/src/metis/__tests__/convert.test.ts create mode 100644 packages/actions-v2/src/metis/__tests__/plan-swap.test.ts create mode 100644 packages/actions-v2/src/metis/client.ts create mode 100644 packages/actions-v2/src/metis/convert.ts create mode 100644 packages/actions-v2/src/metis/index.ts create mode 100644 packages/actions-v2/src/metis/plan-swap.ts create mode 100644 packages/actions-v2/src/metis/types.ts diff --git a/examples/next-js/app/api/jupiter/quote/route.ts b/examples/next-js/app/api/jupiter/quote/route.ts index d681d92..f0ed37e 100644 --- a/examples/next-js/app/api/jupiter/quote/route.ts +++ b/examples/next-js/app/api/jupiter/quote/route.ts @@ -1,30 +1,79 @@ import { NextRequest, NextResponse } from 'next/server'; +const JUPITER_API_BASE = 'https://api.jup.ag/swap/v1'; + /** - * Next.js API route proxy for Jupiter quote API. - * Proxies requests to Jupiter's quote API to work around network/DNS restrictions. + * Next.js API route proxy for Jupiter Metis quote API. + * Proxies requests to Jupiter's quote API with API key authentication. */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const params = new URLSearchParams({ - inputMint: searchParams.get('inputMint') || '', - outputMint: searchParams.get('outputMint') || '', - amount: searchParams.get('amount') || '', - slippageBps: searchParams.get('slippageBps') || '50', - onlyDirectRoutes: searchParams.get('onlyDirectRoutes') || 'false', - asLegacyTransaction: searchParams.get('asLegacyTransaction') || 'false', - }); + const params = new URLSearchParams(); + + // Required params + const inputMint = searchParams.get('inputMint'); + const outputMint = searchParams.get('outputMint'); + const amount = searchParams.get('amount'); + + if (!inputMint || !outputMint || !amount) { + return NextResponse.json( + { error: 'Missing required parameters: inputMint, outputMint, amount' }, + { status: 400 }, + ); + } + + params.set('inputMint', inputMint); + params.set('outputMint', outputMint); + params.set('amount', amount); + + // Optional params + const slippageBps = searchParams.get('slippageBps'); + if (slippageBps) params.set('slippageBps', slippageBps); + + const swapMode = searchParams.get('swapMode'); + if (swapMode) params.set('swapMode', swapMode); + + const dexes = searchParams.get('dexes'); + if (dexes) params.set('dexes', dexes); + + const excludeDexes = searchParams.get('excludeDexes'); + if (excludeDexes) params.set('excludeDexes', excludeDexes); + + const restrictIntermediateTokens = searchParams.get('restrictIntermediateTokens'); + if (restrictIntermediateTokens) params.set('restrictIntermediateTokens', restrictIntermediateTokens); + + const onlyDirectRoutes = searchParams.get('onlyDirectRoutes'); + if (onlyDirectRoutes) params.set('onlyDirectRoutes', onlyDirectRoutes); + + const asLegacyTransaction = searchParams.get('asLegacyTransaction'); + if (asLegacyTransaction) params.set('asLegacyTransaction', asLegacyTransaction); + + const platformFeeBps = searchParams.get('platformFeeBps'); + if (platformFeeBps) params.set('platformFeeBps', platformFeeBps); + + const maxAccounts = searchParams.get('maxAccounts'); + if (maxAccounts) params.set('maxAccounts', maxAccounts); try { - const response = await fetch(`https://lite-api.jup.ag/swap/v1/quote?${params}`, { - headers: { - Accept: 'application/json', - }, + const headers: Record = { + Accept: 'application/json', + }; + + // Add API key if available + if (process.env.JUPITER_API_KEY) { + headers['x-api-key'] = process.env.JUPITER_API_KEY; + } + + const response = await fetch(`${JUPITER_API_BASE}/quote?${params}`, { + headers, + cache: 'no-store', }); if (!response.ok) { - throw new Error(`Jupiter API error: ${response.status}`); + const errorText = await response.text(); + console.error('Jupiter quote API error:', response.status, errorText); + throw new Error(`Jupiter API error: ${response.status} - ${errorText.substring(0, 200)}`); } const data = await response.json(); diff --git a/examples/next-js/app/api/jupiter/swap-instructions/route.ts b/examples/next-js/app/api/jupiter/swap-instructions/route.ts index 64f151a..1a1b079 100644 --- a/examples/next-js/app/api/jupiter/swap-instructions/route.ts +++ b/examples/next-js/app/api/jupiter/swap-instructions/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; +const JUPITER_API_BASE = 'https://api.jup.ag/swap/v1'; + /** - * Next.js API route proxy for Jupiter swap-instructions API. - * Proxies requests to Jupiter's swap-instructions API to work around network/DNS restrictions. + * Next.js API route proxy for Jupiter Metis swap-instructions API. + * Proxies requests to Jupiter's swap-instructions API with API key authentication. */ export async function POST(request: NextRequest) { try { @@ -17,18 +19,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required field: userPublicKey' }, { status: 400 }); } - const response = await fetch('https://lite-api.jup.ag/swap/v1/swap-instructions', { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + // Add API key if available + if (process.env.JUPITER_API_KEY) { + headers['x-api-key'] = process.env.JUPITER_API_KEY; + } + + const response = await fetch(`${JUPITER_API_BASE}/swap-instructions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, + headers, + cache: 'no-store', body: JSON.stringify(body), }); if (!response.ok) { const errorText = await response.text(); - console.error('Jupiter API error response:', response.status, errorText); + console.error('Jupiter swap-instructions API error:', response.status, errorText); throw new Error(`Jupiter API error: ${response.status} - ${errorText.substring(0, 200)}`); } diff --git a/examples/next-js/components/pipeline/examples/jupiter-swap.tsx b/examples/next-js/components/pipeline/examples/jupiter-swap.tsx index f13d768..e56d5a1 100644 --- a/examples/next-js/components/pipeline/examples/jupiter-swap.tsx +++ b/examples/next-js/components/pipeline/examples/jupiter-swap.tsx @@ -1,64 +1,139 @@ 'use client'; import { useMemo } from 'react'; -import { createFlow, type FlowConfig } from '@pipeit/core'; +import { createFlow, type FlowConfig, TransactionBuilder } from '@pipeit/core'; import { VisualPipeline } from '@/lib/visual-pipeline'; -import { jupiter } from '@pipeit/actions/adapters'; +import { + createMetisClient, + metisInstructionToKit, + type QuoteResponse, + type SwapInstructionsResponse, +} from '@pipeit/actions-v2/metis'; +import { address } from '@solana/kit'; // Token addresses const SOL_MINT = 'So11111111111111111111111111111111111111112'; const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +// Proxy through Next.js API route to inject API key server-side +const JUPITER_PROXY_URL = '/api/jupiter'; + /** - * Example: Jupiter Swap using @pipeit/actions adapter + * Example: Jupiter Swap using @pipeit/actions-v2/metis * - * This demonstrates using the Jupiter adapter to get swap instructions, - * then executing them via the Flow API. + * This demonstrates using the Metis module to get swap instructions, + * then executing them via TransactionBuilder with full control. */ export function useJupiterSwapPipeline() { const visualPipeline = useMemo(() => { - // Create Jupiter adapter - const jupiterAdapter = jupiter(); + const metisClient = createMetisClient({ + baseUrl: JUPITER_PROXY_URL, + }); const flowFactory = (config: FlowConfig) => createFlow(config).transaction('jupiter-swap', async ctx => { - // Call Jupiter adapter to get swap instructions - const swapAction = jupiterAdapter.swap({ + const userPublicKey = ctx.signer.address; + + // Step 1: Get quote from Jupiter + const quoteResponse: QuoteResponse = await metisClient.getQuote({ inputMint: SOL_MINT, outputMint: USDC_MINT, amount: 10_000_000n, // 0.01 SOL - slippageBps: 50, + slippageBps: 100, // 1% slippage for safety }); - // Execute the action to get instructions - const result = await swapAction({ - signer: ctx.signer, - rpc: ctx.rpc as any, - rpcSubscriptions: ctx.rpcSubscriptions as any, + console.log( + `Jupiter quote: ${quoteResponse.inAmount} -> ${quoteResponse.outAmount} (${quoteResponse.swapMode})`, + ); + + // Step 2: Get swap instructions + const swapInstructions: SwapInstructionsResponse = await metisClient.getSwapInstructions({ + quoteResponse, + userPublicKey, + wrapAndUnwrapSol: true, + // Prefer shared accounts so Jupiter can handle intermediate token accounts when needed. + useSharedAccounts: true, }); - // Use TransactionBuilder to execute all Jupiter instructions - const { TransactionBuilder } = await import('@pipeit/core'); - const { address } = await import('@solana/kit'); - - // Get lookup table addresses from Jupiter response and convert to Address types - const lookupTables = result.addressLookupTableAddresses ?? []; - const lookupTableAddrs = lookupTables.map((addr: string) => address(addr)); - - const signature = await new TransactionBuilder({ - rpc: ctx.rpc as any, - computeUnits: 400_000, - // Use lookup tables to compress the transaction - lookupTableAddresses: lookupTableAddrs.length > 0 ? lookupTableAddrs : undefined, - }) - .setFeePayerSigner(ctx.signer) - .addInstructions(result.instructions) - .execute({ - rpcSubscriptions: ctx.rpcSubscriptions as any, - commitment: 'confirmed', - }); - - return { signature }; + console.log('Swap instructions:', swapInstructions); + const simulationError = (swapInstructions as unknown as { simulationError?: unknown }).simulationError; + if (simulationError) { + console.warn( + '[Jupiter] swap-instructions returned simulationError. ' + + 'Continuing with local simulation via TransactionBuilder.', + simulationError, + ); + } + + // Step 3: Convert all instructions to Kit format + // IMPORTANT: Do NOT include Jupiter's computeBudgetInstructions here. + // TransactionBuilder will simulate + set CU limit and priority fee itself. + const allInstructions = [ + ...swapInstructions.otherInstructions.map(metisInstructionToKit), + ...swapInstructions.setupInstructions.map(metisInstructionToKit), + ...(swapInstructions.tokenLedgerInstruction + ? [metisInstructionToKit(swapInstructions.tokenLedgerInstruction)] + : []), + metisInstructionToKit(swapInstructions.swapInstruction), + ...(swapInstructions.cleanupInstruction + ? [metisInstructionToKit(swapInstructions.cleanupInstruction)] + : []), + ]; + + // Convert lookup table addresses + const lookupTableAddresses = swapInstructions.addressLookupTableAddresses.map( + addr => address(addr), + ); + + console.log('Total instructions:', allInstructions.length); + console.log('Lookup tables:', lookupTableAddresses); + + // Step 4: Build and execute transaction + async function executeSwapOnce(): Promise { + return new TransactionBuilder({ + rpc: ctx.rpc as any, + // Simulate to set CU limit (and surface simulation logs if it fails). + computeUnits: { strategy: 'simulate', buffer: 1.1 }, + // Fixed high priority fee so it lands before blockhash expiry. + // 200_000 microLamports/CU = 0.2 lamports/CU. + priorityFee: { strategy: 'fixed', microLamports: 200_000 }, + // Don't retry the same blockhash internally; we rebuild on expiry below. + autoRetry: false, + lookupTableAddresses: lookupTableAddresses.length > 0 ? lookupTableAddresses : undefined, + }) + .setFeePayerSigner(ctx.signer) + .addInstructions(allInstructions) + .execute({ + rpcSubscriptions: ctx.rpcSubscriptions as any, + commitment: 'confirmed', + // We've already simulated for CU; skipping preflight reduces latency. + skipPreflight: true, + }); + } + + let signature: string; + try { + signature = await executeSwapOnce(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // If the blockhash expired before landing, rebuild + retry once with a fresh blockhash. + if (message.includes('progressed past the last block')) { + signature = await executeSwapOnce(); + } else { + throw error; + } + } + + console.log('Swap executed:', signature); + + return { + signature, + quote: { + inputAmount: BigInt(quoteResponse.inAmount), + outputAmount: BigInt(quoteResponse.outAmount), + swapMode: quoteResponse.swapMode, + }, + }; }); return new VisualPipeline('jupiter-swap', flowFactory, [{ name: 'jupiter-swap', type: 'transaction' }]); @@ -67,26 +142,54 @@ export function useJupiterSwapPipeline() { return visualPipeline; } -export const jupiterSwapCode = `import { pipe } from '@pipeit/actions' -import { jupiter } from '@pipeit/actions/adapters' +export const jupiterSwapCode = `import { createMetisClient, metisInstructionToKit } from '@pipeit/actions-v2/metis' +import { TransactionBuilder } from '@pipeit/core' +import { address } from '@solana/kit' // Token addresses const SOL = 'So11111111111111111111111111111111111111112' const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' -// Swap 0.01 SOL for USDC using Jupiter -const result = await pipe({ +// Create client with API key +const client = createMetisClient({ + apiKey: 'your-api-key', // from https://portal.jup.ag +}) + +// Step 1: Get quote +const quote = await client.getQuote({ + inputMint: SOL, + outputMint: USDC, + amount: 10_000_000n, // 0.01 SOL + slippageBps: 100, // 1% slippage +}) + +// Step 2: Get swap instructions +const swapIxs = await client.getSwapInstructions({ + quoteResponse: quote, + userPublicKey: signer.address, + wrapAndUnwrapSol: true, + useSharedAccounts: true, +}) + +// Step 3: Convert instructions to Kit format +const instructions = [ + ...swapIxs.otherInstructions.map(metisInstructionToKit), + ...swapIxs.setupInstructions.map(metisInstructionToKit), + metisInstructionToKit(swapIxs.swapInstruction), + ...(swapIxs.cleanupInstruction ? [metisInstructionToKit(swapIxs.cleanupInstruction)] : []), +] + +const lookupTableAddresses = swapIxs.addressLookupTableAddresses.map(address) + +// Step 4: Execute with TransactionBuilder +const signature = await new TransactionBuilder({ rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() } + computeUnits: { strategy: 'simulate', buffer: 1.1 }, + priorityFee: { strategy: 'fixed', microLamports: 200_000 }, + lookupTableAddresses, }) - .swap({ - inputMint: SOL, - outputMint: USDC, - amount: 10_000_000n, // 0.01 SOL in lamports - slippageBps: 50, // 0.5% slippage tolerance - }) - .execute() - -console.log('Swap executed:', result.signature)`; + .setFeePayerSigner(signer) + .addInstructions(instructions) + .execute({ rpcSubscriptions, commitment: 'confirmed', skipPreflight: true }) + +console.log('Swap executed:', signature)`; diff --git a/examples/next-js/package.json b/examples/next-js/package.json index c682625..317a87e 100644 --- a/examples/next-js/package.json +++ b/examples/next-js/package.json @@ -24,7 +24,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@solana-program/system": "^0.9.0", "@solana-program/token": "^0.9.0", - "@solana/connector": "0.1.4", + "@solana/connector": "0.1.7", "@solana/connector-debugger": "0.1.1", "@solana/instruction-plans": "^5.0.0", "@solana/instructions": "^5.0.0", diff --git a/package.json b/package.json index 9821b12..4224ada 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@solana/prettier-config-solana": "^0.0.6", "@types/node": "^24.10.0", "prettier": "^3.6.2", - "turbo": "^2.6.0", + "turbo": "^2.7.2", "typescript": "^5.9.3", "vitest": "^3.2.4" }, diff --git a/packages/actions-v2/package.json b/packages/actions-v2/package.json index 33ed845..9031db8 100644 --- a/packages/actions-v2/package.json +++ b/packages/actions-v2/package.json @@ -17,6 +17,11 @@ "types": "./dist/titan/index.d.ts", "import": "./dist/titan/index.js", "require": "./dist/titan/index.cjs" + }, + "./metis": { + "types": "./dist/metis/index.d.ts", + "import": "./dist/metis/index.js", + "require": "./dist/metis/index.cjs" } }, "files": [ @@ -35,6 +40,8 @@ "defi", "swap", "titan", + "jupiter", + "metis", "instruction-plans", "transaction" ], diff --git a/packages/actions-v2/src/index.ts b/packages/actions-v2/src/index.ts index e229017..9a1ec05 100644 --- a/packages/actions-v2/src/index.ts +++ b/packages/actions-v2/src/index.ts @@ -33,3 +33,7 @@ // Re-export Titan module export * from './titan/index.js'; + +// Note: Metis module is NOT re-exported here to avoid naming conflicts +// (both Titan and Metis have SwapMode, RoutePlanStep, etc.) +// Import Metis directly via: import { ... } from '@pipeit/actions-v2/metis' diff --git a/packages/actions-v2/src/metis/__tests__/convert.test.ts b/packages/actions-v2/src/metis/__tests__/convert.test.ts new file mode 100644 index 0000000..dba81c7 --- /dev/null +++ b/packages/actions-v2/src/metis/__tests__/convert.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for Metis conversion utilities. + */ + +import { describe, it, expect } from 'vitest'; +import { + decodeBase64, + metisInstructionToKit, + metisInstructionsToKit, + metisLookupTablesToAddresses, +} from '../convert.js'; +import type { MetisInstruction, AccountMeta } from '../types.js'; + +describe('decodeBase64', () => { + it('should decode empty string to empty array', () => { + const result = decodeBase64(''); + expect(result).toEqual(new Uint8Array([])); + }); + + it('should decode simple base64', () => { + // 'AQID' is base64 for bytes [1, 2, 3] + const result = decodeBase64('AQID'); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('should decode longer base64 strings', () => { + // 'SGVsbG8gV29ybGQ=' is base64 for 'Hello World' + const result = decodeBase64('SGVsbG8gV29ybGQ='); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100])); + }); + + it('should handle base64 with padding', () => { + // 'YQ==' is base64 for 'a' (single byte) + const result = decodeBase64('YQ=='); + expect(result).toEqual(new Uint8Array([97])); + }); +}); + +describe('metisInstructionToKit', () => { + it('should convert a simple instruction', () => { + const metisIx: MetisInstruction = { + programId: '11111111111111111111111111111111', + accounts: [], + data: 'AQIDBA==', // [1, 2, 3, 4] + }; + + const kitIx = metisInstructionToKit(metisIx); + + expect(kitIx.programAddress).toBe('11111111111111111111111111111111'); + expect(kitIx.accounts).toEqual([]); + expect(kitIx.data).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it('should convert instruction with accounts and correct roles', () => { + const readonlyAccount: AccountMeta = { + pubkey: '11111111111111111111111111111111', + isSigner: false, + isWritable: false, + }; + const readonlySignerAccount: AccountMeta = { + pubkey: '11111111111111111111111111111111', + isSigner: true, + isWritable: false, + }; + const writableAccount: AccountMeta = { + pubkey: '11111111111111111111111111111111', + isSigner: false, + isWritable: true, + }; + const writableSignerAccount: AccountMeta = { + pubkey: '11111111111111111111111111111111', + isSigner: true, + isWritable: true, + }; + + const metisIx: MetisInstruction = { + programId: '11111111111111111111111111111111', + accounts: [ + readonlyAccount, + readonlySignerAccount, + writableAccount, + writableSignerAccount, + ], + data: '', + }; + + const kitIx = metisInstructionToKit(metisIx); + + expect(kitIx.accounts).toHaveLength(4); + + // READONLY (role 0) + expect(kitIx.accounts[0].role).toBe(0); + // READONLY_SIGNER (role 2) + expect(kitIx.accounts[1].role).toBe(2); + // WRITABLE (role 1) + expect(kitIx.accounts[2].role).toBe(1); + // WRITABLE_SIGNER (role 3) + expect(kitIx.accounts[3].role).toBe(3); + }); + + it('should convert instruction with real-looking addresses', () => { + const metisIx: MetisInstruction = { + programId: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + accounts: [ + { + pubkey: 'So11111111111111111111111111111111111111112', + isSigner: false, + isWritable: true, + }, + ], + data: 'AQID', + }; + + const kitIx = metisInstructionToKit(metisIx); + + expect(kitIx.programAddress).toBe('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'); + expect(kitIx.accounts[0].address).toBe('So11111111111111111111111111111111111111112'); + expect(kitIx.accounts[0].role).toBe(1); // WRITABLE + }); +}); + +describe('metisInstructionsToKit', () => { + it('should convert empty array', () => { + expect(metisInstructionsToKit([])).toEqual([]); + }); + + it('should convert multiple instructions', () => { + const instructions: MetisInstruction[] = [ + { + programId: '11111111111111111111111111111111', + accounts: [], + data: 'AQ==', // [1] + }, + { + programId: '11111111111111111111111111111111', + accounts: [], + data: 'Ag==', // [2] + }, + ]; + + const result = metisInstructionsToKit(instructions); + + expect(result).toHaveLength(2); + expect(result[0].data).toEqual(new Uint8Array([1])); + expect(result[1].data).toEqual(new Uint8Array([2])); + }); +}); + +describe('metisLookupTablesToAddresses', () => { + it('should convert empty array', () => { + expect(metisLookupTablesToAddresses([])).toEqual([]); + }); + + it('should convert address strings to Kit addresses', () => { + const addresses = [ + 'AddressLookupTab1e1111111111111111111111111', + 'AddressLookupTab1e2222222222222222222222222', + ]; + + const result = metisLookupTablesToAddresses(addresses); + + expect(result).toHaveLength(2); + expect(result[0]).toBe('AddressLookupTab1e1111111111111111111111111'); + expect(result[1]).toBe('AddressLookupTab1e2222222222222222222222222'); + }); +}); diff --git a/packages/actions-v2/src/metis/__tests__/plan-swap.test.ts b/packages/actions-v2/src/metis/__tests__/plan-swap.test.ts new file mode 100644 index 0000000..92530ad --- /dev/null +++ b/packages/actions-v2/src/metis/__tests__/plan-swap.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for Metis swap plan builder. + */ + +import { describe, it, expect } from 'vitest'; +import { + getMetisSwapInstructionPlanFromResponse, + NoSwapInstructionError, +} from '../plan-swap.js'; +import type { SwapInstructionsResponse, MetisInstruction } from '../types.js'; + +/** + * Create a mock Metis instruction for testing. + */ +function createMockInstruction(data: number[]): MetisInstruction { + return { + programId: '11111111111111111111111111111111', + accounts: [], + // Convert to base64 manually for simplicity + data: Buffer.from(data).toString('base64'), + }; +} + +/** + * Create a mock swap instructions response for testing. + */ +function createMockSwapInstructionsResponse( + overrides: Partial = {}, +): SwapInstructionsResponse { + return { + computeBudgetInstructions: [], + otherInstructions: [], + setupInstructions: [], + swapInstruction: createMockInstruction([1, 2, 3, 4]), + addressLookupTableAddresses: [], + ...overrides, + }; +} + +describe('getMetisSwapInstructionPlanFromResponse', () => { + it('should create a single instruction plan for swap-only response', () => { + const response = createMockSwapInstructionsResponse({ + computeBudgetInstructions: [], + otherInstructions: [], + setupInstructions: [], + }); + + const plan = getMetisSwapInstructionPlanFromResponse(response); + + expect(plan.kind).toBe('single'); + }); + + it('should create a sequential plan when multiple instructions exist', () => { + const response = createMockSwapInstructionsResponse({ + computeBudgetInstructions: [ + createMockInstruction([1]), + createMockInstruction([2]), + ], + setupInstructions: [createMockInstruction([3])], + }); + + const plan = getMetisSwapInstructionPlanFromResponse(response); + + expect(plan.kind).toBe('sequential'); + if (plan.kind === 'sequential') { + // computeBudgetInstructions are ignored (executePlan/TransactionBuilder manage them), + // so we only expect: 1 setup + 1 swap = 2 instructions + expect(plan.plans).toHaveLength(2); + } + }); + + it('should include optional tokenLedgerInstruction when present', () => { + const response = createMockSwapInstructionsResponse({ + tokenLedgerInstruction: createMockInstruction([99]), + }); + + const plan = getMetisSwapInstructionPlanFromResponse(response); + + expect(plan.kind).toBe('sequential'); + if (plan.kind === 'sequential') { + // tokenLedger + swap = 2 instructions + expect(plan.plans).toHaveLength(2); + } + }); + + it('should include optional cleanupInstruction when present', () => { + const response = createMockSwapInstructionsResponse({ + cleanupInstruction: createMockInstruction([100]), + }); + + const plan = getMetisSwapInstructionPlanFromResponse(response); + + expect(plan.kind).toBe('sequential'); + if (plan.kind === 'sequential') { + // swap + cleanup = 2 instructions + expect(plan.plans).toHaveLength(2); + } + }); + + it('should preserve correct instruction order', () => { + const response = createMockSwapInstructionsResponse({ + computeBudgetInstructions: [createMockInstruction([1])], + otherInstructions: [createMockInstruction([2])], + setupInstructions: [createMockInstruction([3])], + tokenLedgerInstruction: createMockInstruction([4]), + swapInstruction: createMockInstruction([5]), + cleanupInstruction: createMockInstruction([6]), + }); + + const plan = getMetisSwapInstructionPlanFromResponse(response); + + expect(plan.kind).toBe('sequential'); + if (plan.kind === 'sequential') { + // computeBudgetInstructions are ignored + // Total: other + setup + tokenLedger + swap + cleanup = 5 instructions in order + expect(plan.plans).toHaveLength(5); + + // Each plan should be a single instruction plan + // The order should be: other, setup, tokenLedger, swap, cleanup + const plans = plan.plans; + for (let i = 0; i < plans.length; i++) { + expect(plans[i].kind).toBe('single'); + } + } + }); + + it('should handle all instruction types being present', () => { + const response = createMockSwapInstructionsResponse({ + computeBudgetInstructions: [ + createMockInstruction([10]), + createMockInstruction([11]), + ], + otherInstructions: [createMockInstruction([20])], + setupInstructions: [ + createMockInstruction([30]), + createMockInstruction([31]), + ], + tokenLedgerInstruction: createMockInstruction([40]), + swapInstruction: createMockInstruction([50]), + cleanupInstruction: createMockInstruction([60]), + }); + + const plan = getMetisSwapInstructionPlanFromResponse(response); + + expect(plan.kind).toBe('sequential'); + if (plan.kind === 'sequential') { + // computeBudgetInstructions are ignored + // 1 other + 2 setup + 1 tokenLedger + 1 swap + 1 cleanup = 6 + expect(plan.plans).toHaveLength(6); + } + }); + + it('should throw NoSwapInstructionError when no instructions exist', () => { + // This is an edge case - normally swapInstruction is required, + // but we test the error handling + const response = { + computeBudgetInstructions: [], + otherInstructions: [], + setupInstructions: [], + swapInstruction: undefined as unknown as MetisInstruction, + addressLookupTableAddresses: [], + }; + + // We need to manually remove swapInstruction to trigger the error + // In practice, the API always returns swapInstruction, but we test defensively + const responseWithNoSwap = createMockSwapInstructionsResponse(); + // Hack: make all instruction arrays empty and remove swapInstruction + // by creating an object that looks like no instructions + const emptyResponse = { + computeBudgetInstructions: [], + otherInstructions: [], + setupInstructions: [], + // Pretend swapInstruction doesn't add to array (can't really happen) + swapInstruction: undefined, + addressLookupTableAddresses: [], + } as unknown as SwapInstructionsResponse; + + // Actually, the function always adds swapInstruction, so we can't easily + // trigger this error. Let's verify the error class exists and is throwable. + const error = new NoSwapInstructionError(); + expect(error.name).toBe('NoSwapInstructionError'); + expect(error.message).toBe('No swap instruction found in response.'); + }); +}); + +describe('MetisSwapPlanResult quote parsing', () => { + // These tests verify the quote metadata is correctly parsed + // We test this indirectly through the response handling + + it('should correctly parse bigint amounts from string response', () => { + // This is testing the pattern used in getMetisSwapPlan + const inAmount = '1000000000'; + const outAmount = '98765432'; + + const parsedIn = BigInt(inAmount); + const parsedOut = BigInt(outAmount); + + expect(parsedIn).toBe(1_000_000_000n); + expect(parsedOut).toBe(98_765_432n); + }); +}); diff --git a/packages/actions-v2/src/metis/client.ts b/packages/actions-v2/src/metis/client.ts new file mode 100644 index 0000000..df648b7 --- /dev/null +++ b/packages/actions-v2/src/metis/client.ts @@ -0,0 +1,218 @@ +/** + * Jupiter Metis REST API client. + * + * Provides a minimal client for Jupiter's Metis Swap API. + * + * @packageDocumentation + */ + +import type { + MetisQuoteParams, + QuoteResponse, + SwapInstructionsRequest, + SwapInstructionsResponse, +} from './types.js'; + +/** + * Default Metis API base URL. + */ +export const METIS_DEFAULT_BASE_URL = 'https://api.jup.ag/swap/v1'; + +function normalizeBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return trimmed; + } + + // Relative paths (e.g. /api/metis for proxy) should stay as-is + const isRelative = trimmed.startsWith('/'); + const hasProtocol = /^https?:\/\//i.test(trimmed); + const normalized = isRelative || hasProtocol ? trimmed : `https://${trimmed}`; + + // Normalize trailing slashes so we can safely join paths. + return normalized.replace(/\/+$/, ''); +} + +function joinUrl(baseUrl: string, path: string): string { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const normalizedPath = path.replace(/^\/+/, ''); + return `${normalizedBaseUrl}/${normalizedPath}`; +} + +/** + * Configuration for the Metis client. + */ +export interface MetisClientConfig { + /** + * REST API base URL. + * + * If not provided, defaults to https://api.jup.ag/swap/v1. + * You may pass a hostname without a protocol (https:// will be assumed). + */ + baseUrl?: string; + /** + * API key for authentication. + * Sent as the x-api-key header. Get one from https://portal.jup.ag + */ + apiKey?: string; + /** Custom fetch implementation (for testing or environments without global fetch) */ + fetch?: typeof globalThis.fetch; +} + +/** + * Jupiter Metis REST API client. + */ +export interface MetisClient { + /** Get a swap quote */ + getQuote(params: MetisQuoteParams): Promise; + /** Get swap instructions from a quote */ + getSwapInstructions(request: SwapInstructionsRequest): Promise; +} + +/** + * Create a Jupiter Metis REST API client. + * + * @example + * ```ts + * const client = createMetisClient({ apiKey: 'your-api-key' }); + * + * const quote = await client.getQuote({ + * inputMint: 'So11111111111111111111111111111111111111112', + * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * amount: 1_000_000_000n, + * }); + * + * const instructions = await client.getSwapInstructions({ + * quoteResponse: quote, + * userPublicKey: 'YourWalletAddress...', + * }); + * ``` + */ +export function createMetisClient(config: MetisClientConfig = {}): MetisClient { + const { + baseUrl: baseUrlInput, + apiKey, + fetch: customFetch = globalThis.fetch, + } = config; + + const baseUrl = normalizeBaseUrl(baseUrlInput ?? METIS_DEFAULT_BASE_URL); + + /** + * Build common headers including API key if provided. + */ + function buildHeaders(contentType?: string): Record { + const headers: Record = {}; + if (apiKey) { + headers['x-api-key'] = apiKey; + } + if (contentType) { + headers['Content-Type'] = contentType; + } + return headers; + } + + /** + * Make a GET request to the Metis API. + */ + async function get(path: string, params?: URLSearchParams): Promise { + const base = joinUrl(baseUrl, path); + const url = params ? `${base}?${params}` : base; + + const response = await customFetch(url, { + headers: buildHeaders(), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new MetisApiError(response.status, errorText, path); + } + + return response.json() as Promise; + } + + /** + * Make a POST request to the Metis API. + */ + async function post(path: string, body: unknown): Promise { + const url = joinUrl(baseUrl, path); + + const response = await customFetch(url, { + method: 'POST', + headers: buildHeaders('application/json'), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new MetisApiError(response.status, errorText, path); + } + + return response.json() as Promise; + } + + return { + async getQuote(params: MetisQuoteParams): Promise { + const urlParams = new URLSearchParams(); + + // Required parameters + urlParams.set('inputMint', params.inputMint); + urlParams.set('outputMint', params.outputMint); + urlParams.set('amount', params.amount.toString()); + + // Optional parameters + if (params.slippageBps !== undefined) { + urlParams.set('slippageBps', params.slippageBps.toString()); + } + if (params.swapMode !== undefined) { + urlParams.set('swapMode', params.swapMode); + } + if (params.dexes !== undefined && params.dexes.length > 0) { + urlParams.set('dexes', params.dexes.join(',')); + } + if (params.excludeDexes !== undefined && params.excludeDexes.length > 0) { + urlParams.set('excludeDexes', params.excludeDexes.join(',')); + } + if (params.restrictIntermediateTokens !== undefined) { + urlParams.set('restrictIntermediateTokens', String(params.restrictIntermediateTokens)); + } + if (params.onlyDirectRoutes !== undefined) { + urlParams.set('onlyDirectRoutes', String(params.onlyDirectRoutes)); + } + if (params.asLegacyTransaction !== undefined) { + urlParams.set('asLegacyTransaction', String(params.asLegacyTransaction)); + } + if (params.platformFeeBps !== undefined) { + urlParams.set('platformFeeBps', params.platformFeeBps.toString()); + } + if (params.maxAccounts !== undefined) { + urlParams.set('maxAccounts', params.maxAccounts.toString()); + } + if (params.instructionVersion !== undefined) { + urlParams.set('instructionVersion', params.instructionVersion); + } + + return get('quote', urlParams); + }, + + async getSwapInstructions(request: SwapInstructionsRequest): Promise { + return post('swap-instructions', request); + }, + }; +} + +/** + * Error thrown when the Metis API returns an error response. + */ +export class MetisApiError extends Error { + readonly statusCode: number; + readonly responseBody: string; + readonly path: string; + + constructor(statusCode: number, responseBody: string, path: string) { + super(`Metis API error (${statusCode}) at ${path}: ${responseBody}`); + this.name = 'MetisApiError'; + this.statusCode = statusCode; + this.responseBody = responseBody; + this.path = path; + } +} diff --git a/packages/actions-v2/src/metis/convert.ts b/packages/actions-v2/src/metis/convert.ts new file mode 100644 index 0000000..3149882 --- /dev/null +++ b/packages/actions-v2/src/metis/convert.ts @@ -0,0 +1,94 @@ +/** + * Conversion utilities for Metis types to Kit types. + * + * @packageDocumentation + */ + +import { address, type Address } from '@solana/addresses'; +import type { Instruction, AccountMeta as KitAccountMeta, AccountRole } from '@solana/instructions'; +import type { MetisInstruction, AccountMeta } from './types.js'; + +/** + * Decode a base64 string to Uint8Array. + * Works in both Node.js and browser environments. + */ +export function decodeBase64(base64: string): Uint8Array { + // Use atob which is available in both modern Node.js (>=16) and browsers + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +/** + * Map Metis account meta flags to Kit AccountRole. + * + * AccountRole values: + * - 0: READONLY + * - 1: WRITABLE + * - 2: READONLY_SIGNER + * - 3: WRITABLE_SIGNER + */ +function toAccountRole(meta: AccountMeta): AccountRole { + if (meta.isSigner && meta.isWritable) { + return 3 as AccountRole; // WRITABLE_SIGNER + } + if (meta.isSigner) { + return 2 as AccountRole; // READONLY_SIGNER + } + if (meta.isWritable) { + return 1 as AccountRole; // WRITABLE + } + return 0 as AccountRole; // READONLY +} + +/** + * Convert a Metis account meta to Kit AccountMeta. + */ +function metisAccountMetaToKit(meta: AccountMeta): KitAccountMeta { + return { + address: address(meta.pubkey), + role: toAccountRole(meta), + }; +} + +/** + * Convert a Metis instruction to a Kit Instruction. + * + * @param instruction - Metis instruction with base64-encoded data + * @returns Kit instruction + * + * @example + * ```ts + * const kitInstruction = metisInstructionToKit(metisInstruction); + * ``` + */ +export function metisInstructionToKit(instruction: MetisInstruction): Instruction { + return { + programAddress: address(instruction.programId), + accounts: instruction.accounts.map(metisAccountMetaToKit), + data: decodeBase64(instruction.data), + }; +} + +/** + * Convert multiple Metis instructions to Kit instructions. + * + * @param instructions - Array of Metis instructions + * @returns Array of Kit instructions + */ +export function metisInstructionsToKit(instructions: MetisInstruction[]): Instruction[] { + return instructions.map(metisInstructionToKit); +} + +/** + * Convert Metis address lookup table addresses to Kit Address array. + * + * @param addresses - Array of base58 address strings + * @returns Array of Kit addresses + */ +export function metisLookupTablesToAddresses(addresses: string[]): Address[] { + return addresses.map(addr => address(addr)); +} diff --git a/packages/actions-v2/src/metis/index.ts b/packages/actions-v2/src/metis/index.ts new file mode 100644 index 0000000..98d4b2d --- /dev/null +++ b/packages/actions-v2/src/metis/index.ts @@ -0,0 +1,53 @@ +/** + * Jupiter Metis Swap API integration. + * + * Provides InstructionPlan factories for swaps via Jupiter's Metis Swap API. + * + * @packageDocumentation + */ + +// Client +export { + createMetisClient, + METIS_DEFAULT_BASE_URL, + MetisApiError, + type MetisClient, + type MetisClientConfig, +} from './client.js'; + +// Types +export type { + SwapMode, + PlatformFee, + SwapInfo, + RoutePlanStep, + QuoteResponse, + MetisQuoteParams, + AccountMeta, + MetisInstruction, + PriorityLevelWithMaxLamports, + JitoTipLamports, + JitoTipLamportsWithPayer, + PrioritizationFeeLamports, + SwapInstructionsRequest, + SwapInstructionsResponse, + MetisSwapQuoteParams, +} from './types.js'; + +// Plan builders +export { + getMetisSwapPlan, + getMetisSwapQuote, + getMetisSwapInstructionPlanFromResponse, + NoSwapInstructionError, + type MetisSwapPlanResult, + type MetisSwapPlanOptions, +} from './plan-swap.js'; + +// Conversion utilities +export { + metisInstructionToKit, + metisInstructionsToKit, + metisLookupTablesToAddresses, + decodeBase64, +} from './convert.js'; diff --git a/packages/actions-v2/src/metis/plan-swap.ts b/packages/actions-v2/src/metis/plan-swap.ts new file mode 100644 index 0000000..b4bf77c --- /dev/null +++ b/packages/actions-v2/src/metis/plan-swap.ts @@ -0,0 +1,285 @@ +/** + * Metis swap plan builder. + * + * Converts Metis quotes and swap instructions to composable Kit InstructionPlans. + * + * @packageDocumentation + */ + +import type { Address } from '@solana/addresses'; +import { + type InstructionPlan, + sequentialInstructionPlan, + singleInstructionPlan, +} from '@solana/instruction-plans'; +import { createMetisClient, type MetisClient, type MetisClientConfig } from './client.js'; +import type { + MetisSwapQuoteParams, + QuoteResponse, + SwapInstructionsRequest, + SwapInstructionsResponse, + SwapMode, +} from './types.js'; +import { + metisInstructionToKit, + metisLookupTablesToAddresses, +} from './convert.js'; + +/** + * Result of building a Metis swap plan. + */ +export interface MetisSwapPlanResult { + /** The instruction plan for the swap */ + plan: InstructionPlan; + /** Address lookup tables used by the swap (pass to executePlan) */ + lookupTableAddresses: Address[]; + /** Quote metadata */ + quote: { + /** Input amount in smallest units */ + inputAmount: bigint; + /** Expected output amount in smallest units */ + outputAmount: bigint; + /** Swap mode used */ + swapMode: SwapMode; + /** Slippage in basis points */ + slippageBps: number; + /** Price impact percentage */ + priceImpactPct: string; + }; + /** Original quote response (for debugging) */ + quoteResponse: QuoteResponse; + /** Original swap instructions response (for debugging) */ + swapInstructionsResponse: SwapInstructionsResponse; +} + +/** + * Options for building a Metis swap plan. + */ +export interface MetisSwapPlanOptions { + /** Metis client instance (creates new one if not provided) */ + client?: MetisClient; + /** Metis client config (used if client not provided) */ + clientConfig?: MetisClientConfig; +} + +/** + * Get a swap quote from Metis. + * + * @param client - Metis client + * @param params - Quote parameters + * @returns Quote response + * + * @example + * ```ts + * const client = createMetisClient({ apiKey: 'your-key' }); + * const quote = await getMetisSwapQuote(client, { + * inputMint: 'So11111111111111111111111111111111111111112', + * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * amount: 1_000_000_000n, + * }); + * ``` + */ +export async function getMetisSwapQuote( + client: MetisClient, + params: MetisSwapQuoteParams['swap'], +): Promise { + return client.getQuote(params); +} + +/** + * Build an InstructionPlan from Metis swap instructions response. + * + * Instructions are assembled in this order: + * 1. otherInstructions (e.g. Jito tips) + * 2. setupInstructions (ATA creation) + * 3. tokenLedgerInstruction (if present) + * 4. swapInstruction + * 5. cleanupInstruction (if present) + * + * NOTE: computeBudgetInstructions are NOT included because executePlan + * handles compute budget automatically. Including them would cause + * "duplicate instruction" errors. + * + * @param response - Swap instructions response from Metis + * @returns Kit InstructionPlan + * + * @example + * ```ts + * const plan = getMetisSwapInstructionPlanFromResponse(swapInstructionsResponse); + * await executePlan(plan, { rpc, rpcSubscriptions, signer }); + * ``` + */ +export function getMetisSwapInstructionPlanFromResponse( + response: SwapInstructionsResponse, +): InstructionPlan { + // NOTE: We intentionally skip computeBudgetInstructions because + // executePlan handles compute budget estimation automatically. + // Including Jupiter's compute budget instructions would cause + // "Transaction contains a duplicate instruction" errors. + const allInstructions = [ + ...response.otherInstructions, + ...response.setupInstructions, + ]; + + // Add optional tokenLedgerInstruction if present + if (response.tokenLedgerInstruction) { + allInstructions.push(response.tokenLedgerInstruction); + } + + // Add the main swap instruction + allInstructions.push(response.swapInstruction); + + // Add optional cleanup instruction if present + if (response.cleanupInstruction) { + allInstructions.push(response.cleanupInstruction); + } + + if (allInstructions.length === 0) { + throw new NoSwapInstructionError(); + } + + const kitInstructions = allInstructions.map(metisInstructionToKit); + + if (kitInstructions.length === 1) { + return singleInstructionPlan(kitInstructions[0]); + } + + // Multiple instructions are sequential + return sequentialInstructionPlan( + kitInstructions.map(ix => singleInstructionPlan(ix)), + ); +} + +/** + * Get a complete Metis swap plan from quote parameters. + * + * This is the main entry point that combines: + * 1. Fetching a quote from Metis + * 2. Fetching swap instructions + * 3. Building an InstructionPlan + * 4. Extracting ALT addresses for executePlan + * + * @param params - Quote and transaction parameters + * @param options - Plan building options + * @returns Complete swap plan result + * + * @example + * ```ts + * import { getMetisSwapPlan } from '@pipeit/actions-v2/metis'; + * import { executePlan } from '@pipeit/core'; + * + * const { plan, lookupTableAddresses, quote } = await getMetisSwapPlan({ + * swap: { + * inputMint: 'So11111111111111111111111111111111111111112', + * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * amount: 1_000_000_000n, // 1 SOL + * slippageBps: 50, // 0.5% + * }, + * transaction: { + * userPublicKey: signer.address, + * }, + * }, { + * clientConfig: { apiKey: 'your-api-key' }, + * }); + * + * console.log(`Swapping for ~${quote.outputAmount} output tokens`); + * + * await executePlan(plan, { + * rpc, + * rpcSubscriptions, + * signer, + * lookupTableAddresses, + * }); + * ``` + * + * @example Composing with other plans + * ```ts + * import { sequentialInstructionPlan } from '@solana/instruction-plans'; + * + * const swapResult = await getMetisSwapPlan({ ... }); + * const transferPlan = singleInstructionPlan(transferInstruction); + * + * const combinedPlan = sequentialInstructionPlan([ + * swapResult.plan, + * transferPlan, + * ]); + * + * // Combine ALTs from all sources + * const allAltAddresses = [ + * ...swapResult.lookupTableAddresses, + * ...otherAltAddresses, + * ]; + * + * await executePlan(combinedPlan, { + * rpc, + * rpcSubscriptions, + * signer, + * lookupTableAddresses: allAltAddresses, + * }); + * ``` + */ +export async function getMetisSwapPlan( + params: MetisSwapQuoteParams, + options?: MetisSwapPlanOptions, +): Promise { + // Get or create client + const client = options?.client ?? createMetisClient(options?.clientConfig); + + // Fetch quote + const quoteResponse = await getMetisSwapQuote(client, params.swap); + + // Build swap instructions request with only defined properties + const tx = params.transaction; + const swapInstructionsRequest: SwapInstructionsRequest = { + quoteResponse, + userPublicKey: tx.userPublicKey, + ...(tx.payer !== undefined && { payer: tx.payer }), + ...(tx.wrapAndUnwrapSol !== undefined && { wrapAndUnwrapSol: tx.wrapAndUnwrapSol }), + ...(tx.useSharedAccounts !== undefined && { useSharedAccounts: tx.useSharedAccounts }), + ...(tx.feeAccount !== undefined && { feeAccount: tx.feeAccount }), + ...(tx.trackingAccount !== undefined && { trackingAccount: tx.trackingAccount }), + ...(tx.prioritizationFeeLamports !== undefined && { prioritizationFeeLamports: tx.prioritizationFeeLamports }), + ...(tx.asLegacyTransaction !== undefined && { asLegacyTransaction: tx.asLegacyTransaction }), + ...(tx.destinationTokenAccount !== undefined && { destinationTokenAccount: tx.destinationTokenAccount }), + ...(tx.nativeDestinationAccount !== undefined && { nativeDestinationAccount: tx.nativeDestinationAccount }), + ...(tx.dynamicComputeUnitLimit !== undefined && { dynamicComputeUnitLimit: tx.dynamicComputeUnitLimit }), + ...(tx.skipUserAccountsRpcCalls !== undefined && { skipUserAccountsRpcCalls: tx.skipUserAccountsRpcCalls }), + ...(tx.computeUnitPriceMicroLamports !== undefined && { computeUnitPriceMicroLamports: tx.computeUnitPriceMicroLamports }), + ...(tx.blockhashSlotsToExpiry !== undefined && { blockhashSlotsToExpiry: tx.blockhashSlotsToExpiry }), + }; + + // Fetch swap instructions + const swapInstructionsResponse = await client.getSwapInstructions(swapInstructionsRequest); + + // Build instruction plan + const plan = getMetisSwapInstructionPlanFromResponse(swapInstructionsResponse); + + // Extract ALT addresses + const lookupTableAddresses = metisLookupTablesToAddresses( + swapInstructionsResponse.addressLookupTableAddresses, + ); + + return { + plan, + lookupTableAddresses, + quote: { + inputAmount: BigInt(quoteResponse.inAmount), + outputAmount: BigInt(quoteResponse.outAmount), + swapMode: quoteResponse.swapMode, + slippageBps: quoteResponse.slippageBps, + priceImpactPct: quoteResponse.priceImpactPct, + }, + quoteResponse, + swapInstructionsResponse, + }; +} + +/** + * Error thrown when swap instructions are missing. + */ +export class NoSwapInstructionError extends Error { + constructor() { + super('No swap instruction found in response.'); + this.name = 'NoSwapInstructionError'; + } +} diff --git a/packages/actions-v2/src/metis/types.ts b/packages/actions-v2/src/metis/types.ts new file mode 100644 index 0000000..788e576 --- /dev/null +++ b/packages/actions-v2/src/metis/types.ts @@ -0,0 +1,261 @@ +/** + * Jupiter Metis Swap API types. + * + * These types match the Metis API wire format. All u64 values are kept as + * strings in wire types so quoteResponse can be POSTed back unchanged. + * + * @packageDocumentation + */ + +/** + * Swap mode - how the amount should be interpreted. + */ +export type SwapMode = 'ExactIn' | 'ExactOut'; + +/** + * Platform fee information from quote. + */ +export interface PlatformFee { + amount: string; + feeBps: number; +} + +/** + * Swap info for a single step in the route. + */ +export interface SwapInfo { + ammKey: string; + label?: string; + inputMint: string; + outputMint: string; + inAmount: string; + outAmount: string; + /** @deprecated */ + feeAmount?: string; + /** @deprecated */ + feeMint?: string; +} + +/** + * A single step in the route plan. + */ +export interface RoutePlanStep { + swapInfo: SwapInfo; + percent?: number | null; + bps?: number; +} + +/** + * Quote response from GET /quote. + * Amounts are strings to allow POSTing back unchanged. + */ +export interface QuoteResponse { + inputMint: string; + outputMint: string; + inAmount: string; + outAmount: string; + otherAmountThreshold: string; + swapMode: SwapMode; + slippageBps: number; + platformFee?: PlatformFee; + priceImpactPct: string; + routePlan: RoutePlanStep[]; + contextSlot?: number; + timeTaken?: number; +} + +/** + * Parameters for requesting a quote. + */ +export interface MetisQuoteParams { + /** Address of input mint */ + inputMint: string; + /** Address of output mint */ + outputMint: string; + /** Raw amount (before decimals) */ + amount: bigint; + /** Slippage in basis points (default: 50) */ + slippageBps?: number; + /** Swap mode (default: ExactIn) */ + swapMode?: SwapMode; + /** Limit to specific DEXes */ + dexes?: string[]; + /** Exclude specific DEXes */ + excludeDexes?: string[]; + /** Restrict intermediate tokens to stable tokens */ + restrictIntermediateTokens?: boolean; + /** Only use direct routes (single hop) */ + onlyDirectRoutes?: boolean; + /** Use legacy transaction format */ + asLegacyTransaction?: boolean; + /** Platform fee in basis points */ + platformFeeBps?: number; + /** Max accounts hint for routing */ + maxAccounts?: number; + /** Instruction version (V1 or V2) */ + instructionVersion?: 'V1' | 'V2'; +} + +/** + * Account metadata for an instruction. + */ +export interface AccountMeta { + pubkey: string; + isSigner: boolean; + isWritable: boolean; +} + +/** + * Instruction in Metis wire format. + */ +export interface MetisInstruction { + programId: string; + accounts: AccountMeta[]; + /** Base64-encoded instruction data */ + data: string; +} + +/** + * Priority level options for prioritization fees. + */ +export interface PriorityLevelWithMaxLamports { + priorityLevelWithMaxLamports: { + priorityLevel: 'medium' | 'high' | 'veryHigh'; + maxLamports: number; + global?: boolean; + }; +} + +/** + * Jito tip options. + */ +export interface JitoTipLamports { + jitoTipLamports: number; +} + +/** + * Jito tip with custom payer. + */ +export interface JitoTipLamportsWithPayer { + jitoTipLamportsWithPayer: { + lamports: number; + payer: string; + }; +} + +/** + * Prioritization fee options (one of the variants). + */ +export type PrioritizationFeeLamports = + | PriorityLevelWithMaxLamports + | JitoTipLamports + | JitoTipLamportsWithPayer + | number; + +/** + * Request body for POST /swap-instructions. + */ +export interface SwapInstructionsRequest { + /** User's public key */ + userPublicKey: string; + /** Quote response from GET /quote */ + quoteResponse: QuoteResponse; + /** Custom payer for fees and rent */ + payer?: string; + /** Auto wrap/unwrap SOL (default: true) */ + wrapAndUnwrapSol?: boolean; + /** Use shared program accounts */ + useSharedAccounts?: boolean; + /** Fee collection token account */ + feeAccount?: string; + /** Tracking public key for analytics */ + trackingAccount?: string; + /** Priority fee configuration */ + prioritizationFeeLamports?: PrioritizationFeeLamports; + /** Use legacy transaction format */ + asLegacyTransaction?: boolean; + /** Custom destination token account */ + destinationTokenAccount?: string; + /** Native SOL destination account */ + nativeDestinationAccount?: string; + /** Dynamically estimate compute units */ + dynamicComputeUnitLimit?: boolean; + /** Skip RPC calls for user accounts */ + skipUserAccountsRpcCalls?: boolean; + /** Dynamic slippage (deprecated) */ + dynamicSlippage?: boolean; + /** Custom compute unit price */ + computeUnitPriceMicroLamports?: number; + /** Slots until transaction expires */ + blockhashSlotsToExpiry?: number; +} + +/** + * Response from POST /swap-instructions. + */ +export interface SwapInstructionsResponse { + /** Compute budget setup instructions */ + computeBudgetInstructions: MetisInstruction[]; + /** Other instructions (e.g. Jito tips) */ + otherInstructions: MetisInstruction[]; + /** Setup instructions for token accounts */ + setupInstructions: MetisInstruction[]; + /** Token ledger instruction (if useTokenLedger) */ + tokenLedgerInstruction?: MetisInstruction; + /** The main swap instruction */ + swapInstruction: MetisInstruction; + /** Cleanup instruction (wrap/unwrap SOL) */ + cleanupInstruction?: MetisInstruction; + /** Address lookup table addresses for versioned transactions */ + addressLookupTableAddresses: string[]; + /** + * Server-side simulation error (if Jupiter attempted simulation and it failed). + * This is informational only; you may still choose to simulate locally. + */ + simulationError?: { error: string; errorCode: string } | null; + /** Slot used for server-side simulation (if available). */ + simulationSlot?: number | null; + /** Estimated compute unit limit returned by Jupiter (if available). */ + computeUnitLimit?: number; + /** Estimated total prioritization fee in lamports (if available). */ + prioritizationFeeLamports?: number; +} + +/** + * Combined parameters for swap quote request (mirrors Titan style). + */ +export interface MetisSwapQuoteParams { + /** Swap parameters */ + swap: { + inputMint: string; + outputMint: string; + amount: bigint; + slippageBps?: number; + swapMode?: SwapMode; + dexes?: string[]; + excludeDexes?: string[]; + restrictIntermediateTokens?: boolean; + onlyDirectRoutes?: boolean; + asLegacyTransaction?: boolean; + platformFeeBps?: number; + maxAccounts?: number; + instructionVersion?: 'V1' | 'V2'; + }; + /** Transaction parameters */ + transaction: { + userPublicKey: string; + payer?: string; + wrapAndUnwrapSol?: boolean; + useSharedAccounts?: boolean; + feeAccount?: string; + trackingAccount?: string; + prioritizationFeeLamports?: PrioritizationFeeLamports; + asLegacyTransaction?: boolean; + destinationTokenAccount?: string; + nativeDestinationAccount?: string; + dynamicComputeUnitLimit?: boolean; + skipUserAccountsRpcCalls?: boolean; + computeUnitPriceMicroLamports?: number; + blockhashSlotsToExpiry?: number; + }; +} diff --git a/packages/actions-v2/tsup.config.ts b/packages/actions-v2/tsup.config.ts index 2cf951a..bf9caaf 100644 --- a/packages/actions-v2/tsup.config.ts +++ b/packages/actions-v2/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig(options => ({ entry: { index: 'src/index.ts', 'titan/index': 'src/titan/index.ts', + 'metis/index': 'src/metis/index.ts', }, format: ['cjs', 'esm'], dts: { diff --git a/packages/fastlane/Cargo.lock b/packages/fastlane/Cargo.lock index d9ab3c8..ffe1aa2 100644 --- a/packages/fastlane/Cargo.lock +++ b/packages/fastlane/Cargo.lock @@ -232,15 +232,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "autotools" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" -dependencies = [ - "cc", -] - [[package]] name = "axum" version = "0.8.8" @@ -2405,12 +2396,16 @@ dependencies = [ [[package]] name = "protobuf-src" version = "1.1.0+21.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" dependencies = [ - "autotools", + "protoc-bin-vendored", ] +[[package]] +name = "protoc-bin-vendored" +version = "2.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e1821cd48a880fe335a80ecb6af0efdd4820fe9cd79920a413d6e15ab39351" + [[package]] name = "pulldown-cmark" version = "0.13.0" diff --git a/packages/fastlane/index.d.ts b/packages/fastlane/index.d.ts index dcb7f83..1241e81 100644 --- a/packages/fastlane/index.d.ts +++ b/packages/fastlane/index.d.ts @@ -9,6 +9,13 @@ export interface TpuClientConfig { rpcUrl: string /** WebSocket URL for slot update subscriptions. */ wsUrl: string + /** + * Optional gRPC URL for Yellowstone slot subscriptions. + * When set, this takes precedence over WebSocket tracking. + */ + grpcUrl?: string + /** Optional gRPC x-token for authenticated Yellowstone endpoints. */ + grpcXToken?: string /** Number of upcoming leaders to send transactions to (default: 2). */ fanout?: number /** Whether to pre-warm connections to upcoming leaders (default: true). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb79e75..3863b98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^3.6.2 version: 3.6.2 turbo: - specifier: ^2.6.0 - version: 2.6.0 + specifier: ^2.7.2 + version: 2.7.2 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -75,11 +75,11 @@ importers: specifier: ^0.9.0 version: 0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana/connector': - specifier: 0.1.4 - version: 0.1.4(@solana/connector-debugger@0.1.1)(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(nanostores@1.0.1)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + specifier: 0.1.7 + version: 0.1.7(@solana/connector-debugger@0.1.1)(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(nanostores@1.0.1)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/connector-debugger': specifier: 0.1.1 - version: 0.1.1(@solana/connector@0.1.4)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 0.1.1(@solana/connector@0.1.7)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/instruction-plans': specifier: ^5.0.0 version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) @@ -1860,8 +1860,8 @@ packages: '@solana/connector': workspace:* react: '>=18.0.0' - '@solana/connector@0.1.4': - resolution: {integrity: sha512-gjGYjLAc44RviD4ehp1cO3Pw/cbYz+5SSanULBrhFDydnCUWybyF8t4hOse2xdn6UXcp4kpE5wReGbO882NcZg==} + '@solana/connector@0.1.7': + resolution: {integrity: sha512-F4ls4VlEJaERl+OVsS00HuhEiB3bl2OBhr4jzUe5c+sVahVuXcYkv/qjdrh5T53u9I2xBqy5Zh5vSpQwl9inhw==} peerDependencies: '@solana/connector-debugger': '*' '@solana/web3.js': ^1.0.0 @@ -5122,38 +5122,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.6.0: - resolution: {integrity: sha512-6vHnLAubHj8Ib45Knu+oY0ZVCLO7WcibzAvt5b1E72YHqAs4y8meMAGMZM0jLqWPh/9maHDc16/qBCMxtW4pXg==} + turbo-darwin-64@2.7.2: + resolution: {integrity: sha512-dxY3X6ezcT5vm3coK6VGixbrhplbQMwgNsCsvZamS/+/6JiebqW9DKt4NwpgYXhDY2HdH00I7FWs3wkVuan4rA==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.6.0: - resolution: {integrity: sha512-IU+gWMEXNBw8H0pxvE7nPEa5p6yahxbN8g/Q4Bf0AHymsAFqsScgV0peeNbWybdmY9jk1LPbALOsF2kY1I7ZiQ==} + turbo-darwin-arm64@2.7.2: + resolution: {integrity: sha512-1bXmuwPLqNFt3mzrtYcVx1sdJ8UYb124Bf48nIgcpMCGZy3kDhgxNv1503kmuK/37OGOZbsWSQFU4I08feIuSg==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.6.0: - resolution: {integrity: sha512-CKoiJ2ZFJLCDsWdRlZg+ew1BkGn8iCEGdePhISVpjsGwkJwSVhVu49z2zKdBeL1IhcSKS2YALwp9ellNZANJxw==} + turbo-linux-64@2.7.2: + resolution: {integrity: sha512-kP+TiiMaiPugbRlv57VGLfcjFNsFbo8H64wMBCPV2270Or2TpDCBULMzZrvEsvWFjT3pBFvToYbdp8/Kw0jAQg==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.6.0: - resolution: {integrity: sha512-WroVCdCvJbrhNxNdw7XB7wHAfPPJPV+IXY+ZKNed+9VdfBu/2mQNfKnvqTuFTH7n+Pdpv8to9qwhXRTJe26upg==} + turbo-linux-arm64@2.7.2: + resolution: {integrity: sha512-VDJwQ0+8zjAfbyY6boNaWfP6RIez4ypKHxwkuB6SrWbOSk+vxTyW5/hEjytTwK8w/TsbKVcMDyvpora8tEsRFw==} cpu: [arm64] os: [linux] - turbo-windows-64@2.6.0: - resolution: {integrity: sha512-7pZo5aGQPR+A7RMtWCZHusarJ6y15LQ+o3jOmpMxTic/W6Bad+jSeqo07TWNIseIWjCVzrSv27+0odiYRYtQdA==} + turbo-windows-64@2.7.2: + resolution: {integrity: sha512-rPjqQXVnI6A6oxgzNEE8DNb6Vdj2Wwyhfv3oDc+YM3U9P7CAcBIlKv/868mKl4vsBtz4ouWpTQNXG8vljgJO+w==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.6.0: - resolution: {integrity: sha512-1Ty+NwIksQY7AtFUCPrTpcKQE7zmd/f7aRjdT+qkqGFQjIjFYctEtN7qo4vpQPBgCfS1U3ka83A2u/9CfJQ3wQ==} + turbo-windows-arm64@2.7.2: + resolution: {integrity: sha512-tcnHvBhO515OheIFWdxA+qUvZzNqqcHbLVFc1+n+TJ1rrp8prYicQtbtmsiKgMvr/54jb9jOabU62URAobnB7g==} cpu: [arm64] os: [win32] - turbo@2.6.0: - resolution: {integrity: sha512-kC5VJqOXo50k0/0jnJDDjibLAXalqT9j7PQ56so0pN+81VR4Fwb2QgIE9dTzT3phqOTQuEXkPh3sCpnv5Isz2g==} + turbo@2.7.2: + resolution: {integrity: sha512-5JIA5aYBAJSAhrhbyag1ZuMSgUZnHtI+Sq3H8D3an4fL8PeF+L1yYvbEJg47akP1PFfATMf5ehkqFnxfkmuwZQ==} hasBin: true tw-animate-css@1.4.0: @@ -6905,9 +6905,9 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/connector-debugger@0.1.1(@solana/connector@0.1.4)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/connector-debugger@0.1.1(@solana/connector@0.1.7)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: - '@solana/connector': 0.1.4(@solana/connector-debugger@0.1.1)(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(nanostores@1.0.1)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/connector': 0.1.7(@solana/connector-debugger@0.1.1)(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(nanostores@1.0.1)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/kit': 4.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) react: 19.2.3 transitivePeerDependencies: @@ -6915,7 +6915,7 @@ snapshots: - typescript - ws - '@solana/connector@0.1.4(@solana/connector-debugger@0.1.1)(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(nanostores@1.0.1)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/connector@0.1.7(@solana/connector-debugger@0.1.1)(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(nanostores@1.0.1)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@nanostores/persistent': 1.2.0(nanostores@1.0.1) '@solana-mobile/wallet-standard-mobile': 0.4.3(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.1(@babel/core@7.28.5)(@types/react@19.2.7)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3)(typescript@5.9.3) @@ -6932,7 +6932,7 @@ snapshots: '@wallet-standard/features': 1.1.0 '@wallet-ui/core': 2.1.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) optionalDependencies: - '@solana/connector-debugger': 0.1.1(@solana/connector@0.1.4)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/connector-debugger': 0.1.1(@solana/connector@0.1.7)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.3)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) react: 19.2.3 transitivePeerDependencies: @@ -10957,32 +10957,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.6.0: + turbo-darwin-64@2.7.2: optional: true - turbo-darwin-arm64@2.6.0: + turbo-darwin-arm64@2.7.2: optional: true - turbo-linux-64@2.6.0: + turbo-linux-64@2.7.2: optional: true - turbo-linux-arm64@2.6.0: + turbo-linux-arm64@2.7.2: optional: true - turbo-windows-64@2.6.0: + turbo-windows-64@2.7.2: optional: true - turbo-windows-arm64@2.6.0: + turbo-windows-arm64@2.7.2: optional: true - turbo@2.6.0: + turbo@2.7.2: optionalDependencies: - turbo-darwin-64: 2.6.0 - turbo-darwin-arm64: 2.6.0 - turbo-linux-64: 2.6.0 - turbo-linux-arm64: 2.6.0 - turbo-windows-64: 2.6.0 - turbo-windows-arm64: 2.6.0 + turbo-darwin-64: 2.7.2 + turbo-darwin-arm64: 2.7.2 + turbo-linux-64: 2.7.2 + turbo-linux-arm64: 2.7.2 + turbo-windows-64: 2.7.2 + turbo-windows-arm64: 2.7.2 tw-animate-css@1.4.0: {} From 5417a10e57da994987b60c363b1cf3fff52d09f4 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 26 Dec 2025 21:02:38 -0800 Subject: [PATCH 07/12] chore: replace old actions pacakge with actions-v2 and rename back to actions. --- CONTRIBUTING.md | 8 +- README.md | 47 +- examples/next-js/README.md | 4 +- .../components/pipeline/examples/index.ts | 1 - .../pipeline/examples/jupiter-swap.tsx | 6 +- .../pipeline/examples/pipe-multi-swap.tsx | 164 ------ .../pipeline/examples/titan-swap.tsx | 6 +- examples/next-js/package.json | 1 - packages/actions-v2/README.md | 293 ----------- packages/actions-v2/package.json | 78 --- packages/actions-v2/src/index.ts | 39 -- packages/actions-v2/tsconfig.json | 13 - packages/actions-v2/tsup.config.ts | 29 -- packages/actions/README.md | 481 +++++++----------- packages/actions/package.json | 31 +- packages/actions/src/adapters/index.ts | 19 - packages/actions/src/adapters/jupiter.ts | 278 ---------- packages/actions/src/errors.ts | 81 --- packages/actions/src/index.ts | 69 +-- .../src/metis/__tests__/convert.test.ts | 0 .../src/metis/__tests__/plan-swap.test.ts | 0 .../src/metis/client.ts | 0 .../src/metis/convert.ts | 0 .../src/metis/index.ts | 0 .../src/metis/plan-swap.ts | 2 +- .../src/metis/types.ts | 0 packages/actions/src/pipe.ts | 398 --------------- .../src/titan/__tests__/convert.test.ts | 0 .../src/titan/__tests__/plan-swap.test.ts | 0 .../src/titan/client.ts | 0 .../src/titan/convert.ts | 0 .../src/titan/index.ts | 0 .../src/titan/plan-swap.ts | 2 +- .../src/titan/types.ts | 0 packages/actions/src/types.ts | 222 -------- packages/actions/tsup.config.ts | 5 +- packages/core/src/flow/index.ts | 2 +- packages/core/src/index.ts | 2 +- pnpm-lock.yaml | 42 -- 39 files changed, 279 insertions(+), 2044 deletions(-) delete mode 100644 examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx delete mode 100644 packages/actions-v2/README.md delete mode 100644 packages/actions-v2/package.json delete mode 100644 packages/actions-v2/src/index.ts delete mode 100644 packages/actions-v2/tsconfig.json delete mode 100644 packages/actions-v2/tsup.config.ts delete mode 100644 packages/actions/src/adapters/index.ts delete mode 100644 packages/actions/src/adapters/jupiter.ts delete mode 100644 packages/actions/src/errors.ts rename packages/{actions-v2 => actions}/src/metis/__tests__/convert.test.ts (100%) rename packages/{actions-v2 => actions}/src/metis/__tests__/plan-swap.test.ts (100%) rename packages/{actions-v2 => actions}/src/metis/client.ts (100%) rename packages/{actions-v2 => actions}/src/metis/convert.ts (100%) rename packages/{actions-v2 => actions}/src/metis/index.ts (100%) rename packages/{actions-v2 => actions}/src/metis/plan-swap.ts (99%) rename packages/{actions-v2 => actions}/src/metis/types.ts (100%) delete mode 100644 packages/actions/src/pipe.ts rename packages/{actions-v2 => actions}/src/titan/__tests__/convert.test.ts (100%) rename packages/{actions-v2 => actions}/src/titan/__tests__/plan-swap.test.ts (100%) rename packages/{actions-v2 => actions}/src/titan/client.ts (100%) rename packages/{actions-v2 => actions}/src/titan/convert.ts (100%) rename packages/{actions-v2 => actions}/src/titan/index.ts (100%) rename packages/{actions-v2 => actions}/src/titan/plan-swap.ts (99%) rename packages/{actions-v2 => actions}/src/titan/types.ts (100%) delete mode 100644 packages/actions/src/types.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 921bc01..f5bfb8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ Thank you for your interest in contributing to Pipeit! This document provides gu This is a monorepo managed with Turbo and pnpm workspaces: - `packages/core/` - Main transaction builder with execution strategies, Flow API, and Kit integration -- `packages/actions/` - High-level DeFi actions with pluggable protocol adapters +- `packages/actions/` - InstructionPlan factories for DeFi (Titan, Metis) - `packages/fastlane/` - Native Rust QUIC client for direct TPU submission (NAPI) - `examples/next-js/` - Next.js example application demonstrating usage @@ -103,9 +103,9 @@ pnpm test ### @pipeit/actions -- Follow adapter pattern for protocol integrations -- Ensure adapters are pluggable and testable -- Document adapter interfaces and requirements +- Build Kit-compatible InstructionPlans +- Follow existing Titan/Metis patterns for new integrations +- Document quote/route/plan building pipeline - Consider API rate limits and error handling ### @pipeit/fastlane diff --git a/README.md b/README.md index f4b32d5..8c6ab2b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Built on modern Solana libraries (@solana/kit) with a focus on type safety, deve | Package | Description | Docs | |---------|-------------|------| | [@pipeit/core](./packages/core) | Transaction builder with smart defaults, flows, and execution strategies | [README](./packages/core/README.md) | -| [@pipeit/actions](./packages/actions) | High-level DeFi actions with pluggable adapters | [README](./packages/actions/README.md) | +| [@pipeit/actions](./packages/actions) | InstructionPlan factories for DeFi (Titan, Metis) | [README](./packages/actions/README.md) | | [@pipeit/fastlane](./packages/fastlane) | Native Rust QUIC client for direct TPU submission | [Package](./packages/fastlane) | ## Package Overview @@ -35,12 +35,12 @@ The foundation package for transaction building: - Kit instruction-plans integration - Server exports for server components based TPU handlers -### @pipeit/actions (WIP) -High-level DeFi operations: -- Simple, composable API for swaps and other DeFi actions -- Pluggable adapters (Jupiter, with more coming) -- Automatic address lookup table handling -- Lifecycle hooks for monitoring execution +### @pipeit/actions +Composable InstructionPlan factories for DeFi: +- Kit-compatible InstructionPlans for swap operations +- Titan and Metis aggregator integration +- Address lookup table support +- Composable with Kit's plan combinators ### @pipeit/fastlane Ultra-fast transaction submission: @@ -57,7 +57,7 @@ Ultra-fast transaction submission: pipeit/ ├── packages/ │ ├── @pipeit/core # Transaction builder, flows, execution -│ ├── @pipeit/actions # High-level DeFi actions +│ ├── @pipeit/actions # InstructionPlan factories for DeFi │ └── @pipeit/fastlane # Native QUIC TPU client └── examples/ └── next-js/ # Example application @@ -74,7 +74,7 @@ pipeit/ # Transaction builder (recommended starting point) pnpm install @pipeit/core @solana/kit -# High-level DeFi actions +# DeFi operations (swaps via Titan/Metis) pnpm install @pipeit/actions @pipeit/core @solana/kit # TPU direct submission (server-side only) @@ -126,22 +126,29 @@ const result = await createFlow({ rpc, rpcSubscriptions, signer }) ### DeFi Swap ```typescript -import { pipe } from '@pipeit/actions'; -import { jupiter } from '@pipeit/actions/adapters'; +import { getTitanSwapPlan } from '@pipeit/actions/titan'; +import { executePlan } from '@pipeit/core'; -const result = await pipe({ - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, -}) - .swap({ +// Get a swap plan from Titan +const { plan, lookupTableAddresses, quote } = await getTitanSwapPlan({ + swap: { inputMint: 'So11111111111111111111111111111111111111112', // SOL outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC amount: 100_000_000n, // 0.1 SOL slippageBps: 50, - }) - .execute(); + }, + transaction: { + userPublicKey: signer.address, + }, +}); + +// Execute with ALT support +await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + lookupTableAddresses, +}); ``` ## Execution Strategies diff --git a/examples/next-js/README.md b/examples/next-js/README.md index 1e23a62..82eea22 100644 --- a/examples/next-js/README.md +++ b/examples/next-js/README.md @@ -37,7 +37,7 @@ Interactive demos of various pipeline patterns with real mainnet transactions: | **Batched Transfers** | Multiple transfers batched into one atomic transaction | | **Mixed Pipeline** | Instruction and transaction steps - shows when batching breaks | | **Jupiter Swap** | Token swap using Jupiter aggregator | -| **Pipe Multi-Swap** | SOL → USDC → BONK sequential swaps with Flow orchestration | +| **Titan Swap** | Token swap using Titan aggregator | | **Jito Bundle** | MEV-protected bundle submission with Jito tip instructions | | **TPU Direct** | Direct QUIC submission to validator TPU - bypass RPC for max speed | @@ -102,7 +102,7 @@ Multi-step examples demonstrate: ## Dependencies - `@pipeit/core` - Transaction builder -- `@pipeit/actions` - DeFi actions (Jupiter swaps) +- `@pipeit/actions` - DeFi actions (Titan, Metis swaps) - `@pipeit/fastlane` - TPU direct submission - `@solana/kit` - Solana primitives - `@solana/connector` - Wallet connection diff --git a/examples/next-js/components/pipeline/examples/index.ts b/examples/next-js/components/pipeline/examples/index.ts index 860712a..7f15b9b 100644 --- a/examples/next-js/components/pipeline/examples/index.ts +++ b/examples/next-js/components/pipeline/examples/index.ts @@ -4,7 +4,6 @@ export { useMixedPipeline, mixedPipelineCode } from './mixed-pipeline'; export { useInstructionPlanPipeline, instructionPlanCode } from './instruction-plan'; export { useJupiterSwapPipeline, jupiterSwapCode } from './jupiter-swap'; export { useTitanSwapPipeline, titanSwapCode } from './titan-swap'; -export { usePipeMultiSwapPipeline, pipeMultiSwapCode } from './pipe-multi-swap'; export { useJitoBundlePipeline, jitoBundleCode } from './jito-bundle'; export { useTpuDirectPipeline, diff --git a/examples/next-js/components/pipeline/examples/jupiter-swap.tsx b/examples/next-js/components/pipeline/examples/jupiter-swap.tsx index e56d5a1..468586b 100644 --- a/examples/next-js/components/pipeline/examples/jupiter-swap.tsx +++ b/examples/next-js/components/pipeline/examples/jupiter-swap.tsx @@ -8,7 +8,7 @@ import { metisInstructionToKit, type QuoteResponse, type SwapInstructionsResponse, -} from '@pipeit/actions-v2/metis'; +} from '@pipeit/actions/metis'; import { address } from '@solana/kit'; // Token addresses @@ -19,7 +19,7 @@ const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const JUPITER_PROXY_URL = '/api/jupiter'; /** - * Example: Jupiter Swap using @pipeit/actions-v2/metis + * Example: Jupiter Swap using @pipeit/actions/metis * * This demonstrates using the Metis module to get swap instructions, * then executing them via TransactionBuilder with full control. @@ -142,7 +142,7 @@ export function useJupiterSwapPipeline() { return visualPipeline; } -export const jupiterSwapCode = `import { createMetisClient, metisInstructionToKit } from '@pipeit/actions-v2/metis' +export const jupiterSwapCode = `import { createMetisClient, metisInstructionToKit } from '@pipeit/actions/metis' import { TransactionBuilder } from '@pipeit/core' import { address } from '@solana/kit' diff --git a/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx b/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx deleted file mode 100644 index c33c6ed..0000000 --- a/examples/next-js/components/pipeline/examples/pipe-multi-swap.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { createFlow, type FlowConfig } from '@pipeit/core'; -import { VisualPipeline } from '@/lib/visual-pipeline'; -import { jupiter } from '@pipeit/actions/adapters'; - -// Token addresses -const SOL_MINT = 'So11111111111111111111111111111111111111112'; -const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; -const BONK_MINT = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'; - -/** - * Example: Multi-Swap Pipeline using @pipeit/actions - * - * Demonstrates the power of the Pipe API: - * - Sequential swaps with automatic transaction management - * - SOL → USDC, then USDC → BONK - * - Each swap in its own transaction (Solana size limits) - * - Flow orchestrates the sequence with proper blockhash handling - */ -export function usePipeMultiSwapPipeline() { - const visualPipeline = useMemo(() => { - const jupiterAdapter = jupiter(); - - const flowFactory = (config: FlowConfig) => - createFlow(config) - // Transaction 1: SOL → USDC - .transaction('swap-sol-usdc', async ctx => { - console.log('[Multi-Swap] Step 1: SOL → USDC'); - - const swapAction = jupiterAdapter.swap({ - inputMint: SOL_MINT, - outputMint: USDC_MINT, - amount: 10_000_000n, // 0.01 SOL - slippageBps: 50, - }); - - const result = await swapAction({ - signer: ctx.signer, - rpc: ctx.rpc as any, - rpcSubscriptions: ctx.rpcSubscriptions as any, - }); - - const { TransactionBuilder } = await import('@pipeit/core'); - const { address } = await import('@solana/kit'); - - const lookupTables = result.addressLookupTableAddresses ?? []; - const lookupTableAddrs = lookupTables.map((addr: string) => address(addr)); - - const { value: blockhash } = await (ctx.rpc as any).getLatestBlockhash().send(); - - const signature = await new TransactionBuilder({ - rpc: ctx.rpc as any, - computeUnits: 300_000, - lookupTableAddresses: lookupTableAddrs.length > 0 ? lookupTableAddrs : undefined, - priorityFee: 'high', - }) - .setFeePayerSigner(ctx.signer) - .setBlockhashLifetime(blockhash.blockhash, blockhash.lastValidBlockHeight) - .addInstructions(result.instructions) - .execute({ - rpcSubscriptions: ctx.rpcSubscriptions as any, - commitment: 'confirmed', - }); - - console.log('[Multi-Swap] SOL → USDC complete:', signature); - return { signature }; - }) - // Transaction 2: USDC → BONK - .transaction('swap-usdc-bonk', async ctx => { - console.log('[Multi-Swap] Step 2: USDC → BONK'); - - const swapAction = jupiterAdapter.swap({ - inputMint: USDC_MINT, - outputMint: BONK_MINT, - amount: 100_000n, // 0.1 USDC - slippageBps: 100, - }); - - const result = await swapAction({ - signer: ctx.signer, - rpc: ctx.rpc as any, - rpcSubscriptions: ctx.rpcSubscriptions as any, - }); - - const { TransactionBuilder } = await import('@pipeit/core'); - const { address } = await import('@solana/kit'); - - const lookupTables = result.addressLookupTableAddresses ?? []; - const lookupTableAddrs = lookupTables.map((addr: string) => address(addr)); - - const { value: blockhash } = await (ctx.rpc as any).getLatestBlockhash().send(); - - const signature = await new TransactionBuilder({ - rpc: ctx.rpc as any, - computeUnits: 300_000, - lookupTableAddresses: lookupTableAddrs.length > 0 ? lookupTableAddrs : undefined, - priorityFee: 'high', - }) - .setFeePayerSigner(ctx.signer) - .setBlockhashLifetime(blockhash.blockhash, blockhash.lastValidBlockHeight) - .addInstructions(result.instructions) - .execute({ - rpcSubscriptions: ctx.rpcSubscriptions as any, - commitment: 'confirmed', - }); - - console.log('[Multi-Swap] USDC → BONK complete:', signature); - return { signature }; - }); - - return new VisualPipeline('pipe-multi-swap', flowFactory, [ - { name: 'swap-sol-usdc', type: 'transaction' }, - { name: 'swap-usdc-bonk', type: 'transaction' }, - ]); - }, []); - - return visualPipeline; -} - -export const pipeMultiSwapCode = `import { pipe } from '@pipeit/actions' -import { jupiter } from '@pipeit/actions/adapters' - -// Token addresses -const SOL = 'So11111111111111111111111111111111111111112' -const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' -const BONK = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263' - -// Multi-swap flow: SOL → USDC → BONK -// Each swap needs its own transaction due to size constraints -const config = { - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, - priorityFee: 'high', -} - -// Swap 1: SOL → USDC -const result1 = await pipe(config) - .swap({ - inputMint: SOL, - outputMint: USDC, - amount: 10_000_000n, // 0.01 SOL - slippageBps: 50, - }) - .onActionStart(() => console.log('Building SOL → USDC swap...')) - .execute() - -console.log('Swap 1:', result1.signature) - -// Swap 2: USDC → BONK -const result2 = await pipe(config) - .swap({ - inputMint: USDC, - outputMint: BONK, - amount: 100_000n, // 0.1 USDC - slippageBps: 100, - }) - .onActionStart(() => console.log('Building USDC → BONK swap...')) - .execute() - -console.log('Swap 2:', result2.signature)`; diff --git a/examples/next-js/components/pipeline/examples/titan-swap.tsx b/examples/next-js/components/pipeline/examples/titan-swap.tsx index 619da74..0edb3fd 100644 --- a/examples/next-js/components/pipeline/examples/titan-swap.tsx +++ b/examples/next-js/components/pipeline/examples/titan-swap.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { executePlan, createFlow, type FlowConfig, type TransactionPlanResult } from '@pipeit/core'; import { VisualPipeline } from '@/lib/visual-pipeline'; -import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; +import { getTitanSwapPlan } from '@pipeit/actions/titan'; import { getSignatureFromTransaction } from '@solana/kit'; // Token addresses @@ -14,7 +14,7 @@ const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const TITAN_PROXY_URL = '/api/titan'; /** - * Example: Titan Swap using @pipeit/actions-v2 + * Example: Titan Swap using @pipeit/actions * * This demonstrates using the new InstructionPlan-first approach with Titan, * including ALT (Address Lookup Table) support for optimal transaction packing. @@ -74,7 +74,7 @@ export function useTitanSwapPipeline() { return visualPipeline; } -export const titanSwapCode = `import { getTitanSwapPlan, TITAN_DEMO_BASE_URLS } from '@pipeit/actions-v2/titan' +export const titanSwapCode = `import { getTitanSwapPlan, TITAN_DEMO_BASE_URLS } from '@pipeit/actions/titan' import { executePlan } from '@pipeit/core' import { getSignatureFromTransaction } from '@solana/kit' diff --git a/examples/next-js/package.json b/examples/next-js/package.json index 317a87e..a24216b 100644 --- a/examples/next-js/package.json +++ b/examples/next-js/package.json @@ -12,7 +12,6 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@pipeit/actions": "workspace:*", - "@pipeit/actions-v2": "workspace:*", "@pipeit/core": "workspace:*", "@pipeit/fastlane": "workspace:*", "@radix-ui/react-accordion": "^1.2.12", diff --git a/packages/actions-v2/README.md b/packages/actions-v2/README.md deleted file mode 100644 index 287700b..0000000 --- a/packages/actions-v2/README.md +++ /dev/null @@ -1,293 +0,0 @@ -# @pipeit/actions-v2 - -Composable InstructionPlan factories for Solana DeFi, starting with Titan integration. - -This package provides Kit-compatible `InstructionPlan` factories that can be: -- Executed directly with `@pipeit/core`'s `executePlan` -- Composed with other InstructionPlans using Kit's plan combinators -- Used by anyone in the Kit ecosystem - -## Installation - -```bash -pnpm install @pipeit/actions-v2 @pipeit/core @solana/kit -``` - -## Quick Start - -```typescript -import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; -import { executePlan } from '@pipeit/core'; -import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit'; - -const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com'); -const rpcSubscriptions = createSolanaRpcSubscriptions('wss://api.mainnet-beta.solana.com'); - -// Get a swap plan from Titan -const { plan, lookupTableAddresses, quote } = await getTitanSwapPlan({ - swap: { - inputMint: 'So11111111111111111111111111111111111111112', // SOL - outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC - amount: 1_000_000_000n, // 1 SOL - slippageBps: 50, // 0.5% - }, - transaction: { - userPublicKey: signer.address, - createOutputTokenAccount: true, - }, -}); - -console.log(`Swapping 1 SOL for ~${quote.outputAmount / 1_000_000n} USDC`); - -// Execute with ALT support for optimal transaction packing -await executePlan(plan, { - rpc, - rpcSubscriptions, - signer, - lookupTableAddresses, -}); -``` - -## Titan API - -### `getTitanSwapPlan` - -The main entry point that fetches a quote, selects the best route, and returns a composable plan. - -```typescript -import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; - -const { plan, lookupTableAddresses, quote, providerId, route } = await getTitanSwapPlan({ - swap: { - inputMint: 'So11111111111111111111111111111111111111112', - outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - amount: 1_000_000_000n, - slippageBps: 50, - // Optional filters - dexes: ['Raydium', 'Orca'], // Only use these DEXes - excludeDexes: ['Phoenix'], // Exclude these DEXes - onlyDirectRoutes: false, // Allow multi-hop routes - providers: ['titan'], // Only use specific providers - }, - transaction: { - userPublicKey: signer.address, - createOutputTokenAccount: true, - closeInputTokenAccount: false, - }, -}, { - // Optional: specify a provider - providerId: 'titan', -}); -``` - -### Lower-Level APIs - -For more control, you can use the individual functions: - -```typescript -import { - createTitanClient, - TITAN_DEMO_BASE_URLS, - getTitanSwapQuote, - selectTitanRoute, - getTitanSwapInstructionPlanFromRoute, -} from '@pipeit/actions-v2/titan'; - -// Create a client -const client = createTitanClient({ - // Option A: pick a demo region (us1 | jp1 | de1) - demoRegion: 'us1', - // Option B: specify a full base URL (demo or production) - // baseUrl: TITAN_DEMO_BASE_URLS.jp1, - // baseUrl: 'https://api.titan.ag/api/v1', - authToken: 'optional-jwt-for-fees', -}); - -// Get quotes from all providers -const quotes = await getTitanSwapQuote(client, { - swap: { inputMint, outputMint, amount }, - transaction: { userPublicKey }, -}); - -// Select the best route (or a specific provider) -const { providerId, route } = selectTitanRoute(quotes, { - providerId: 'titan', // Optional: use specific provider -}); - -// Build the instruction plan -const plan = getTitanSwapInstructionPlanFromRoute(route); - -// Extract ALT addresses -const lookupTableAddresses = route.addressLookupTables.map(titanPubkeyToAddress); -``` - -## Composing Plans - -The real power of InstructionPlans is composition. Combine multiple plans: - -```typescript -import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; -import { - sequentialInstructionPlan, - parallelInstructionPlan, - singleInstructionPlan, -} from '@solana/instruction-plans'; -import { executePlan } from '@pipeit/core'; - -// Swap SOL → USDC -const swapResult = await getTitanSwapPlan({ - swap: { - inputMint: SOL_MINT, - outputMint: USDC_MINT, - amount: 10_000_000_000n, // 10 SOL - }, - transaction: { userPublicKey: signer.address }, -}); - -// Add a transfer instruction -const transferPlan = singleInstructionPlan(transferInstruction); - -// Combine: swap then transfer -const combinedPlan = sequentialInstructionPlan([ - swapResult.plan, - transferPlan, -]); - -// Execute with all ALTs -await executePlan(combinedPlan, { - rpc, - rpcSubscriptions, - signer, - lookupTableAddresses: swapResult.lookupTableAddresses, -}); -``` - -## ALT (Address Lookup Table) Support - -Titan swaps often require Address Lookup Tables to stay under transaction size limits. The `@pipeit/core` `executePlan` function handles this automatically: - -1. **Planner-time compression**: ALTs are used during transaction planning, so Kit can pack more instructions per transaction. -2. **Executor-time compression**: Messages are compressed before simulation and signing, ensuring what you simulate is what you send. - -```typescript -// Option 1: Pass ALT addresses (core will fetch them) -await executePlan(plan, { - rpc, - rpcSubscriptions, - signer, - lookupTableAddresses: swapResult.lookupTableAddresses, -}); - -// Option 2: Pre-fetch ALT data yourself -import { fetchAddressLookupTables } from '@pipeit/core'; - -const addressesByLookupTable = await fetchAddressLookupTables( - rpc, - swapResult.lookupTableAddresses, -); - -await executePlan(plan, { - rpc, - rpcSubscriptions, - signer, - addressesByLookupTable, -}); -``` - -## Swap Modes - -Titan supports two swap modes: - -- **ExactIn** (default): Swap exactly N input tokens, get variable output -- **ExactOut**: Get exactly N output tokens, use variable input - -```typescript -// ExactIn: Swap exactly 1 SOL, get as much USDC as possible -const exactInResult = await getTitanSwapPlan({ - swap: { - inputMint: SOL_MINT, - outputMint: USDC_MINT, - amount: 1_000_000_000n, // 1 SOL - swapMode: 'ExactIn', - }, - transaction: { userPublicKey: signer.address }, -}); - -// ExactOut: Get exactly 100 USDC, use as little SOL as possible -const exactOutResult = await getTitanSwapPlan({ - swap: { - inputMint: SOL_MINT, - outputMint: USDC_MINT, - amount: 100_000_000n, // 100 USDC - swapMode: 'ExactOut', - }, - transaction: { userPublicKey: signer.address }, -}); -``` - -## Error Handling - -```typescript -import { - TitanApiError, - NoRoutesError, - ProviderNotFoundError, - NoInstructionsError, -} from '@pipeit/actions-v2/titan'; - -try { - const result = await getTitanSwapPlan({ ... }); -} catch (error) { - if (error instanceof TitanApiError) { - console.error(`API error (${error.statusCode}): ${error.responseBody}`); - } else if (error instanceof NoRoutesError) { - console.error(`No routes available for quote ${error.quoteId}`); - } else if (error instanceof ProviderNotFoundError) { - console.error(`Provider ${error.providerId} not found. Available: ${error.availableProviders}`); - } else if (error instanceof NoInstructionsError) { - console.error('Route has no instructions (may only provide pre-built transaction)'); - } -} -``` - -## Type Exports - -### Client - -- `createTitanClient` - Create a Titan REST API client -- `TitanClient` - Client interface -- `TitanClientConfig` - Client configuration - -### Plan Building - -- `getTitanSwapPlan` - Main entry point -- `getTitanSwapQuote` - Fetch raw quotes -- `selectTitanRoute` - Select best route from quotes -- `getTitanSwapInstructionPlanFromRoute` - Build plan from route -- `TitanSwapPlanResult` - Result type -- `TitanSwapPlanOptions` - Options type - -### Types - -- `SwapQuoteParams` - Quote request parameters -- `SwapQuotes` - Quote response -- `SwapRoute` - Individual route -- `RoutePlanStep` - Step in a route -- `SwapMode` - 'ExactIn' | 'ExactOut' - -### Errors - -- `TitanApiError` - API request failed -- `NoRoutesError` - No routes available -- `ProviderNotFoundError` - Requested provider not found -- `NoInstructionsError` - Route has no instructions - -### Conversion Utilities - -- `titanInstructionToKit` - Convert Titan instruction to Kit -- `titanPubkeyToAddress` - Convert Titan pubkey to Kit Address -- `encodeBase58` - Encode bytes as base58 - -## License - -MIT diff --git a/packages/actions-v2/package.json b/packages/actions-v2/package.json deleted file mode 100644 index 9031db8..0000000 --- a/packages/actions-v2/package.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "@pipeit/actions-v2", - "version": "0.1.0", - "description": "Composable DeFi InstructionPlan factories for Solana (Titan-first)", - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "typings": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./titan": { - "types": "./dist/titan/index.d.ts", - "import": "./dist/titan/index.js", - "require": "./dist/titan/index.cjs" - }, - "./metis": { - "types": "./dist/metis/index.d.ts", - "import": "./dist/metis/index.js", - "require": "./dist/metis/index.cjs" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "clean": "rm -rf dist", - "lint": "eslint src", - "typecheck": "tsc --noEmit", - "test": "vitest" - }, - "keywords": [ - "solana", - "defi", - "swap", - "titan", - "jupiter", - "metis", - "instruction-plans", - "transaction" - ], - "license": "MIT", - "peerDependencies": { - "@pipeit/core": "^0.2.5", - "@solana/kit": "^5.0.0", - "@solana/addresses": "*", - "@solana/instruction-plans": "*", - "@solana/instructions": "*", - "@solana/rpc": "*", - "@solana/rpc-subscriptions": "*", - "@solana/signers": "*", - "@solana/transactions": "*" - }, - "dependencies": { - "@msgpack/msgpack": "^3.0.0" - }, - "devDependencies": { - "@pipeit/core": "workspace:*", - "@solana/addresses": "*", - "@solana/instruction-plans": "*", - "@solana/instructions": "*", - "@solana/kit": "^5.0.0", - "@solana/rpc": "*", - "@solana/rpc-subscriptions": "*", - "@solana/signers": "*", - "@solana/transactions": "*", - "@types/node": "^24", - "tsup": "^8.5.0", - "typescript": "^5.8.3", - "vitest": "^3.2.4" - } -} diff --git a/packages/actions-v2/src/index.ts b/packages/actions-v2/src/index.ts deleted file mode 100644 index 9a1ec05..0000000 --- a/packages/actions-v2/src/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @pipeit/actions-v2 - Composable InstructionPlan factories for Solana DeFi. - * - * This package provides Kit-compatible InstructionPlan factories that can be: - * - Executed directly with `@pipeit/core`'s executePlan - * - Composed with other InstructionPlans using Kit's plan combinators - * - Used by anyone in the Kit ecosystem - * - * @example - * ```ts - * import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; - * import { executePlan } from '@pipeit/core'; - * - * // Get a swap plan from Titan - * const { plan, lookupTableAddresses } = await getTitanSwapPlan({ - * inputMint: SOL_MINT, - * outputMint: USDC_MINT, - * amount: 1_000_000_000n, - * user: signer.address, - * }); - * - * // Execute with ALT support - * await executePlan(plan, { - * rpc, - * rpcSubscriptions, - * signer, - * lookupTableAddresses, - * }); - * ``` - * - * @packageDocumentation - */ - -// Re-export Titan module -export * from './titan/index.js'; - -// Note: Metis module is NOT re-exported here to avoid naming conflicts -// (both Titan and Metis have SwapMode, RoutePlanStep, etc.) -// Import Metis directly via: import { ... } from '@pipeit/actions-v2/metis' diff --git a/packages/actions-v2/tsconfig.json b/packages/actions-v2/tsconfig.json deleted file mode 100644 index 5544585..0000000 --- a/packages/actions-v2/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "incremental": false, - "composite": false, - "module": "ES2022", - "moduleResolution": "bundler" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/packages/actions-v2/tsup.config.ts b/packages/actions-v2/tsup.config.ts deleted file mode 100644 index bf9caaf..0000000 --- a/packages/actions-v2/tsup.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig(options => ({ - entry: { - index: 'src/index.ts', - 'titan/index': 'src/titan/index.ts', - 'metis/index': 'src/metis/index.ts', - }, - format: ['cjs', 'esm'], - dts: { - resolve: true, - }, - tsconfig: './tsconfig.json', - splitting: false, - sourcemap: true, - clean: true, - treeshake: true, - external: [ - '@pipeit/core', - '@solana/kit', - '@solana/addresses', - '@solana/instruction-plans', - '@solana/instructions', - '@solana/rpc', - '@solana/rpc-subscriptions', - '@solana/signers', - '@solana/transactions', - ], -})); diff --git a/packages/actions/README.md b/packages/actions/README.md index 3f63bbc..e8cfb06 100644 --- a/packages/actions/README.md +++ b/packages/actions/README.md @@ -1,6 +1,11 @@ # @pipeit/actions -High-level DeFi actions for Solana with a simple, composable API. Uses pluggable adapters to avoid vendor lock-in. +Composable InstructionPlan factories for Solana DeFi, starting with Titan integration. + +This package provides Kit-compatible `InstructionPlan` factories that can be: +- Executed directly with `@pipeit/core`'s `executePlan` +- Composed with other InstructionPlans using Kit's plan combinators +- Used by anyone in the Kit ecosystem ## Installation @@ -11,383 +16,277 @@ pnpm install @pipeit/actions @pipeit/core @solana/kit ## Quick Start ```typescript -import { pipe } from '@pipeit/actions'; -import { jupiter } from '@pipeit/actions/adapters'; +import { getTitanSwapPlan } from '@pipeit/actions/titan'; +import { executePlan } from '@pipeit/core'; import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit'; const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com'); const rpcSubscriptions = createSolanaRpcSubscriptions('wss://api.mainnet-beta.solana.com'); -// Swap SOL for USDC using Jupiter -const result = await pipe({ - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, -}) - .swap({ +// Get a swap plan from Titan +const { plan, lookupTableAddresses, quote } = await getTitanSwapPlan({ + swap: { inputMint: 'So11111111111111111111111111111111111111112', // SOL outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC - amount: 10_000_000n, // 0.1 SOL + amount: 1_000_000_000n, // 1 SOL slippageBps: 50, // 0.5% - }) - .execute(); - -console.log('Transaction:', result.signature); -``` - -## Pipe API - -The `pipe()` function creates a fluent builder for composing DeFi actions into atomic transactions. - -### Configuration - -```typescript -interface PipeConfig { - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; - signer: TransactionSigner; - adapters?: { - swap?: SwapAdapter; - }; - priorityFee?: PriorityFeeLevel | PriorityFeeConfig; - computeUnits?: 'auto' | number; - autoRetry?: boolean | { maxAttempts: number; backoff: 'linear' | 'exponential' }; - logLevel?: 'silent' | 'minimal' | 'verbose'; -} -``` - -### Adding Actions - -#### Swap Action - -```typescript -pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }).swap({ - inputMint: 'So11111111111111111111111111111111111111112', - outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - amount: 10_000_000n, - slippageBps: 50, // Optional, default: 50 (0.5%) + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + }, }); -``` - -#### Custom Actions -```typescript -pipe({ rpc, rpcSubscriptions, signer }).add(async ctx => ({ - instructions: [myCustomInstruction], - computeUnits: 200_000, // Optional hint - addressLookupTableAddresses: ['...'], // Optional ALT addresses - data: { custom: 'data' }, // Optional metadata -})); -``` +console.log(`Swapping 1 SOL for ~${quote.outputAmount / 1_000_000n} USDC`); -### Executing - -```typescript -// Basic execution -const result = await pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ ... }) - .execute(); - -console.log('Signature:', result.signature); -console.log('Action results:', result.actionResults); - -// With options -const result = await pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ ... }) - .execute({ - commitment: 'confirmed', - abortSignal: abortController.signal - }); -``` - -### Simulating - -Test action sequences before execution: - -```typescript -const simulation = await pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ ... }) - .simulate(); - -if (simulation.success) { - console.log('Estimated compute units:', simulation.unitsConsumed); - console.log('Logs:', simulation.logs); -} else { - console.error('Simulation failed:', simulation.error); -} +// Execute with ALT support for optimal transaction packing +await executePlan(plan, { + rpc, + rpcSubscriptions, + signer, + lookupTableAddresses, +}); ``` -### Lifecycle Hooks - -Monitor action execution progress: - -```typescript -pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ ... }) - .onActionStart((index) => console.log(`Starting action ${index}`)) - .onActionComplete((index, result) => { - console.log(`Action ${index} completed with ${result.instructions.length} instructions`); - }) - .onActionError((index, error) => { - console.error(`Action ${index} failed:`, error); - }) - .execute(); -``` +## Titan API -### Chaining Multiple Actions +### `getTitanSwapPlan` -All actions in a pipe execute atomically in a single transaction: +The main entry point that fetches a quote, selects the best route, and returns a composable plan. ```typescript -const result = await pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ inputMint: SOL, outputMint: USDC, amount: 10_000_000n }) - .add(async ctx => ({ - instructions: [transferInstruction], - })) - .swap({ inputMint: USDC, outputMint: BONK, amount: 5_000_000n }) - .execute(); +import { getTitanSwapPlan } from '@pipeit/actions/titan'; + +const { plan, lookupTableAddresses, quote, providerId, route } = await getTitanSwapPlan({ + swap: { + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: 1_000_000_000n, + slippageBps: 50, + // Optional filters + dexes: ['Raydium', 'Orca'], // Only use these DEXes + excludeDexes: ['Phoenix'], // Exclude these DEXes + onlyDirectRoutes: false, // Allow multi-hop routes + providers: ['titan'], // Only use specific providers + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + closeInputTokenAccount: false, + }, +}, { + // Optional: specify a provider + providerId: 'titan', +}); ``` -## Adapters - -Adapters provide protocol-specific implementations for actions. Pipeit includes built-in adapters and supports custom adapters. +### Lower-Level APIs -### Jupiter Adapter - -Jupiter adapter for token swaps across all Solana DEXs: +For more control, you can use the individual functions: ```typescript -import { jupiter } from '@pipeit/actions/adapters'; - -// Default configuration -const adapter = jupiter(); - -// Custom configuration -const adapter = jupiter({ - apiUrl: 'https://lite-api.jup.ag/swap/v1', // Default - wrapAndUnwrapSol: true, // Default: auto-wrap/unwrap SOL - dynamicComputeUnitLimit: true, // Default: use Jupiter's CU estimate - prioritizationFeeLamports: 'auto', // Default: use Jupiter's fee estimate +import { + createTitanClient, + TITAN_DEMO_BASE_URLS, + getTitanSwapQuote, + selectTitanRoute, + getTitanSwapInstructionPlanFromRoute, +} from '@pipeit/actions/titan'; + +// Create a client +const client = createTitanClient({ + // Option A: pick a demo region (us1 | jp1 | de1) + demoRegion: 'us1', + // Option B: specify a full base URL (demo or production) + // baseUrl: TITAN_DEMO_BASE_URLS.jp1, + // baseUrl: 'https://api.titan.ag/api/v1', + authToken: 'optional-jwt-for-fees', }); -``` -**Configuration Options:** - -- `apiUrl` - Base URL for Jupiter API (default: `https://lite-api.jup.ag/swap/v1`) -- `wrapAndUnwrapSol` - Automatically wrap/unwrap SOL (default: `true`) -- `dynamicComputeUnitLimit` - Use Jupiter's compute unit estimate (default: `true`) -- `prioritizationFeeLamports` - Priority fee in lamports or `'auto'` (default: `'auto'`) +// Get quotes from all providers +const quotes = await getTitanSwapQuote(client, { + swap: { inputMint, outputMint, amount }, + transaction: { userPublicKey }, +}); -### Creating Custom Adapters +// Select the best route (or a specific provider) +const { providerId, route } = selectTitanRoute(quotes, { + providerId: 'titan', // Optional: use specific provider +}); -Implement the `SwapAdapter` interface: +// Build the instruction plan +const plan = getTitanSwapInstructionPlanFromRoute(route); -```typescript -import type { SwapAdapter, SwapParams, ActionContext } from '@pipeit/actions'; - -const mySwapAdapter: SwapAdapter = { - swap: (params: SwapParams) => async (ctx: ActionContext) => { - // Call your DEX API - const quote = await fetchQuote(params); - const instructions = await buildSwapInstructions(quote, ctx.signer.address); - - return { - instructions, - computeUnits: 300_000, // Optional - addressLookupTableAddresses: ['...'], // Optional - data: { - inputAmount: BigInt(quote.inAmount), - outputAmount: BigInt(quote.outAmount), - priceImpactPct: quote.priceImpact, - }, - }; - }, -}; - -// Use your custom adapter -pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: mySwapAdapter } }) - .swap({ ... }) - .execute(); +// Extract ALT addresses +const lookupTableAddresses = route.addressLookupTables.map(titanPubkeyToAddress); ``` -## Configuration +## Composing Plans -### Priority Fees +The real power of InstructionPlans is composition. Combine multiple plans: ```typescript -// Preset levels -pipe({ - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, - priorityFee: 'high', // none | low | medium | high | veryHigh -}); - -// Custom configuration -pipe({ - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, - priorityFee: { - strategy: 'percentile', - percentile: 75, +import { getTitanSwapPlan } from '@pipeit/actions/titan'; +import { + sequentialInstructionPlan, + parallelInstructionPlan, + singleInstructionPlan, +} from '@solana/instruction-plans'; +import { executePlan } from '@pipeit/core'; + +// Swap SOL → USDC +const swapResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 10_000_000_000n, // 10 SOL }, + transaction: { userPublicKey: signer.address }, }); -``` -### Compute Units +// Add a transfer instruction +const transferPlan = singleInstructionPlan(transferInstruction); -```typescript -// Auto (collects from actions or uses default) -pipe({ - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, - computeUnits: 'auto', -}); +// Combine: swap then transfer +const combinedPlan = sequentialInstructionPlan([ + swapResult.plan, + transferPlan, +]); -// Fixed limit -pipe({ +// Execute with all ALTs +await executePlan(combinedPlan, { rpc, rpcSubscriptions, signer, - adapters: { swap: jupiter() }, - computeUnits: 400_000, + lookupTableAddresses: swapResult.lookupTableAddresses, }); ``` -### Auto-Retry +## ALT (Address Lookup Table) Support + +Titan swaps often require Address Lookup Tables to stay under transaction size limits. The `@pipeit/core` `executePlan` function handles this automatically: + +1. **Planner-time compression**: ALTs are used during transaction planning, so Kit can pack more instructions per transaction. +2. **Executor-time compression**: Messages are compressed before simulation and signing, ensuring what you simulate is what you send. ```typescript -// Default retry (3 attempts, exponential backoff) -pipe({ +// Option 1: Pass ALT addresses (core will fetch them) +await executePlan(plan, { rpc, rpcSubscriptions, signer, - adapters: { swap: jupiter() }, - autoRetry: true, + lookupTableAddresses: swapResult.lookupTableAddresses, }); -// Custom retry configuration -pipe({ - rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, - autoRetry: { - maxAttempts: 5, - backoff: 'exponential', // or 'linear' - }, -}); +// Option 2: Pre-fetch ALT data yourself +import { fetchAddressLookupTables } from '@pipeit/core'; -// No retry -pipe({ +const addressesByLookupTable = await fetchAddressLookupTables( rpc, - rpcSubscriptions, - signer, - adapters: { swap: jupiter() }, - autoRetry: false, -}); -``` + swapResult.lookupTableAddresses, +); -### Logging - -```typescript -pipe({ +await executePlan(plan, { rpc, rpcSubscriptions, signer, - adapters: { swap: jupiter() }, - logLevel: 'verbose', // silent | minimal | verbose + addressesByLookupTable, }); ``` -## Address Lookup Tables +## Swap Modes + +Titan supports two swap modes: -Actions can return address lookup table addresses, which are automatically fetched and used for transaction compression: +- **ExactIn** (default): Swap exactly N input tokens, get variable output +- **ExactOut**: Get exactly N output tokens, use variable input ```typescript -const result = await pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ ... }) - .execute(); +// ExactIn: Swap exactly 1 SOL, get as much USDC as possible +const exactInResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 1_000_000_000n, // 1 SOL + swapMode: 'ExactIn', + }, + transaction: { userPublicKey: signer.address }, +}); -// Jupiter adapter automatically includes ALT addresses if needed -// Pipe fetches and applies them automatically +// ExactOut: Get exactly 100 USDC, use as little SOL as possible +const exactOutResult = await getTitanSwapPlan({ + swap: { + inputMint: SOL_MINT, + outputMint: USDC_MINT, + amount: 100_000_000n, // 100 USDC + swapMode: 'ExactOut', + }, + transaction: { userPublicKey: signer.address }, +}); ``` ## Error Handling ```typescript import { - NoActionsError, - NoAdapterError, - ActionExecutionError, - isNoActionsError, - isNoAdapterError, - isActionExecutionError -} from '@pipeit/actions'; + TitanApiError, + NoRoutesError, + ProviderNotFoundError, + NoInstructionsError, +} from '@pipeit/actions/titan'; try { - const result = await pipe({ rpc, rpcSubscriptions, signer, adapters: { swap: jupiter() } }) - .swap({ ... }) - .execute(); + const result = await getTitanSwapPlan({ ... }); } catch (error) { - if (isNoActionsError(error)) { - console.error('No actions added to pipe'); - } else if (isNoAdapterError(error)) { - console.error(`Adapter not configured: ${error.adapterName}`); - } else if (isActionExecutionError(error)) { - console.error(`Action ${error.actionIndex} failed:`, error.cause); - } + if (error instanceof TitanApiError) { + console.error(`API error (${error.statusCode}): ${error.responseBody}`); + } else if (error instanceof NoRoutesError) { + console.error(`No routes available for quote ${error.quoteId}`); + } else if (error instanceof ProviderNotFoundError) { + console.error(`Provider ${error.providerId} not found. Available: ${error.availableProviders}`); + } else if (error instanceof NoInstructionsError) { + console.error('Route has no instructions (may only provide pre-built transaction)'); + } } ``` ## Type Exports -### Main Classes +### Client -- `Pipe` - Fluent builder class +- `createTitanClient` - Create a Titan REST API client +- `TitanClient` - Client interface +- `TitanClientConfig` - Client configuration -### Functions +### Plan Building -- `pipe` - Create a new pipe instance +- `getTitanSwapPlan` - Main entry point +- `getTitanSwapQuote` - Fetch raw quotes +- `selectTitanRoute` - Select best route from quotes +- `getTitanSwapInstructionPlanFromRoute` - Build plan from route +- `TitanSwapPlanResult` - Result type +- `TitanSwapPlanOptions` - Options type ### Types -- `PipeConfig` - Configuration for creating a pipe -- `PipeResult` - Result from executing a pipe -- `ExecuteOptions` - Options for execution -- `PipeHooks` - Lifecycle hooks - -### Action Types - -- `ActionContext` - Context passed to actions -- `ActionExecutor` - Function that executes an action -- `ActionFactory` - Factory function for creating action executors -- `ActionResult` - Result returned by an action - -### Swap Types - -- `SwapParams` - Parameters for swap action -- `SwapResult` - Extended result for swap actions -- `SwapAdapter` - Interface for swap adapters +- `SwapQuoteParams` - Quote request parameters +- `SwapQuotes` - Quote response +- `SwapRoute` - Individual route +- `RoutePlanStep` - Step in a route +- `SwapMode` - 'ExactIn' | 'ExactOut' -### Error Types +### Errors -- `NoActionsError` - No actions added to pipe -- `NoAdapterError` - Required adapter not configured -- `ActionExecutionError` - Action execution failed +- `TitanApiError` - API request failed +- `NoRoutesError` - No routes available +- `ProviderNotFoundError` - Requested provider not found +- `NoInstructionsError` - Route has no instructions -### Re-exported from Core +### Conversion Utilities -- `PriorityFeeLevel` - Priority fee level type -- `PriorityFeeConfig` - Priority fee configuration -- `ActionsRpcApi` - Minimum RPC API required -- `ActionsRpcSubscriptionsApi` - Minimum RPC subscriptions API required +- `titanInstructionToKit` - Convert Titan instruction to Kit +- `titanPubkeyToAddress` - Convert Titan pubkey to Kit Address +- `encodeBase58` - Encode bytes as base58 ## License diff --git a/packages/actions/package.json b/packages/actions/package.json index 13712de..91ff7f5 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -1,7 +1,7 @@ { "name": "@pipeit/actions", - "version": "0.1.1", - "description": "High-level DeFi actions for Solana with pluggable protocol adapters", + "version": "0.1.0", + "description": "Composable DeFi InstructionPlan factories for Solana (Titan-first)", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -13,15 +13,15 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, - "./adapters": { - "types": "./dist/adapters/index.d.ts", - "import": "./dist/adapters/index.js", - "require": "./dist/adapters/index.cjs" + "./titan": { + "types": "./dist/titan/index.d.ts", + "import": "./dist/titan/index.js", + "require": "./dist/titan/index.cjs" }, - "./adapters/jupiter": { - "types": "./dist/adapters/jupiter.d.ts", - "import": "./dist/adapters/jupiter.js", - "require": "./dist/adapters/jupiter.cjs" + "./metis": { + "types": "./dist/metis/index.d.ts", + "import": "./dist/metis/index.js", + "require": "./dist/metis/index.cjs" } }, "files": [ @@ -39,24 +39,31 @@ "solana", "defi", "swap", + "titan", "jupiter", - "actions", + "metis", + "instruction-plans", "transaction" ], "license": "MIT", "peerDependencies": { - "@pipeit/core": "^0.2.1", + "@pipeit/core": "^0.2.5", "@solana/kit": "^5.0.0", "@solana/addresses": "*", + "@solana/instruction-plans": "*", "@solana/instructions": "*", "@solana/rpc": "*", "@solana/rpc-subscriptions": "*", "@solana/signers": "*", "@solana/transactions": "*" }, + "dependencies": { + "@msgpack/msgpack": "^3.0.0" + }, "devDependencies": { "@pipeit/core": "workspace:*", "@solana/addresses": "*", + "@solana/instruction-plans": "*", "@solana/instructions": "*", "@solana/kit": "^5.0.0", "@solana/rpc": "*", diff --git a/packages/actions/src/adapters/index.ts b/packages/actions/src/adapters/index.ts deleted file mode 100644 index af0fde1..0000000 --- a/packages/actions/src/adapters/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Protocol adapters for @pipeit/actions. - * - * Import adapters from this module or from their individual paths: - * - * @example - * ```ts - * // Import all adapters - * import { jupiter } from '@pipeit/actions/adapters' - * - * // Or import individually (better for tree-shaking) - * import { jupiter } from '@pipeit/actions/adapters/jupiter' - * ``` - * - * @packageDocumentation - */ - -// Jupiter adapter for swaps -export { jupiter, type JupiterConfig } from './jupiter.js'; diff --git a/packages/actions/src/adapters/jupiter.ts b/packages/actions/src/adapters/jupiter.ts deleted file mode 100644 index 21c20cb..0000000 --- a/packages/actions/src/adapters/jupiter.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * Jupiter swap adapter for @pipeit/actions. - * - * Delegates all swap logic to Jupiter's API, which handles: - * - Route finding across all Solana DEXs - * - Account resolution - * - Instruction building - * - wSOL wrapping/unwrapping - * - * @example - * ```ts - * import { pipe } from '@pipeit/actions' - * import { jupiter } from '@pipeit/actions/adapters/jupiter' - * - * await pipe({ - * rpc, - * rpcSubscriptions, - * signer, - * adapters: { swap: jupiter() } - * }) - * .swap({ inputMint: SOL, outputMint: USDC, amount: 10_000_000n }) - * .execute() - * ``` - * - * @packageDocumentation - */ - -import { address } from '@solana/addresses'; -import type { Instruction, AccountMeta, AccountRole } from '@solana/instructions'; -import type { SwapAdapter, SwapParams, ActionContext } from '../types.js'; - -/** - * Configuration options for Jupiter adapter. - */ -export interface JupiterConfig { - /** Base URL for Jupiter API (default: https://lite-api.jup.ag/swap/v1) */ - apiUrl?: string; - /** Whether to automatically wrap/unwrap SOL (default: true) */ - wrapAndUnwrapSol?: boolean; - /** Whether to use dynamic compute unit limit (default: true) */ - dynamicComputeUnitLimit?: boolean; - /** Priority fee in lamports or 'auto' (default: 'auto') */ - prioritizationFeeLamports?: number | 'auto'; -} - -/** - * Jupiter API quote response - */ -interface JupiterQuote { - inputMint: string; - outputMint: string; - inAmount: string; - outAmount: string; - priceImpactPct: string; - routePlan: Array<{ - swapInfo: { - ammKey: string; - label: string; - inputMint: string; - outputMint: string; - inAmount: string; - outAmount: string; - feeAmount: string; - feeMint: string; - }; - percent: number; - }>; -} - -/** - * Jupiter API swap instruction response - */ -interface JupiterSwapResponse { - swapInstruction: { - programId: string; - accounts: Array<{ - pubkey: string; - isSigner: boolean; - isWritable: boolean; - }>; - data: string; // base64 encoded - }; - setupInstructions?: Array<{ - programId: string; - accounts: Array<{ - pubkey: string; - isSigner: boolean; - isWritable: boolean; - }>; - data: string; - }>; - cleanupInstruction?: { - programId: string; - accounts: Array<{ - pubkey: string; - isSigner: boolean; - isWritable: boolean; - }>; - data: string; - }; - addressLookupTableAddresses?: string[]; - computeUnitLimit?: number; - simulationError?: Record; -} - -/** - * Convert Jupiter account format to Solana Kit AccountMeta - */ -function toAccountMeta(acc: { pubkey: string; isSigner: boolean; isWritable: boolean }): AccountMeta { - // AccountRole: 0=readonly, 1=writable, 2=readonly_signer, 3=writable_signer - let role: AccountRole; - if (acc.isSigner && acc.isWritable) { - role = 3 as AccountRole; // WRITABLE_SIGNER - } else if (acc.isSigner) { - role = 2 as AccountRole; // READONLY_SIGNER - } else if (acc.isWritable) { - role = 1 as AccountRole; // WRITABLE - } else { - role = 0 as AccountRole; // READONLY - } - - return { - address: address(acc.pubkey), - role, - }; -} - -/** - * Convert Jupiter instruction format to Solana Kit Instruction - */ -function toInstruction(ix: { - programId: string; - accounts: Array<{ pubkey: string; isSigner: boolean; isWritable: boolean }>; - data: string; -}): Instruction { - return { - programAddress: address(ix.programId), - accounts: ix.accounts.map(toAccountMeta), - data: Buffer.from(ix.data, 'base64'), - }; -} - -/** - * Create a Jupiter swap adapter. - * - * @param config - Optional configuration - * @returns A SwapAdapter that uses Jupiter's API - * - * @example - * ```ts - * // Default configuration - * const adapter = jupiter() - * - * // Custom API URL (for proxying in browser) - * const adapter = jupiter({ apiUrl: '/api/jupiter' }) - * ``` - */ -export function jupiter(config: JupiterConfig = {}): SwapAdapter { - const { - apiUrl = 'https://lite-api.jup.ag/swap/v1', - wrapAndUnwrapSol = true, - dynamicComputeUnitLimit = true, - prioritizationFeeLamports = 'auto', - } = config; - - return { - swap: (params: SwapParams) => async (ctx: ActionContext) => { - // Normalize all params to strings (Address is a branded string type) - const inputMint = String(params.inputMint); - const outputMint = String(params.outputMint); - const amount = String(params.amount); - const slippageBps = params.slippageBps ?? 50; - - // 1. Get quote from Jupiter - const quoteUrl = new URL(`${apiUrl}/quote`); - quoteUrl.searchParams.set('inputMint', inputMint); - quoteUrl.searchParams.set('outputMint', outputMint); - quoteUrl.searchParams.set('amount', amount); - quoteUrl.searchParams.set('slippageBps', slippageBps.toString()); - - console.log('[Jupiter] Fetching quote:', quoteUrl.toString()); - - const quoteResponse = await fetch(quoteUrl.toString()); - if (!quoteResponse.ok) { - const errorText = await quoteResponse.text(); - throw new Error(`Jupiter quote API error: ${quoteResponse.status} - ${errorText}`); - } - - const quote: JupiterQuote = await quoteResponse.json(); - console.log('[Jupiter] Quote received:', { - inAmount: quote.inAmount, - outAmount: quote.outAmount, - priceImpactPct: quote.priceImpactPct, - routes: quote.routePlan.length, - }); - - // 2. Get swap instructions from Jupiter - const swapUrl = `${apiUrl}/swap-instructions`; - const signerAddress = String(ctx.signer.address); - - console.log('[Jupiter] Fetching swap instructions for user:', signerAddress); - - const swapResponse = await fetch(swapUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - quoteResponse: quote, - userPublicKey: signerAddress, - wrapAndUnwrapSol, - dynamicComputeUnitLimit, - prioritizationFeeLamports, - }), - }); - - if (!swapResponse.ok) { - const errorText = await swapResponse.text(); - throw new Error(`Jupiter swap-instructions API error: ${swapResponse.status} - ${errorText}`); - } - - const swapData: JupiterSwapResponse = await swapResponse.json(); - - // Check for simulation errors - if (swapData.simulationError && Object.keys(swapData.simulationError).length > 0) { - console.warn('[Jupiter] Simulation error detected:', swapData.simulationError); - } - - // 3. Build instructions array - const instructions: Instruction[] = []; - - // Add setup instructions (ATA creation, wSOL wrapping, etc.) - if (swapData.setupInstructions && swapData.setupInstructions.length > 0) { - console.log('[Jupiter] Adding', swapData.setupInstructions.length, 'setup instructions'); - for (const setupIx of swapData.setupInstructions) { - instructions.push(toInstruction(setupIx)); - } - } - - // Add main swap instruction - instructions.push(toInstruction(swapData.swapInstruction)); - - // Add cleanup instruction (unwrap wSOL, etc.) - if (swapData.cleanupInstruction) { - console.log('[Jupiter] Adding cleanup instruction'); - instructions.push(toInstruction(swapData.cleanupInstruction)); - } - - console.log('[Jupiter] Built', instructions.length, 'total instructions'); - if (swapData.addressLookupTableAddresses?.length) { - console.log('[Jupiter] Lookup tables:', swapData.addressLookupTableAddresses.length); - } - - // Build the result with all fields at top level for ActionResult compatibility - const result: { - instructions: Instruction[]; - computeUnits?: number; - addressLookupTableAddresses?: string[]; - data: Record; - } = { - instructions, - // Surface ALT addresses at top level for Pipe to collect - addressLookupTableAddresses: swapData.addressLookupTableAddresses ?? [], - data: { - inputAmount: BigInt(quote.inAmount), - outputAmount: BigInt(quote.outAmount), - priceImpactPct: parseFloat(quote.priceImpactPct), - route: quote.routePlan, - }, - }; - - // Only add computeUnits if Jupiter provided it - if (swapData.computeUnitLimit !== undefined) { - result.computeUnits = swapData.computeUnitLimit; - } - - return result; - }, - }; -} diff --git a/packages/actions/src/errors.ts b/packages/actions/src/errors.ts deleted file mode 100644 index c4cd3ce..0000000 --- a/packages/actions/src/errors.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Error types for @pipeit/actions. - * - * These are actions-specific errors for common failure cases. - * For general Solana/transaction errors, use @solana/errors or @pipeit/core. - * - * @packageDocumentation - */ - -/** - * Error thrown when attempting to execute a pipe with no actions. - */ -export class NoActionsError extends Error { - constructor() { - super('No actions to execute. Add at least one action to the pipe.'); - this.name = 'NoActionsError'; - Object.setPrototypeOf(this, NoActionsError.prototype); - } -} - -/** - * Error thrown when an adapter is required but not configured. - * - * @example - * ```ts - * // This would throw NoAdapterError if swap adapter not configured - * pipe({ rpc, rpcSubscriptions, signer }) - * .swap({ inputMint: SOL, outputMint: USDC, amount: 1000000n }) - * ``` - */ -export class NoAdapterError extends Error { - constructor( - /** The type of adapter that was missing */ - public readonly adapterType: string, - ) { - super( - `No ${adapterType} adapter configured. Pass a ${adapterType} adapter in pipe config:\n` + - `pipe({ ..., adapters: { ${adapterType}: yourAdapter() } })`, - ); - this.name = 'NoAdapterError'; - Object.setPrototypeOf(this, NoAdapterError.prototype); - } -} - -/** - * Error thrown when an action fails during execution. - * Wraps the original error with the action index for debugging. - */ -export class ActionExecutionError extends Error { - constructor( - /** Index of the action that failed (0-based) */ - public readonly actionIndex: number, - /** The original error that caused the failure */ - public readonly cause: Error, - ) { - super(`Action ${actionIndex} failed: ${cause.message}`); - this.name = 'ActionExecutionError'; - Object.setPrototypeOf(this, ActionExecutionError.prototype); - } -} - -/** - * Type guard to check if an error is a NoActionsError. - */ -export function isNoActionsError(error: unknown): error is NoActionsError { - return error instanceof NoActionsError || (error instanceof Error && error.name === 'NoActionsError'); -} - -/** - * Type guard to check if an error is a NoAdapterError. - */ -export function isNoAdapterError(error: unknown): error is NoAdapterError { - return error instanceof NoAdapterError || (error instanceof Error && error.name === 'NoAdapterError'); -} - -/** - * Type guard to check if an error is an ActionExecutionError. - */ -export function isActionExecutionError(error: unknown): error is ActionExecutionError { - return error instanceof ActionExecutionError || (error instanceof Error && error.name === 'ActionExecutionError'); -} diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 5a3941c..137c624 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -1,60 +1,39 @@ /** - * @pipeit/actions - High-level DeFi actions for Solana. + * @pipeit/actions - Composable InstructionPlan factories for Solana DeFi. * - * A simple, composable API for building DeFi transactions on Solana. - * Uses pluggable adapters to avoid vendor lock-in. + * This package provides Kit-compatible InstructionPlan factories that can be: + * - Executed directly with `@pipeit/core`'s executePlan + * - Composed with other InstructionPlans using Kit's plan combinators + * - Used by anyone in the Kit ecosystem * * @example * ```ts - * import { pipe } from '@pipeit/actions' - * import { jupiter } from '@pipeit/actions/adapters' - * import { SOL, USDC } from '@pipeit/actions/tokens' + * import { getTitanSwapPlan } from '@pipeit/actions/titan'; + * import { executePlan } from '@pipeit/core'; * - * // Swap SOL for USDC using Jupiter - * const result = await pipe({ + * // Get a swap plan from Titan + * const { plan, lookupTableAddresses } = await getTitanSwapPlan({ + * inputMint: SOL_MINT, + * outputMint: USDC_MINT, + * amount: 1_000_000_000n, + * user: signer.address, + * }); + * + * // Execute with ALT support + * await executePlan(plan, { * rpc, * rpcSubscriptions, * signer, - * adapters: { swap: jupiter() } - * }) - * .swap({ inputMint: SOL, outputMint: USDC, amount: 10_000_000n }) - * .execute() - * - * console.log('Transaction:', result.signature) + * lookupTableAddresses, + * }); * ``` * * @packageDocumentation */ -// Core API -export { pipe, Pipe } from './pipe.js'; - -// Errors -export { - NoActionsError, - NoAdapterError, - ActionExecutionError, - isNoActionsError, - isNoAdapterError, - isActionExecutionError, -} from './errors.js'; +// Re-export Titan module +export * from './titan/index.js'; -// Types -export type { - ActionContext, - ActionExecutor, - ActionFactory, - ActionResult, - ActionsRpcApi, - ActionsRpcSubscriptionsApi, - ExecuteOptions, - PipeConfig, - PipeHooks, - PipeResult, - SwapAdapter, - SwapParams, - SwapResult, - // Re-exported from core for convenience - PriorityFeeLevel, - PriorityFeeConfig, -} from './types.js'; +// Note: Metis module is NOT re-exported here to avoid naming conflicts +// (both Titan and Metis have SwapMode, RoutePlanStep, etc.) +// Import Metis directly via: import { ... } from '@pipeit/actions/metis' diff --git a/packages/actions-v2/src/metis/__tests__/convert.test.ts b/packages/actions/src/metis/__tests__/convert.test.ts similarity index 100% rename from packages/actions-v2/src/metis/__tests__/convert.test.ts rename to packages/actions/src/metis/__tests__/convert.test.ts diff --git a/packages/actions-v2/src/metis/__tests__/plan-swap.test.ts b/packages/actions/src/metis/__tests__/plan-swap.test.ts similarity index 100% rename from packages/actions-v2/src/metis/__tests__/plan-swap.test.ts rename to packages/actions/src/metis/__tests__/plan-swap.test.ts diff --git a/packages/actions-v2/src/metis/client.ts b/packages/actions/src/metis/client.ts similarity index 100% rename from packages/actions-v2/src/metis/client.ts rename to packages/actions/src/metis/client.ts diff --git a/packages/actions-v2/src/metis/convert.ts b/packages/actions/src/metis/convert.ts similarity index 100% rename from packages/actions-v2/src/metis/convert.ts rename to packages/actions/src/metis/convert.ts diff --git a/packages/actions-v2/src/metis/index.ts b/packages/actions/src/metis/index.ts similarity index 100% rename from packages/actions-v2/src/metis/index.ts rename to packages/actions/src/metis/index.ts diff --git a/packages/actions-v2/src/metis/plan-swap.ts b/packages/actions/src/metis/plan-swap.ts similarity index 99% rename from packages/actions-v2/src/metis/plan-swap.ts rename to packages/actions/src/metis/plan-swap.ts index b4bf77c..d68c942 100644 --- a/packages/actions-v2/src/metis/plan-swap.ts +++ b/packages/actions/src/metis/plan-swap.ts @@ -165,7 +165,7 @@ export function getMetisSwapInstructionPlanFromResponse( * * @example * ```ts - * import { getMetisSwapPlan } from '@pipeit/actions-v2/metis'; + * import { getMetisSwapPlan } from '@pipeit/actions/metis'; * import { executePlan } from '@pipeit/core'; * * const { plan, lookupTableAddresses, quote } = await getMetisSwapPlan({ diff --git a/packages/actions-v2/src/metis/types.ts b/packages/actions/src/metis/types.ts similarity index 100% rename from packages/actions-v2/src/metis/types.ts rename to packages/actions/src/metis/types.ts diff --git a/packages/actions/src/pipe.ts b/packages/actions/src/pipe.ts deleted file mode 100644 index aa8e797..0000000 --- a/packages/actions/src/pipe.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Pipe - Fluent API for composing and executing DeFi actions. - * - * @example - * ```ts - * import { pipe } from '@pipeit/actions' - * import { jupiter } from '@pipeit/actions/adapters' - * - * await pipe({ - * rpc, - * rpcSubscriptions, - * signer, - * adapters: { swap: jupiter() } - * }) - * .swap({ inputMint: SOL, outputMint: USDC, amount: 10_000_000n }) - * .execute() - * ``` - * - * @packageDocumentation - */ - -import { TransactionBuilder, fetchAddressLookupTables, type AddressesByLookupTableAddress } from '@pipeit/core'; -import { address } from '@solana/addresses'; -import type { Instruction } from '@solana/instructions'; -import type { - ActionContext, - ActionExecutor, - ActionResult, - ExecuteOptions, - PipeConfig, - PipeHooks, - PipeResult, - SwapParams, -} from './types.js'; -import { NoActionsError, NoAdapterError, ActionExecutionError } from './errors.js'; - -/** - * Fluent builder for composing DeFi actions into atomic transactions. - */ -export class Pipe { - private config: PipeConfig; - private actions: ActionExecutor[] = []; - private context: ActionContext; - private hooks: PipeHooks = {}; - - constructor(config: PipeConfig) { - this.config = config; - this.context = { - signer: config.signer, - rpc: config.rpc, - rpcSubscriptions: config.rpcSubscriptions, - }; - } - - /** - * Add a custom action to the pipe. - * - * @param action - Action executor function - * @returns The pipe instance for chaining - * - * @example - * ```ts - * pipe.add(async (ctx) => ({ - * instructions: [myCustomInstruction], - * })) - * ``` - */ - add(action: ActionExecutor): this { - this.actions.push(action); - return this; - } - - /** - * Add a swap action using the configured swap adapter. - * - * @param params - Swap parameters - * @returns The pipe instance for chaining - * @throws If no swap adapter is configured - * - * @example - * ```ts - * pipe.swap({ - * inputMint: 'So11111111111111111111111111111111111111112', - * outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - * amount: 10_000_000n, - * slippageBps: 50 - * }) - * ``` - */ - swap(params: SwapParams): this { - if (!this.config.adapters?.swap) { - throw new NoAdapterError('swap'); - } - - const executor = this.config.adapters.swap.swap(params); - this.actions.push(executor); - return this; - } - - /** - * Set a hook called when an action starts executing. - * - * @param handler - Function called with the action index - * @returns The pipe instance for chaining - * - * @example - * ```ts - * pipe - * .swap({ ... }) - * .onActionStart((index) => console.log(`Starting action ${index}`)) - * .execute() - * ``` - */ - onActionStart(handler: (index: number) => void): this { - this.hooks.onActionStart = handler; - return this; - } - - /** - * Set a hook called when an action completes successfully. - * - * @param handler - Function called with the action index and result - * @returns The pipe instance for chaining - * - * @example - * ```ts - * pipe - * .swap({ ... }) - * .onActionComplete((index, result) => { - * console.log(`Action ${index} completed with ${result.instructions.length} instructions`) - * }) - * .execute() - * ``` - */ - onActionComplete(handler: (index: number, result: ActionResult) => void): this { - this.hooks.onActionComplete = handler; - return this; - } - - /** - * Set a hook called when an action fails. - * - * @param handler - Function called with the action index and error - * @returns The pipe instance for chaining - * - * @example - * ```ts - * pipe - * .swap({ ... }) - * .onActionError((index, error) => console.error(`Action ${index} failed:`, error)) - * .execute() - * ``` - */ - onActionError(handler: (index: number, error: Error) => void): this { - this.hooks.onActionError = handler; - return this; - } - - /** - * Execute all actions in the pipe as a single atomic transaction. - * Uses TransactionBuilder for full feature support including: - * - Address lookup tables (ALTs) for transaction compression - * - Priority fees and compute unit configuration - * - Auto-retry with configurable backoff - * - * @param options - Execution options (commitment, abortSignal) - * @returns The transaction signature and action results - * - * @example - * ```ts - * const { signature } = await pipe - * .swap({ inputMint: SOL, outputMint: USDC, amount: 10_000_000n }) - * .execute({ commitment: 'confirmed' }) - * - * console.log('Transaction:', signature) - * ``` - * - * @example With abort signal - * ```ts - * const controller = new AbortController(); - * setTimeout(() => controller.abort(), 30_000); - * - * const { signature } = await pipe - * .swap({ ... }) - * .execute({ abortSignal: controller.signal }) - * ``` - */ - async execute(options: ExecuteOptions = {}): Promise { - const { commitment = 'confirmed', abortSignal } = options; - - // Check if already aborted - if (abortSignal?.aborted) { - throw new Error('Execution aborted'); - } - - if (this.actions.length === 0) { - throw new NoActionsError(); - } - - // Execute all actions to get their instructions, with hooks - const actionResults: ActionResult[] = []; - const allInstructions: Instruction[] = []; - let totalComputeUnits = 0; - - for (let i = 0; i < this.actions.length; i++) { - // Check abort before each action - if (abortSignal?.aborted) { - throw new Error('Execution aborted'); - } - - const action = this.actions[i]; - - // Call onActionStart hook - this.hooks.onActionStart?.(i); - - try { - const result = await action(this.context); - actionResults.push(result); - allInstructions.push(...result.instructions); - - // Track compute units - if (result.computeUnits) { - totalComputeUnits += result.computeUnits; - } - - // Call onActionComplete hook - this.hooks.onActionComplete?.(i, result); - } catch (error) { - // Call onActionError hook - this.hooks.onActionError?.(i, error as Error); - throw new ActionExecutionError(i, error as Error); - } - } - - // Collect ALT addresses from all action results (deduplicated) - const altAddresses = actionResults - .flatMap(r => r.addressLookupTableAddresses ?? []) - .filter((addr, i, arr) => arr.indexOf(addr) === i); - - // Fetch lookup tables if any ALTs were returned by actions - let addressesByLookupTable: AddressesByLookupTableAddress | undefined; - if (altAddresses.length > 0) { - addressesByLookupTable = await fetchAddressLookupTables( - this.config.rpc, - altAddresses.map(a => address(a)), - ); - } - - // Check abort before execution - if (abortSignal?.aborted) { - throw new Error('Execution aborted'); - } - - // Determine compute units: use config, collected from actions, or undefined (let builder decide) - const computeUnits = - this.config.computeUnits === 'auto' - ? totalComputeUnits > 0 - ? totalComputeUnits - : undefined - : this.config.computeUnits; - - // Build and execute using TransactionBuilder with all config options - const signature = await new TransactionBuilder({ - rpc: this.config.rpc, - ...(computeUnits !== undefined && { computeUnits }), - priorityFee: this.config.priorityFee ?? 'medium', - autoRetry: this.config.autoRetry ?? { maxAttempts: 3, backoff: 'exponential' }, - logLevel: this.config.logLevel ?? 'minimal', - ...(addressesByLookupTable && { addressesByLookupTable }), - }) - .setFeePayerSigner(this.config.signer) - .addInstructions(allInstructions) - .execute({ - rpcSubscriptions: this.config.rpcSubscriptions, - commitment, - }); - - return { - signature, - actionResults, - }; - } - - /** - * Simulate the transaction without executing. - * Useful for checking if a transaction would succeed and estimating compute units. - * - * @returns Simulation results - * - * @example - * ```ts - * const simulation = await pipe - * .swap({ ... }) - * .simulate(); - * - * if (simulation.success) { - * console.log('Estimated CU:', simulation.unitsConsumed); - * } else { - * console.error('Simulation failed:', simulation.error); - * } - * ``` - */ - async simulate(): Promise<{ - success: boolean; - logs: string[]; - unitsConsumed?: bigint; - error?: unknown; - }> { - if (this.actions.length === 0) { - throw new NoActionsError(); - } - - // Execute all actions to get their instructions - const allInstructions: Instruction[] = []; - let totalComputeUnits = 0; - - for (let i = 0; i < this.actions.length; i++) { - try { - const result = await this.actions[i](this.context); - allInstructions.push(...result.instructions); - - if (result.computeUnits) { - totalComputeUnits += result.computeUnits; - } - } catch (error) { - throw new ActionExecutionError(i, error as Error); - } - } - - // Determine compute units: use config, collected from actions, or default - const computeUnits = - this.config.computeUnits === 'auto' - ? totalComputeUnits > 0 - ? totalComputeUnits - : 400_000 - : (this.config.computeUnits ?? (totalComputeUnits > 0 ? totalComputeUnits : 400_000)); - - // Build and simulate using core with config options - const result = await new TransactionBuilder({ - rpc: this.config.rpc, - computeUnits, - priorityFee: this.config.priorityFee ?? 'medium', - logLevel: this.config.logLevel ?? 'minimal', - }) - .setFeePayerSigner(this.config.signer) - .addInstructions(allInstructions) - .simulate(); - - // Build response object, conditionally adding optional properties - const response: { - success: boolean; - logs: string[]; - unitsConsumed?: bigint; - error?: unknown; - } = { - success: result.err === null, - logs: result.logs ?? [], - }; - - if (result.unitsConsumed !== undefined) { - response.unitsConsumed = result.unitsConsumed; - } - - if (result.err !== null) { - response.error = result.err; - } - - return response; - } -} - -/** - * Create a new pipe for composing DeFi actions. - * - * @param config - Pipe configuration including RPC clients, signer, and adapters - * @returns A new Pipe instance - * - * @example - * ```ts - * import { pipe } from '@pipeit/actions' - * import { jupiter } from '@pipeit/actions/adapters' - * import { SOL, USDC } from '@pipeit/actions/tokens' - * - * const result = await pipe({ - * rpc, - * rpcSubscriptions, - * signer, - * adapters: { swap: jupiter() } - * }) - * .swap({ inputMint: SOL, outputMint: USDC, amount: 10_000_000n }) - * .execute() - * - * console.log('Swap executed:', result.signature) - * ``` - */ -export function pipe(config: PipeConfig): Pipe { - return new Pipe(config); -} diff --git a/packages/actions-v2/src/titan/__tests__/convert.test.ts b/packages/actions/src/titan/__tests__/convert.test.ts similarity index 100% rename from packages/actions-v2/src/titan/__tests__/convert.test.ts rename to packages/actions/src/titan/__tests__/convert.test.ts diff --git a/packages/actions-v2/src/titan/__tests__/plan-swap.test.ts b/packages/actions/src/titan/__tests__/plan-swap.test.ts similarity index 100% rename from packages/actions-v2/src/titan/__tests__/plan-swap.test.ts rename to packages/actions/src/titan/__tests__/plan-swap.test.ts diff --git a/packages/actions-v2/src/titan/client.ts b/packages/actions/src/titan/client.ts similarity index 100% rename from packages/actions-v2/src/titan/client.ts rename to packages/actions/src/titan/client.ts diff --git a/packages/actions-v2/src/titan/convert.ts b/packages/actions/src/titan/convert.ts similarity index 100% rename from packages/actions-v2/src/titan/convert.ts rename to packages/actions/src/titan/convert.ts diff --git a/packages/actions-v2/src/titan/index.ts b/packages/actions/src/titan/index.ts similarity index 100% rename from packages/actions-v2/src/titan/index.ts rename to packages/actions/src/titan/index.ts diff --git a/packages/actions-v2/src/titan/plan-swap.ts b/packages/actions/src/titan/plan-swap.ts similarity index 99% rename from packages/actions-v2/src/titan/plan-swap.ts rename to packages/actions/src/titan/plan-swap.ts index 3847cd0..b730050 100644 --- a/packages/actions-v2/src/titan/plan-swap.ts +++ b/packages/actions/src/titan/plan-swap.ts @@ -207,7 +207,7 @@ export function getTitanSwapInstructionPlanFromRoute(route: SwapRoute): Instruct * * @example * ```ts - * import { getTitanSwapPlan } from '@pipeit/actions-v2/titan'; + * import { getTitanSwapPlan } from '@pipeit/actions/titan'; * import { executePlan } from '@pipeit/core'; * * const { plan, lookupTableAddresses, quote } = await getTitanSwapPlan({ diff --git a/packages/actions-v2/src/titan/types.ts b/packages/actions/src/titan/types.ts similarity index 100% rename from packages/actions-v2/src/titan/types.ts rename to packages/actions/src/titan/types.ts diff --git a/packages/actions/src/types.ts b/packages/actions/src/types.ts deleted file mode 100644 index 1b80d9d..0000000 --- a/packages/actions/src/types.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Core types for @pipeit/actions - * - * Design principles: - * - Protocol-agnostic interfaces - * - Pluggable adapters - * - Type-safe composition - * - * @packageDocumentation - */ - -import type { Address } from '@solana/addresses'; -import type { Instruction } from '@solana/instructions'; -import type { - FlowRpcApi, - FlowRpcSubscriptionsApi, - BaseContext, - PriorityFeeLevel, - PriorityFeeConfig, -} from '@pipeit/core'; - -// Re-export core types for convenience -export type { PriorityFeeLevel, PriorityFeeConfig } from '@pipeit/core'; - -// ============================================================================= -// Re-export shared types from core with action-specific aliases -// ============================================================================= - -/** - * Minimum RPC API required for actions. - * Re-exported from core for convenience. - */ -export type ActionsRpcApi = FlowRpcApi; - -/** - * Minimum RPC subscriptions API required for actions. - * Re-exported from core for convenience. - */ -export type ActionsRpcSubscriptionsApi = FlowRpcSubscriptionsApi; - -// ============================================================================= -// Core Action Types -// ============================================================================= - -/** - * Context passed to all actions during execution. - * Contains the RPC clients and signer needed to build instructions. - * Extends BaseContext from core. - */ -export interface ActionContext extends BaseContext {} - -/** - * Result returned by an action. - * Contains the instructions to execute and optional metadata. - */ -export interface ActionResult { - /** Instructions to include in the transaction */ - instructions: Instruction[]; - /** Suggested compute units for this action (optional hint) */ - computeUnits?: number; - /** Address lookup table addresses used by the instructions */ - addressLookupTableAddresses?: string[]; - /** Any additional data returned by the action */ - data?: Record; -} - -/** - * An action executor - a function that takes context and returns instructions. - */ -export type ActionExecutor = (ctx: ActionContext) => Promise; - -/** - * An action factory - creates an executor from parameters. - * - * @example - * ```ts - * const swapAction: ActionFactory = (params) => async (ctx) => { - * // Build instructions based on params and ctx - * return { instructions: [...] } - * } - * ``` - */ -export type ActionFactory = (params: TParams) => ActionExecutor; - -// ============================================================================= -// Swap Types (Protocol-Agnostic) -// ============================================================================= - -/** - * Parameters for a token swap action. - * Protocol-agnostic - works with any swap adapter. - */ -export interface SwapParams { - /** Token mint to swap from */ - inputMint: Address | string; - /** Token mint to swap to */ - outputMint: Address | string; - /** Amount to swap (in smallest units, e.g., lamports for SOL) */ - amount: bigint | number; - /** Slippage tolerance in basis points (default: 50 = 0.5%) */ - slippageBps?: number; -} - -/** - * Extended result for swap actions with quote information. - */ -export interface SwapResult extends ActionResult { - data: { - /** Input amount in smallest units */ - inputAmount: bigint; - /** Expected output amount in smallest units */ - outputAmount: bigint; - /** Price impact percentage (optional) */ - priceImpactPct?: number; - /** Route information (adapter-specific) */ - route?: unknown; - }; -} - -/** - * Adapter interface for swap operations. - * Implement this to create a custom swap adapter. - * - * @example - * ```ts - * const mySwapAdapter: SwapAdapter = { - * swap: (params) => async (ctx) => { - * // Call your preferred DEX API - * return { instructions: [...], data: { inputAmount, outputAmount } } - * } - * } - * ``` - */ -export interface SwapAdapter { - /** Create a swap action from parameters */ - swap: ActionFactory; -} - -// ============================================================================= -// Pipe Configuration -// ============================================================================= - -/** - * Configuration for creating a pipe. - * Extends BaseContext from core with action-specific configuration. - */ -export interface PipeConfig extends BaseContext { - /** Configured adapters for different action types */ - adapters?: { - /** Swap adapter (e.g., Jupiter, Raydium API) */ - swap?: SwapAdapter; - }; - - /** - * Priority fee configuration. - * - PriorityFeeLevel string: Use preset level ('none', 'low', 'medium', 'high', 'veryHigh') - * - PriorityFeeConfig object: Use custom configuration with strategy - * @default 'medium' - */ - priorityFee?: PriorityFeeLevel | PriorityFeeConfig; - - /** - * Compute unit configuration. - * - 'auto': Use default (no explicit instruction) - * - number: Use fixed compute unit limit - * @default 'auto' - */ - computeUnits?: 'auto' | number; - - /** - * Auto-retry failed transactions. - * - true: Use default retry (3 attempts, exponential backoff) - * - false: No retry - * - Object: Custom retry configuration - * @default { maxAttempts: 3, backoff: 'exponential' } - */ - autoRetry?: boolean | { maxAttempts: number; backoff: 'linear' | 'exponential' }; - - /** - * Logging level for transaction building. - * @default 'minimal' - */ - logLevel?: 'silent' | 'minimal' | 'verbose'; -} - -/** - * Options for executing a pipe. - */ -export interface ExecuteOptions { - /** - * Commitment level for transaction confirmation. - * @default 'confirmed' - */ - commitment?: 'processed' | 'confirmed' | 'finalized'; - /** - * Optional abort signal to cancel execution. - * When aborted, the execution will stop and throw an error. - */ - abortSignal?: AbortSignal; -} - -/** - * Hooks for monitoring pipe execution progress. - */ -export interface PipeHooks { - /** Called when an action starts executing (building instructions) */ - onActionStart?: (index: number) => void; - /** Called when an action completes successfully (instructions built) */ - onActionComplete?: (index: number, result: ActionResult) => void; - /** Called when an action fails */ - onActionError?: (index: number, error: Error) => void; -} - -/** - * Result from executing a pipe. - */ -export interface PipeResult { - /** Transaction signature */ - signature: string; - /** Results from each action in the pipe */ - actionResults: ActionResult[]; -} diff --git a/packages/actions/tsup.config.ts b/packages/actions/tsup.config.ts index de8d373..bf9caaf 100644 --- a/packages/actions/tsup.config.ts +++ b/packages/actions/tsup.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from 'tsup'; export default defineConfig(options => ({ entry: { index: 'src/index.ts', - 'adapters/index': 'src/adapters/index.ts', - 'adapters/jupiter': 'src/adapters/jupiter.ts', + 'titan/index': 'src/titan/index.ts', + 'metis/index': 'src/metis/index.ts', }, format: ['cjs', 'esm'], dts: { @@ -19,6 +19,7 @@ export default defineConfig(options => ({ '@pipeit/core', '@solana/kit', '@solana/addresses', + '@solana/instruction-plans', '@solana/instructions', '@solana/rpc', '@solana/rpc-subscriptions', diff --git a/packages/core/src/flow/index.ts b/packages/core/src/flow/index.ts index 0ad1394..8ac5ef4 100644 --- a/packages/core/src/flow/index.ts +++ b/packages/core/src/flow/index.ts @@ -6,7 +6,7 @@ export { createFlow, TransactionFlow } from './flow.js'; export type { - // Shared types (used by both core and @pipeit/actions) + // Shared types FlowRpcApi, FlowRpcSubscriptionsApi, BaseContext, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9b9432..81f7f50 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,7 +28,7 @@ export type { // Flow API - for multi-step transaction orchestration with dynamic context export { createFlow, TransactionFlow } from './flow/index.js'; export type { - // Shared types (used by @pipeit/actions) + // Shared types FlowRpcApi, FlowRpcSubscriptionsApi, BaseContext, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3863b98..c2ca6f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: '@pipeit/actions': specifier: workspace:* version: link:../../packages/actions - '@pipeit/actions-v2': - specifier: workspace:* - version: link:../../packages/actions-v2 '@pipeit/core': specifier: workspace:* version: link:../../packages/core @@ -182,45 +179,6 @@ importers: version: 5.9.3 packages/actions: - devDependencies: - '@pipeit/core': - specifier: workspace:* - version: link:../core - '@solana/addresses': - specifier: '*' - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/instructions': - specifier: '*' - version: 5.0.0(typescript@5.9.3) - '@solana/kit': - specifier: ^5.0.0 - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana/rpc': - specifier: '*' - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/rpc-subscriptions': - specifier: '*' - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana/signers': - specifier: '*' - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@solana/transactions': - specifier: '*' - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) - '@types/node': - specifier: ^24 - version: 24.10.0 - tsup: - specifier: ^8.5.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) - typescript: - specifier: ^5.8.3 - version: 5.9.3 - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.10.0)(@vitest/ui@3.2.4)(happy-dom@20.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - - packages/actions-v2: dependencies: '@msgpack/msgpack': specifier: ^3.0.0 From 16d62f53023c12f38c3142ace3e1be5224d6c0a2 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 26 Dec 2025 23:33:11 -0800 Subject: [PATCH 08/12] Update package.json --- packages/actions/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/actions/package.json b/packages/actions/package.json index 91ff7f5..f5d0e9a 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -1,7 +1,7 @@ { "name": "@pipeit/actions", - "version": "0.1.0", - "description": "Composable DeFi InstructionPlan factories for Solana (Titan-first)", + "version": "0.1.1", + "description": "Composable DeFi InstructionPlan factories for Solana", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", From 59587384c5a9d4f37f9e36f37cf8c710cb490c8e Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 28 Dec 2025 13:39:24 -0800 Subject: [PATCH 09/12] chore: updating encoder decoder bits --- examples/next-js/package.json | 2 +- packages/actions/src/metis/convert.ts | 10 +- packages/actions/src/titan/convert.ts | 38 +---- .../src/plans/__tests__/execute-plan.test.ts | 80 +++-------- .../__typetests__/execute-plan-typetest.ts | 133 ++++++++++++++++++ pnpm-lock.yaml | 22 ++- 6 files changed, 183 insertions(+), 102 deletions(-) create mode 100644 packages/core/src/plans/__typetests__/execute-plan-typetest.ts diff --git a/examples/next-js/package.json b/examples/next-js/package.json index a24216b..a8ccbf3 100644 --- a/examples/next-js/package.json +++ b/examples/next-js/package.json @@ -13,7 +13,7 @@ "@phosphor-icons/react": "^2.1.10", "@pipeit/actions": "workspace:*", "@pipeit/core": "workspace:*", - "@pipeit/fastlane": "workspace:*", + "@pipeit/fastlane": "0.1.5", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/actions/src/metis/convert.ts b/packages/actions/src/metis/convert.ts index 3149882..fbc3ef3 100644 --- a/packages/actions/src/metis/convert.ts +++ b/packages/actions/src/metis/convert.ts @@ -5,21 +5,15 @@ */ import { address, type Address } from '@solana/addresses'; +import { getBase64Encoder } from '@solana/kit'; import type { Instruction, AccountMeta as KitAccountMeta, AccountRole } from '@solana/instructions'; import type { MetisInstruction, AccountMeta } from './types.js'; /** * Decode a base64 string to Uint8Array. - * Works in both Node.js and browser environments. */ export function decodeBase64(base64: string): Uint8Array { - // Use atob which is available in both modern Node.js (>=16) and browsers - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; + return Uint8Array.from(getBase64Encoder().encode(base64)); } /** diff --git a/packages/actions/src/titan/convert.ts b/packages/actions/src/titan/convert.ts index 02d44b2..ea3b113 100644 --- a/packages/actions/src/titan/convert.ts +++ b/packages/actions/src/titan/convert.ts @@ -4,15 +4,11 @@ * @packageDocumentation */ -import { address, type Address } from '@solana/addresses'; +import { getAddressDecoder, type Address } from '@solana/addresses'; import type { Instruction, AccountMeta, AccountRole } from '@solana/instructions'; +import { getBase58Decoder } from '@solana/kit'; import type { TitanPubkey, TitanInstruction, TitanAccountMeta } from './types.js'; -/** - * Base58 alphabet used by Solana. - */ -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - /** * Encode bytes as a base58 string. * @@ -26,31 +22,8 @@ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvw * ``` */ export function encodeBase58(bytes: Uint8Array): string { - if (bytes.length === 0) return ''; - - // Count leading zeros - let leadingZeros = 0; - for (const byte of bytes) { - if (byte === 0) leadingZeros++; - else break; - } - - // Convert to BigInt - let num = 0n; - for (const byte of bytes) { - num = num * 256n + BigInt(byte); - } - - // Convert to base58 - let result = ''; - while (num > 0n) { - const remainder = Number(num % 58n); - num = num / 58n; - result = BASE58_ALPHABET[remainder] + result; - } - - // Add leading '1's for zeros - return '1'.repeat(leadingZeros) + result; + const decoder = getBase58Decoder(); + return decoder.decode(bytes); } /** @@ -65,7 +38,8 @@ export function encodeBase58(bytes: Uint8Array): string { * ``` */ export function titanPubkeyToAddress(pubkey: TitanPubkey): Address { - return address(encodeBase58(pubkey)); + const decoder = getAddressDecoder(); + return decoder.decode(pubkey); } /** diff --git a/packages/core/src/plans/__tests__/execute-plan.test.ts b/packages/core/src/plans/__tests__/execute-plan.test.ts index 9c9be8f..706487b 100644 --- a/packages/core/src/plans/__tests__/execute-plan.test.ts +++ b/packages/core/src/plans/__tests__/execute-plan.test.ts @@ -3,31 +3,24 @@ * * Note: Full integration tests require mocking RPC connections. * These tests verify the exports and basic structure. + * + * For type-level tests verifying ExecutePlanConfig constraints, + * see `__typetests__/execute-plan-typetest.ts`. */ import { describe, it, expect } from 'vitest'; -import { address } from '@solana/addresses'; -import { executePlan, type ExecutePlanConfig } from '../execute-plan.js'; +import { executePlan } from '../execute-plan.js'; import { sequentialInstructionPlan, parallelInstructionPlan, createTransactionPlanner, createTransactionPlanExecutor, } from '../index.js'; -import type { AddressesByLookupTableAddress } from '../../lookup-tables/index.js'; describe('executePlan exports', () => { it('should export executePlan function', () => { expect(typeof executePlan).toBe('function'); }); - - it('should export ExecutePlanConfig type (verifiable via usage)', () => { - // Type-level test - if this compiles, the type is exported correctly - const _config: Partial = { - commitment: 'confirmed', - }; - expect(_config.commitment).toBe('confirmed'); - }); }); describe('Kit re-exports', () => { @@ -48,54 +41,23 @@ describe('Kit re-exports', () => { }); }); -describe('ExecutePlanConfig', () => { - it('should require SimulateTransactionApi for CU estimation', () => { - // This is a compile-time check - the RPC type now includes SimulateTransactionApi - // The test documents that CU estimation via simulation is integrated - const configDescription = ` - ExecutePlanConfig now requires: - - GetEpochInfoApi - - GetSignatureStatusesApi - - SendTransactionApi - - GetLatestBlockhashApi - - SimulateTransactionApi (for CU estimation) - `; - expect(configDescription).toContain('SimulateTransactionApi'); - }); - - it('should document conditional GetAccountInfoApi requirement for lookup table fetching', () => { - // This documents that GetAccountInfoApi is only required when using lookupTableAddresses. - // When using addressesByLookupTable (pre-fetched data), GetAccountInfoApi is not required. - const altConfigDescription = ` - ExecutePlanConfig ALT support: - - lookupTableAddresses: requires GetAccountInfoApi on RPC (tables will be fetched) - - addressesByLookupTable: no additional RPC requirements (pre-fetched data) - - omit both: original behavior without ALT compression - `; - expect(altConfigDescription).toContain('GetAccountInfoApi'); - expect(altConfigDescription).toContain('addressesByLookupTable'); - expect(altConfigDescription).toContain('lookupTableAddresses'); - }); - - it('should accept config with addressesByLookupTable (type-level verification)', () => { - // Type-level test - if this compiles, the config union correctly allows pre-fetched ALT data - // without requiring GetAccountInfoApi on the RPC type - const testAltAddress = address('ALT1111111111111111111111111111111111111111'); - const testAddress1 = address('11111111111111111111111111111111'); - const testAddress2 = address('22222222222222222222222222222222222222222222'); - - const prefetchedData: AddressesByLookupTableAddress = { - [testAltAddress]: [testAddress1, testAddress2], - }; - - // This config shape should be valid - addressesByLookupTable without lookupTableAddresses - const _configWithPrefetchedData: Pick = { - commitment: 'confirmed', - addressesByLookupTable: prefetchedData, - }; - - expect(_configWithPrefetchedData.addressesByLookupTable).toBeDefined(); - expect(Object.keys(_configWithPrefetchedData.addressesByLookupTable!)).toHaveLength(1); +describe('ExecutePlanConfig requirements', () => { + /** + * ExecutePlanConfig requires these RPC APIs: + * - GetEpochInfoApi + * - GetSignatureStatusesApi + * - SendTransactionApi + * - GetLatestBlockhashApi + * - SimulateTransactionApi (for CU estimation) + * + * Additionally: + * - lookupTableAddresses: requires GetAccountInfoApi (tables will be fetched) + * - addressesByLookupTable: no additional requirements (pre-fetched data) + * + * See `__typetests__/execute-plan-typetest.ts` for compile-time verification. + */ + it('documents RPC API requirements', () => { + expect(true).toBe(true); }); }); diff --git a/packages/core/src/plans/__typetests__/execute-plan-typetest.ts b/packages/core/src/plans/__typetests__/execute-plan-typetest.ts new file mode 100644 index 0000000..5391b64 --- /dev/null +++ b/packages/core/src/plans/__typetests__/execute-plan-typetest.ts @@ -0,0 +1,133 @@ +/** + * Type tests for ExecutePlanConfig. + * + * These tests verify that the ExecutePlanConfig type union correctly enforces + * RPC API requirements based on the lookup table configuration provided. + */ + +import type { Address } from '@solana/addresses'; +import type { + Rpc, + GetLatestBlockhashApi, + GetAccountInfoApi, + GetEpochInfoApi, + GetSignatureStatusesApi, + SendTransactionApi, + SimulateTransactionApi, +} from '@solana/rpc'; +import type { RpcSubscriptions, SignatureNotificationsApi, SlotNotificationsApi } from '@solana/rpc-subscriptions'; +import type { TransactionSigner } from '@solana/signers'; + +import type { ExecutePlanConfig } from '../execute-plan.js'; +import type { AddressesByLookupTableAddress } from '../../lookup-tables/index.js'; + +// Mock types for testing +type BaseRpcApi = GetEpochInfoApi & + GetSignatureStatusesApi & + SendTransactionApi & + GetLatestBlockhashApi & + SimulateTransactionApi; + +type RpcApiWithAccountInfo = BaseRpcApi & GetAccountInfoApi; + +const baseRpc = null as unknown as Rpc; +const rpcWithAccountInfo = null as unknown as Rpc; +const rpcSubscriptions = null as unknown as RpcSubscriptions; +const signer = null as unknown as TransactionSigner; +const altAddress = null as unknown as Address; +const addressesByLookupTable = null as unknown as AddressesByLookupTableAddress; + +// [DESCRIBE] ExecutePlanConfig without ALT support +{ + // It accepts config with base RPC when no lookup tables are provided + { + const config: ExecutePlanConfig = { + rpc: baseRpc, + rpcSubscriptions, + signer, + }; + config satisfies ExecutePlanConfig; + } + + // It accepts config with optional commitment + { + const config: ExecutePlanConfig = { + rpc: baseRpc, + rpcSubscriptions, + signer, + commitment: 'confirmed', + }; + config satisfies ExecutePlanConfig; + } + + // It accepts config with optional abortSignal + { + const config: ExecutePlanConfig = { + rpc: baseRpc, + rpcSubscriptions, + signer, + abortSignal: new AbortController().signal, + }; + config satisfies ExecutePlanConfig; + } +} + +// [DESCRIBE] ExecutePlanConfig with lookupTableAddresses +{ + // It requires RPC with GetAccountInfoApi when lookupTableAddresses is provided + { + const config: ExecutePlanConfig = { + rpc: rpcWithAccountInfo, + rpcSubscriptions, + signer, + lookupTableAddresses: [altAddress], + }; + config satisfies ExecutePlanConfig; + } + + // @ts-expect-error It rejects base RPC (without GetAccountInfoApi) when lookupTableAddresses is provided + const _invalidConfig: ExecutePlanConfig = { + rpc: baseRpc, + rpcSubscriptions, + signer, + lookupTableAddresses: [altAddress], + }; +} + +// [DESCRIBE] ExecutePlanConfig with addressesByLookupTable (pre-fetched) +{ + // It accepts base RPC when using pre-fetched addressesByLookupTable + { + const config: ExecutePlanConfig = { + rpc: baseRpc, + rpcSubscriptions, + signer, + addressesByLookupTable, + }; + config satisfies ExecutePlanConfig; + } + + // It does not require GetAccountInfoApi since tables are pre-fetched + { + const config: ExecutePlanConfig = { + rpc: baseRpc, // Base RPC is sufficient + rpcSubscriptions, + signer, + addressesByLookupTable, + commitment: 'finalized', + }; + config satisfies ExecutePlanConfig; + } +} + +// [DESCRIBE] ExecutePlanConfig mutual exclusivity +{ + // @ts-expect-error It rejects config with both lookupTableAddresses and addressesByLookupTable + const _invalidConfig: ExecutePlanConfig = { + rpc: rpcWithAccountInfo, + rpcSubscriptions, + signer, + lookupTableAddresses: [altAddress], + addressesByLookupTable, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2ca6f7..e91867b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: workspace:* version: link:../../packages/core '@pipeit/fastlane': - specifier: workspace:* - version: link:../../packages/fastlane + specifier: 0.1.5 + version: 0.1.5 '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -226,6 +226,9 @@ importers: packages/core: devDependencies: + '@solana-program/address-lookup-table': + specifier: '*' + version: 0.10.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana-program/compute-budget': specifier: '*' version: 0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -1137,6 +1140,10 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@pipeit/fastlane@0.1.5': + resolution: {integrity: sha512-Ngv+sSbD6/wCInJnnfk5Sw4eiPYHr2j2TQ62Qn6UD1iGizKQKjsQ76gp/4l5z468YCHRi+rRBeOPmw4eZoUdQA==} + engines: {node: '>= 18'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1683,6 +1690,11 @@ packages: '@solana-mobile/wallet-standard-mobile@0.4.3': resolution: {integrity: sha512-LLMQs/KgRZpftIhwOLCM2VZLMdA2vIghJjKsYUIiy1FBJS9GEkGDLJdbujb92lfAdmYwbyTuolIRik7JMPH3Kg==} + '@solana-program/address-lookup-table@0.10.0': + resolution: {integrity: sha512-lcp+IYwoFBODhg8vXsh5vpxweLxpSKqjAu8P1LyqQxgk2yqwYmJGA79YKa+lZvsQjP/c0rzIZYWIGxFMMes2zA==} + peerDependencies: + '@solana/kit': ^5.0 + '@solana-program/compute-budget@0.11.0': resolution: {integrity: sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==} peerDependencies: @@ -6181,6 +6193,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@pipeit/fastlane@0.1.5': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -6706,6 +6720,10 @@ snapshots: - react-native - typescript + '@solana-program/address-lookup-table@0.10.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + dependencies: + '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget@0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) From d54b775d14ef5775b6e7a0bd57a92bac98ce9cca Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 28 Dec 2025 13:40:48 -0800 Subject: [PATCH 10/12] Update pnpm-lock.yaml --- pnpm-lock.yaml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e91867b..816fb41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,9 +226,6 @@ importers: packages/core: devDependencies: - '@solana-program/address-lookup-table': - specifier: '*' - version: 0.10.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana-program/compute-budget': specifier: '*' version: 0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -1690,11 +1687,6 @@ packages: '@solana-mobile/wallet-standard-mobile@0.4.3': resolution: {integrity: sha512-LLMQs/KgRZpftIhwOLCM2VZLMdA2vIghJjKsYUIiy1FBJS9GEkGDLJdbujb92lfAdmYwbyTuolIRik7JMPH3Kg==} - '@solana-program/address-lookup-table@0.10.0': - resolution: {integrity: sha512-lcp+IYwoFBODhg8vXsh5vpxweLxpSKqjAu8P1LyqQxgk2yqwYmJGA79YKa+lZvsQjP/c0rzIZYWIGxFMMes2zA==} - peerDependencies: - '@solana/kit': ^5.0 - '@solana-program/compute-budget@0.11.0': resolution: {integrity: sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==} peerDependencies: @@ -6720,10 +6712,6 @@ snapshots: - react-native - typescript - '@solana-program/address-lookup-table@0.10.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': - dependencies: - '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana-program/compute-budget@0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) From 66fac0dca7dcfdaf57fc73a67423faaa52014880 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 28 Dec 2025 14:46:44 -0800 Subject: [PATCH 11/12] chore: use kit atls, compressions, formatting --- .github/workflows/publish.yml | 3 - README.md | 46 +- examples/next-js/README.md | 18 +- .../next-js/app/api/titan/[...path]/route.ts | 17 +- examples/next-js/app/layout.tsx | 4 +- examples/next-js/app/page.tsx | 2 +- .../next-js/components/code/code-block.tsx | 6 +- .../components/connector/wallet-modal.tsx | 19 +- .../components/landing/animated-line.tsx | 113 +- .../backgrounds/dithered-bars-background.tsx | 230 +- .../geometric-wave-grid-background.tsx | 151 +- .../glitch-grid-text-background.tsx | 267 +-- .../perlin-contours-background.tsx | 302 +-- .../backgrounds/piano-roll-background.tsx | 342 +-- .../backgrounds/radar-sweep-background.tsx | 452 ++-- .../structural-validation-background.tsx | 776 ++++--- .../next-js/components/landing/benefits.tsx | 14 +- .../components/landing/features-bento.tsx | 221 +- examples/next-js/components/landing/hero.tsx | 4 +- .../next-js/components/navigation/app-nav.tsx | 2 +- .../pipeline/examples/jupiter-swap.tsx | 4 +- .../pipeline/examples/titan-swap.tsx | 4 +- .../pipeline/examples/tpu-direct.tsx | 50 +- .../components/pipeline/tpu-results-panel.tsx | 77 +- examples/next-js/components/ui/accordion.tsx | 6 +- examples/next-js/tsconfig.json | 68 +- package.json | 58 +- packages/actions/README.md | 58 +- packages/actions/package.json | 146 +- .../src/metis/__tests__/convert.test.ts | 7 +- .../src/metis/__tests__/plan-swap.test.ts | 20 +- packages/actions/src/metis/client.ts | 13 +- packages/actions/src/metis/plan-swap.ts | 32 +- .../src/titan/__tests__/convert.test.ts | 12 +- .../src/titan/__tests__/plan-swap.test.ts | 3 +- packages/actions/src/titan/client.ts | 16 +- packages/actions/src/titan/index.ts | 8 +- packages/actions/src/titan/plan-swap.ts | 32 +- packages/core/README.md | 1 + packages/core/src/builder/builder.ts | 1990 ++++++++--------- .../__tests__/compute-units.test.ts | 5 +- packages/core/src/errors/tpu-errors.ts | 19 +- .../core/src/execution/__tests__/jito.test.ts | 3 - .../src/execution/__tests__/parallel.test.ts | 3 - .../execution/__tests__/strategies.test.ts | 3 - packages/core/src/execution/index.ts | 3 - packages/core/src/execution/jito.ts | 3 - packages/core/src/execution/parallel.ts | 3 - packages/core/src/execution/strategies.ts | 18 +- packages/core/src/lookup-tables/compress.ts | 113 +- .../__typetests__/execute-plan-typetest.ts | 14 +- packages/core/src/plans/execute-plan.ts | 52 +- packages/core/src/server/tpu-handler.ts | 19 +- packages/fastlane/index.d.ts | 200 +- packages/fastlane/index.js | 536 +++-- packages/fastlane/test.mjs | 11 +- pnpm-lock.yaml | 4 +- 57 files changed, 3168 insertions(+), 3435 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b5acf7c..016ee93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,6 +68,3 @@ jobs: working-directory: packages/actions env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - diff --git a/README.md b/README.md index 8c6ab2b..a16448a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Pipeit is a comprehensive TypeScript SDK for building and executing Solana trans Built on modern Solana libraries (@solana/kit) with a focus on type safety, developer experience, and production readiness. **Key Features:** + - Type-safe transaction building with compile-time validation - Multiple execution strategies (Standard RPC, Jito Bundles, Parallel Execution, TPU direct) - Multi-step flows with dynamic context between steps @@ -19,16 +20,18 @@ Built on modern Solana libraries (@solana/kit) with a focus on type safety, deve ## Packages -| Package | Description | Docs | -|---------|-------------|------| -| [@pipeit/core](./packages/core) | Transaction builder with smart defaults, flows, and execution strategies | [README](./packages/core/README.md) | -| [@pipeit/actions](./packages/actions) | InstructionPlan factories for DeFi (Titan, Metis) | [README](./packages/actions/README.md) | -| [@pipeit/fastlane](./packages/fastlane) | Native Rust QUIC client for direct TPU submission | [Package](./packages/fastlane) | +| Package | Description | Docs | +| --------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------- | +| [@pipeit/core](./packages/core) | Transaction builder with smart defaults, flows, and execution strategies | [README](./packages/core/README.md) | +| [@pipeit/actions](./packages/actions) | InstructionPlan factories for DeFi (Titan, Metis) | [README](./packages/actions/README.md) | +| [@pipeit/fastlane](./packages/fastlane) | Native Rust QUIC client for direct TPU submission | [Package](./packages/fastlane) | ## Package Overview ### @pipeit/core + The foundation package for transaction building: + - TransactionBuilder with auto-blockhash, auto-retry, and priority fees - Flow API for multi-step workflows with dynamic context - Multiple execution strategies (RPC, Jito bundles, parallel execution, TPU direct) @@ -36,14 +39,18 @@ The foundation package for transaction building: - Server exports for server components based TPU handlers ### @pipeit/actions + Composable InstructionPlan factories for DeFi: + - Kit-compatible InstructionPlans for swap operations - Titan and Metis aggregator integration - Address lookup table support - Composable with Kit's plan combinators ### @pipeit/fastlane + Ultra-fast transaction submission: + - Native Rust QUIC implementation via NAPI - Direct TPU submission bypassing RPC nodes - Continuous resubmission until confirmation @@ -64,6 +71,7 @@ pipeit/ ``` **Choosing a Package:** + - Building transactions? → `@pipeit/core` - DeFi operations (swaps)? → `@pipeit/actions` + `@pipeit/core` - Ultra-fast submission? → `@pipeit/fastlane` + `@pipeit/core` @@ -155,21 +163,18 @@ await executePlan(plan, { Pipeit supports multiple execution strategies for different use cases: -| Preset | Description | Use Case | -|--------|-------------|----------| -| `'standard'` | Default RPC submission | General transactions | -| `'economical'` | Jito bundle only | MEV-sensitive swaps | -| `'fast'` | Jito + parallel RPC race | Time-sensitive operations | -| `'ultra'` | TPU direct + Jito race | Fastest possible (requires `@pipeit/fastlane`) | +| Preset | Description | Use Case | +| -------------- | ------------------------ | ---------------------------------------------- | +| `'standard'` | Default RPC submission | General transactions | +| `'economical'` | Jito bundle only | MEV-sensitive swaps | +| `'fast'` | Jito + parallel RPC race | Time-sensitive operations | +| `'ultra'` | TPU direct + Jito race | Fastest possible (requires `@pipeit/fastlane`) | ```typescript -const signature = await new TransactionBuilder({ rpc }) - .setFeePayerSigner(signer) - .addInstruction(instruction) - .execute({ - rpcSubscriptions, - execution: 'fast', // or 'standard', 'economical', 'ultra' - }); +const signature = await new TransactionBuilder({ rpc }).setFeePayerSigner(signer).addInstruction(instruction).execute({ + rpcSubscriptions, + execution: 'fast', // or 'standard', 'economical', 'ultra' +}); ``` For custom configuration, see the [@pipeit/core README](./packages/core/README.md). @@ -232,11 +237,13 @@ export { tpuHandler as POST } from '@pipeit/core/server'; ## Development ### Prerequisites + - Node.js 20+ - pnpm 10+ - Rust (for @pipeit/fastlane development) ### Setup + ```bash git clone https://github.com/stevesarmiento/pipeit.git cd pipeit @@ -244,6 +251,7 @@ pnpm install ``` ### Commands + ```bash pnpm build # Build all packages pnpm test # Run all tests @@ -257,5 +265,5 @@ Contributions are welcome. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for ## License -MIT +MIT [LICENSE.md](.LICENSE.md) diff --git a/examples/next-js/README.md b/examples/next-js/README.md index 82eea22..ebc261c 100644 --- a/examples/next-js/README.md +++ b/examples/next-js/README.md @@ -31,15 +31,15 @@ Demonstrates the value proposition of Pipeit with: Interactive demos of various pipeline patterns with real mainnet transactions: -| Example | Description | -|---------|-------------| -| **Simple Transfer** | Single instruction, single transaction - baseline example | -| **Batched Transfers** | Multiple transfers batched into one atomic transaction | -| **Mixed Pipeline** | Instruction and transaction steps - shows when batching breaks | -| **Jupiter Swap** | Token swap using Jupiter aggregator | -| **Titan Swap** | Token swap using Titan aggregator | -| **Jito Bundle** | MEV-protected bundle submission with Jito tip instructions | -| **TPU Direct** | Direct QUIC submission to validator TPU - bypass RPC for max speed | +| Example | Description | +| --------------------- | ------------------------------------------------------------------ | +| **Simple Transfer** | Single instruction, single transaction - baseline example | +| **Batched Transfers** | Multiple transfers batched into one atomic transaction | +| **Mixed Pipeline** | Instruction and transaction steps - shows when batching breaks | +| **Jupiter Swap** | Token swap using Jupiter aggregator | +| **Titan Swap** | Token swap using Titan aggregator | +| **Jito Bundle** | MEV-protected bundle submission with Jito tip instructions | +| **TPU Direct** | Direct QUIC submission to validator TPU - bypass RPC for max speed | Each example includes: diff --git a/examples/next-js/app/api/titan/[...path]/route.ts b/examples/next-js/app/api/titan/[...path]/route.ts index a06d240..0e7c7aa 100644 --- a/examples/next-js/app/api/titan/[...path]/route.ts +++ b/examples/next-js/app/api/titan/[...path]/route.ts @@ -21,10 +21,7 @@ const TITAN_DEMO_URLS: Record = { * - region: 'us1' | 'jp1' | 'de1' (default: 'us1') - selects Titan endpoint * - ...all other params forwarded to Titan */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ path: string[] }> }, -) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { const { path } = await params; const titanPath = '/' + path.join('/'); const searchParams = request.nextUrl.searchParams; @@ -41,9 +38,7 @@ export async function GET( } }); - const url = forwardParams.toString() - ? `${baseUrl}${titanPath}?${forwardParams}` - : `${baseUrl}${titanPath}`; + const url = forwardParams.toString() ? `${baseUrl}${titanPath}?${forwardParams}` : `${baseUrl}${titanPath}`; try { // Use server-side token from env, or forward client header as fallback @@ -76,9 +71,9 @@ export async function GET( }); } catch (error) { console.error('Titan API proxy error:', error); - return new NextResponse( - error instanceof Error ? error.message : 'Failed to proxy Titan request', - { status: 500, headers: { 'Content-Type': 'text/plain' } }, - ); + return new NextResponse(error instanceof Error ? error.message : 'Failed to proxy Titan request', { + status: 500, + headers: { 'Content-Type': 'text/plain' }, + }); } } diff --git a/examples/next-js/app/layout.tsx b/examples/next-js/app/layout.tsx index 62e0fdc..33f7d96 100644 --- a/examples/next-js/app/layout.tsx +++ b/examples/next-js/app/layout.tsx @@ -72,7 +72,9 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/examples/next-js/app/page.tsx b/examples/next-js/app/page.tsx index 073b6f6..ed39c81 100644 --- a/examples/next-js/app/page.tsx +++ b/examples/next-js/app/page.tsx @@ -76,7 +76,7 @@ export default function Home() { afterCode={afterCode} /> - + {/* Playground - sticky at top, covers content as it scrolls behind */} diff --git a/examples/next-js/components/code/code-block.tsx b/examples/next-js/components/code/code-block.tsx index be989e7..9a0de16 100644 --- a/examples/next-js/components/code/code-block.tsx +++ b/examples/next-js/components/code/code-block.tsx @@ -29,10 +29,7 @@ function CodeBlockFallback({ return (
     );
 }
-
diff --git a/examples/next-js/components/connector/wallet-modal.tsx b/examples/next-js/components/connector/wallet-modal.tsx
index 796ec40..a562214 100644
--- a/examples/next-js/components/connector/wallet-modal.tsx
+++ b/examples/next-js/components/connector/wallet-modal.tsx
@@ -146,7 +146,9 @@ export function WalletModal({ open, onOpenChange }: WalletModalProps) {
                                                         
                                                     
                                                     
- {isConnecting && } + {isConnecting && ( + + )} {walletInfo.wallet.icon && (
- {isConnecting && } + {isConnecting && ( + + )} {walletInfo.wallet.icon && ( {walletInfo.wallet.name}
-
- Not installed -
+
Not installed
@@ -301,9 +306,7 @@ export function WalletModal({ open, onOpenChange }: WalletModalProps) { Install a Solana wallet extension to get started

-
@@ -242,7 +232,13 @@ function TpuStatsPanel({ result }: { result: TpuSubmissionResult }) { /** * TPU Real-Time Visualization Component. */ -export function TpuRealTimeVisualization({ tpuState, lastResult }: { tpuState: TpuState; lastResult: TpuSubmissionResult | null }) { +export function TpuRealTimeVisualization({ + tpuState, + lastResult, +}: { + tpuState: TpuState; + lastResult: TpuSubmissionResult | null; +}) { return (
{/* State indicator */} @@ -318,14 +314,11 @@ export function TpuRealTimeVisualization({ tpuState, lastResult }: { tpuState: T {/* Rounds visualization */} {lastResult && (lastResult.rounds ?? 0) > 0 && ( - +
- Submission Rounds ({lastResult.rounds} rounds, {lastResult.totalLeadersSent ?? lastResult.leaderCount ?? 0} leaders) + Submission Rounds ({lastResult.rounds} rounds,{' '} + {lastResult.totalLeadersSent ?? lastResult.leaderCount ?? 0} leaders)
@@ -336,7 +329,7 @@ export function TpuRealTimeVisualization({ tpuState, lastResult }: { tpuState: T 'w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium', index === (lastResult.rounds ?? 1) - 1 && lastResult.confirmed ? 'bg-emerald-500 text-white' - : 'bg-purple-100 text-purple-600' + : 'bg-purple-100 text-purple-600', )} initial={{ scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} @@ -351,16 +344,11 @@ export function TpuRealTimeVisualization({ tpuState, lastResult }: { tpuState: T {/* Stats panel */} {lastResult && ( - + )}
- ); } diff --git a/examples/next-js/components/pipeline/tpu-results-panel.tsx b/examples/next-js/components/pipeline/tpu-results-panel.tsx index 19a0566..52edd85 100644 --- a/examples/next-js/components/pipeline/tpu-results-panel.tsx +++ b/examples/next-js/components/pipeline/tpu-results-panel.tsx @@ -7,29 +7,18 @@ import type { TpuSubmissionResult, LeaderResult } from './examples/tpu-direct'; /** * Round indicator dot with tooltip showing leaders for that round. */ -function RoundDot({ - filled, - index, - leaders -}: { - filled: boolean; - index: number; - leaders?: LeaderResult[]; -}) { +function RoundDot({ filled, index, leaders }: { filled: boolean; index: number; leaders?: LeaderResult[] }) { const hasLeaders = leaders && leaders.length > 0; - + return (
- + {/* Tooltip */} {hasLeaders && (
@@ -84,9 +73,7 @@ export function TpuResultsPanel({ result, isExecuting }: TpuResultsPanelProps) { ))}
-
- TPU Direct — awaiting execution -
+
TPU Direct — awaiting execution
); } @@ -138,44 +125,36 @@ export function TpuResultsPanel({ result, isExecuting }: TpuResultsPanelProps) { {/* 4x4 grid */}
{Array.from({ length: 16 }).map((_, i) => ( - + ))}
- {/* Stats row */} -
-
- Status - - {isConfirmed ? 'Confirmed' : 'Pending'} - -
+ {/* Stats row */} +
+
+ Status + + {isConfirmed ? 'Confirmed' : 'Pending'} + +
-
- Rounds - {rounds} -
+
+ Rounds + {rounds} +
-
- Leaders - {totalLeaders} -
+
+ Leaders + {totalLeaders} +
-
- Time - - {result.latencyMs > 1000 - ? `${(result.latencyMs / 1000).toFixed(1)}s` - : `${result.latencyMs}ms` - } - -
+
+ Time + + {result.latencyMs > 1000 ? `${(result.latencyMs / 1000).toFixed(1)}s` : `${result.latencyMs}ms`} +
+
); } diff --git a/examples/next-js/components/ui/accordion.tsx b/examples/next-js/components/ui/accordion.tsx index f43c509..ab64551 100644 --- a/examples/next-js/components/ui/accordion.tsx +++ b/examples/next-js/components/ui/accordion.tsx @@ -12,7 +12,11 @@ function Accordion({ ...props }: React.ComponentProps) { return ( - + ); } diff --git a/examples/next-js/tsconfig.json b/examples/next-js/tsconfig.json index 1a64ca2..959a336 100644 --- a/examples/next-js/tsconfig.json +++ b/examples/next-js/tsconfig.json @@ -1,44 +1,28 @@ { - "compilerOptions": { - "target": "ES2020", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": [ - "./*" - ], - "@pipeit/core": [ - "../../packages/core/dist" - ] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"], + "@pipeit/core": ["../../packages/core/dist"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/package.json b/package.json index 4224ada..46140b6 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,33 @@ { - "name": "@pipeit/monorepo", - "private": true, - "type": "module", - "packageManager": "pnpm@10.26.2", - "scripts": { - "build": "turbo run build", - "dev": "turbo run dev", - "lint": "turbo run lint", - "test": "turbo run test", - "typecheck": "turbo run typecheck", - "clean": "turbo run clean" - }, - "devDependencies": { - "@solana/prettier-config-solana": "^0.0.6", - "@types/node": "^24.10.0", - "prettier": "^3.6.2", - "turbo": "^2.7.2", - "typescript": "^5.9.3", - "vitest": "^3.2.4" - }, - "pnpm": { - "overrides": { - "globalthis@1.0.4": "npm:@ungap/global-this@^0.4.4" + "name": "@pipeit/monorepo", + "private": true, + "type": "module", + "packageManager": "pnpm@10.26.2", + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "test": "turbo run test", + "typecheck": "turbo run typecheck", + "clean": "turbo run clean", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "@solana/prettier-config-solana": "^0.0.6", + "@types/node": "^24.10.0", + "prettier": "^3.6.2", + "turbo": "^2.7.2", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "pnpm": { + "overrides": { + "globalthis@1.0.4": "npm:@ungap/global-this@^0.4.4" + } + }, + "engines": { + "node": ">=20.18.0", + "pnpm": "^10" } - }, - "engines": { - "node": ">=20.18.0", - "pnpm": "^10" - } } diff --git a/packages/actions/README.md b/packages/actions/README.md index e8cfb06..781e093 100644 --- a/packages/actions/README.md +++ b/packages/actions/README.md @@ -3,6 +3,7 @@ Composable InstructionPlan factories for Solana DeFi, starting with Titan integration. This package provides Kit-compatible `InstructionPlan` factories that can be: + - Executed directly with `@pipeit/core`'s `executePlan` - Composed with other InstructionPlans using Kit's plan combinators - Used by anyone in the Kit ecosystem @@ -57,27 +58,30 @@ The main entry point that fetches a quote, selects the best route, and returns a ```typescript import { getTitanSwapPlan } from '@pipeit/actions/titan'; -const { plan, lookupTableAddresses, quote, providerId, route } = await getTitanSwapPlan({ - swap: { - inputMint: 'So11111111111111111111111111111111111111112', - outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - amount: 1_000_000_000n, - slippageBps: 50, - // Optional filters - dexes: ['Raydium', 'Orca'], // Only use these DEXes - excludeDexes: ['Phoenix'], // Exclude these DEXes - onlyDirectRoutes: false, // Allow multi-hop routes - providers: ['titan'], // Only use specific providers +const { plan, lookupTableAddresses, quote, providerId, route } = await getTitanSwapPlan( + { + swap: { + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: 1_000_000_000n, + slippageBps: 50, + // Optional filters + dexes: ['Raydium', 'Orca'], // Only use these DEXes + excludeDexes: ['Phoenix'], // Exclude these DEXes + onlyDirectRoutes: false, // Allow multi-hop routes + providers: ['titan'], // Only use specific providers + }, + transaction: { + userPublicKey: signer.address, + createOutputTokenAccount: true, + closeInputTokenAccount: false, + }, }, - transaction: { - userPublicKey: signer.address, - createOutputTokenAccount: true, - closeInputTokenAccount: false, + { + // Optional: specify a provider + providerId: 'titan', }, -}, { - // Optional: specify a provider - providerId: 'titan', -}); +); ``` ### Lower-Level APIs @@ -127,11 +131,7 @@ The real power of InstructionPlans is composition. Combine multiple plans: ```typescript import { getTitanSwapPlan } from '@pipeit/actions/titan'; -import { - sequentialInstructionPlan, - parallelInstructionPlan, - singleInstructionPlan, -} from '@solana/instruction-plans'; +import { sequentialInstructionPlan, parallelInstructionPlan, singleInstructionPlan } from '@solana/instruction-plans'; import { executePlan } from '@pipeit/core'; // Swap SOL → USDC @@ -148,10 +148,7 @@ const swapResult = await getTitanSwapPlan({ const transferPlan = singleInstructionPlan(transferInstruction); // Combine: swap then transfer -const combinedPlan = sequentialInstructionPlan([ - swapResult.plan, - transferPlan, -]); +const combinedPlan = sequentialInstructionPlan([swapResult.plan, transferPlan]); // Execute with all ALTs await executePlan(combinedPlan, { @@ -181,10 +178,7 @@ await executePlan(plan, { // Option 2: Pre-fetch ALT data yourself import { fetchAddressLookupTables } from '@pipeit/core'; -const addressesByLookupTable = await fetchAddressLookupTables( - rpc, - swapResult.lookupTableAddresses, -); +const addressesByLookupTable = await fetchAddressLookupTables(rpc, swapResult.lookupTableAddresses); await executePlan(plan, { rpc, diff --git a/packages/actions/package.json b/packages/actions/package.json index f5d0e9a..71dba33 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -1,78 +1,78 @@ { - "name": "@pipeit/actions", - "version": "0.1.1", - "description": "Composable DeFi InstructionPlan factories for Solana", - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "typings": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "name": "@pipeit/actions", + "version": "0.1.1", + "description": "Composable DeFi InstructionPlan factories for Solana", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "typings": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./titan": { + "types": "./dist/titan/index.d.ts", + "import": "./dist/titan/index.js", + "require": "./dist/titan/index.cjs" + }, + "./metis": { + "types": "./dist/metis/index.d.ts", + "import": "./dist/metis/index.js", + "require": "./dist/metis/index.cjs" + } }, - "./titan": { - "types": "./dist/titan/index.d.ts", - "import": "./dist/titan/index.js", - "require": "./dist/titan/index.cjs" + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest" }, - "./metis": { - "types": "./dist/metis/index.d.ts", - "import": "./dist/metis/index.js", - "require": "./dist/metis/index.cjs" + "keywords": [ + "solana", + "defi", + "swap", + "titan", + "jupiter", + "metis", + "instruction-plans", + "transaction" + ], + "license": "MIT", + "peerDependencies": { + "@pipeit/core": "^0.2.5", + "@solana/kit": "^5.0.0", + "@solana/addresses": "*", + "@solana/instruction-plans": "*", + "@solana/instructions": "*", + "@solana/rpc": "*", + "@solana/rpc-subscriptions": "*", + "@solana/signers": "*", + "@solana/transactions": "*" + }, + "dependencies": { + "@msgpack/msgpack": "^3.0.0" + }, + "devDependencies": { + "@pipeit/core": "workspace:*", + "@solana/addresses": "*", + "@solana/instruction-plans": "*", + "@solana/instructions": "*", + "@solana/kit": "^5.0.0", + "@solana/rpc": "*", + "@solana/rpc-subscriptions": "*", + "@solana/signers": "*", + "@solana/transactions": "*", + "@types/node": "^24", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "clean": "rm -rf dist", - "lint": "eslint src", - "typecheck": "tsc --noEmit", - "test": "vitest" - }, - "keywords": [ - "solana", - "defi", - "swap", - "titan", - "jupiter", - "metis", - "instruction-plans", - "transaction" - ], - "license": "MIT", - "peerDependencies": { - "@pipeit/core": "^0.2.5", - "@solana/kit": "^5.0.0", - "@solana/addresses": "*", - "@solana/instruction-plans": "*", - "@solana/instructions": "*", - "@solana/rpc": "*", - "@solana/rpc-subscriptions": "*", - "@solana/signers": "*", - "@solana/transactions": "*" - }, - "dependencies": { - "@msgpack/msgpack": "^3.0.0" - }, - "devDependencies": { - "@pipeit/core": "workspace:*", - "@solana/addresses": "*", - "@solana/instruction-plans": "*", - "@solana/instructions": "*", - "@solana/kit": "^5.0.0", - "@solana/rpc": "*", - "@solana/rpc-subscriptions": "*", - "@solana/signers": "*", - "@solana/transactions": "*", - "@types/node": "^24", - "tsup": "^8.5.0", - "typescript": "^5.8.3", - "vitest": "^3.2.4" - } } diff --git a/packages/actions/src/metis/__tests__/convert.test.ts b/packages/actions/src/metis/__tests__/convert.test.ts index dba81c7..034caf9 100644 --- a/packages/actions/src/metis/__tests__/convert.test.ts +++ b/packages/actions/src/metis/__tests__/convert.test.ts @@ -75,12 +75,7 @@ describe('metisInstructionToKit', () => { const metisIx: MetisInstruction = { programId: '11111111111111111111111111111111', - accounts: [ - readonlyAccount, - readonlySignerAccount, - writableAccount, - writableSignerAccount, - ], + accounts: [readonlyAccount, readonlySignerAccount, writableAccount, writableSignerAccount], data: '', }; diff --git a/packages/actions/src/metis/__tests__/plan-swap.test.ts b/packages/actions/src/metis/__tests__/plan-swap.test.ts index 92530ad..ac320b4 100644 --- a/packages/actions/src/metis/__tests__/plan-swap.test.ts +++ b/packages/actions/src/metis/__tests__/plan-swap.test.ts @@ -3,10 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { - getMetisSwapInstructionPlanFromResponse, - NoSwapInstructionError, -} from '../plan-swap.js'; +import { getMetisSwapInstructionPlanFromResponse, NoSwapInstructionError } from '../plan-swap.js'; import type { SwapInstructionsResponse, MetisInstruction } from '../types.js'; /** @@ -52,10 +49,7 @@ describe('getMetisSwapInstructionPlanFromResponse', () => { it('should create a sequential plan when multiple instructions exist', () => { const response = createMockSwapInstructionsResponse({ - computeBudgetInstructions: [ - createMockInstruction([1]), - createMockInstruction([2]), - ], + computeBudgetInstructions: [createMockInstruction([1]), createMockInstruction([2])], setupInstructions: [createMockInstruction([3])], }); @@ -126,15 +120,9 @@ describe('getMetisSwapInstructionPlanFromResponse', () => { it('should handle all instruction types being present', () => { const response = createMockSwapInstructionsResponse({ - computeBudgetInstructions: [ - createMockInstruction([10]), - createMockInstruction([11]), - ], + computeBudgetInstructions: [createMockInstruction([10]), createMockInstruction([11])], otherInstructions: [createMockInstruction([20])], - setupInstructions: [ - createMockInstruction([30]), - createMockInstruction([31]), - ], + setupInstructions: [createMockInstruction([30]), createMockInstruction([31])], tokenLedgerInstruction: createMockInstruction([40]), swapInstruction: createMockInstruction([50]), cleanupInstruction: createMockInstruction([60]), diff --git a/packages/actions/src/metis/client.ts b/packages/actions/src/metis/client.ts index df648b7..e1eeb13 100644 --- a/packages/actions/src/metis/client.ts +++ b/packages/actions/src/metis/client.ts @@ -6,12 +6,7 @@ * @packageDocumentation */ -import type { - MetisQuoteParams, - QuoteResponse, - SwapInstructionsRequest, - SwapInstructionsResponse, -} from './types.js'; +import type { MetisQuoteParams, QuoteResponse, SwapInstructionsRequest, SwapInstructionsResponse } from './types.js'; /** * Default Metis API base URL. @@ -89,11 +84,7 @@ export interface MetisClient { * ``` */ export function createMetisClient(config: MetisClientConfig = {}): MetisClient { - const { - baseUrl: baseUrlInput, - apiKey, - fetch: customFetch = globalThis.fetch, - } = config; + const { baseUrl: baseUrlInput, apiKey, fetch: customFetch = globalThis.fetch } = config; const baseUrl = normalizeBaseUrl(baseUrlInput ?? METIS_DEFAULT_BASE_URL); diff --git a/packages/actions/src/metis/plan-swap.ts b/packages/actions/src/metis/plan-swap.ts index d68c942..93f9922 100644 --- a/packages/actions/src/metis/plan-swap.ts +++ b/packages/actions/src/metis/plan-swap.ts @@ -7,11 +7,7 @@ */ import type { Address } from '@solana/addresses'; -import { - type InstructionPlan, - sequentialInstructionPlan, - singleInstructionPlan, -} from '@solana/instruction-plans'; +import { type InstructionPlan, sequentialInstructionPlan, singleInstructionPlan } from '@solana/instruction-plans'; import { createMetisClient, type MetisClient, type MetisClientConfig } from './client.js'; import type { MetisSwapQuoteParams, @@ -20,10 +16,7 @@ import type { SwapInstructionsResponse, SwapMode, } from './types.js'; -import { - metisInstructionToKit, - metisLookupTablesToAddresses, -} from './convert.js'; +import { metisInstructionToKit, metisLookupTablesToAddresses } from './convert.js'; /** * Result of building a Metis swap plan. @@ -109,17 +102,12 @@ export async function getMetisSwapQuote( * await executePlan(plan, { rpc, rpcSubscriptions, signer }); * ``` */ -export function getMetisSwapInstructionPlanFromResponse( - response: SwapInstructionsResponse, -): InstructionPlan { +export function getMetisSwapInstructionPlanFromResponse(response: SwapInstructionsResponse): InstructionPlan { // NOTE: We intentionally skip computeBudgetInstructions because // executePlan handles compute budget estimation automatically. // Including Jupiter's compute budget instructions would cause // "Transaction contains a duplicate instruction" errors. - const allInstructions = [ - ...response.otherInstructions, - ...response.setupInstructions, - ]; + const allInstructions = [...response.otherInstructions, ...response.setupInstructions]; // Add optional tokenLedgerInstruction if present if (response.tokenLedgerInstruction) { @@ -145,9 +133,7 @@ export function getMetisSwapInstructionPlanFromResponse( } // Multiple instructions are sequential - return sequentialInstructionPlan( - kitInstructions.map(ix => singleInstructionPlan(ix)), - ); + return sequentialInstructionPlan(kitInstructions.map(ix => singleInstructionPlan(ix))); } /** @@ -244,7 +230,9 @@ export async function getMetisSwapPlan( ...(tx.nativeDestinationAccount !== undefined && { nativeDestinationAccount: tx.nativeDestinationAccount }), ...(tx.dynamicComputeUnitLimit !== undefined && { dynamicComputeUnitLimit: tx.dynamicComputeUnitLimit }), ...(tx.skipUserAccountsRpcCalls !== undefined && { skipUserAccountsRpcCalls: tx.skipUserAccountsRpcCalls }), - ...(tx.computeUnitPriceMicroLamports !== undefined && { computeUnitPriceMicroLamports: tx.computeUnitPriceMicroLamports }), + ...(tx.computeUnitPriceMicroLamports !== undefined && { + computeUnitPriceMicroLamports: tx.computeUnitPriceMicroLamports, + }), ...(tx.blockhashSlotsToExpiry !== undefined && { blockhashSlotsToExpiry: tx.blockhashSlotsToExpiry }), }; @@ -255,9 +243,7 @@ export async function getMetisSwapPlan( const plan = getMetisSwapInstructionPlanFromResponse(swapInstructionsResponse); // Extract ALT addresses - const lookupTableAddresses = metisLookupTablesToAddresses( - swapInstructionsResponse.addressLookupTableAddresses, - ); + const lookupTableAddresses = metisLookupTablesToAddresses(swapInstructionsResponse.addressLookupTableAddresses); return { plan, diff --git a/packages/actions/src/titan/__tests__/convert.test.ts b/packages/actions/src/titan/__tests__/convert.test.ts index 26cd790..91917e9 100644 --- a/packages/actions/src/titan/__tests__/convert.test.ts +++ b/packages/actions/src/titan/__tests__/convert.test.ts @@ -3,12 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { - encodeBase58, - titanPubkeyToAddress, - titanInstructionToKit, - titanPubkeysToAddresses, -} from '../convert.js'; +import { encodeBase58, titanPubkeyToAddress, titanInstructionToKit, titanPubkeysToAddresses } from '../convert.js'; import type { TitanInstruction, TitanAccountMeta } from '../types.js'; describe('encodeBase58', () => { @@ -128,10 +123,7 @@ describe('titanPubkeysToAddresses', () => { }); it('should convert multiple pubkeys', () => { - const pubkeys = [ - new Uint8Array(32), - new Uint8Array(32), - ]; + const pubkeys = [new Uint8Array(32), new Uint8Array(32)]; const addresses = titanPubkeysToAddresses(pubkeys); expect(addresses).toHaveLength(2); diff --git a/packages/actions/src/titan/__tests__/plan-swap.test.ts b/packages/actions/src/titan/__tests__/plan-swap.test.ts index a653f5d..683611d 100644 --- a/packages/actions/src/titan/__tests__/plan-swap.test.ts +++ b/packages/actions/src/titan/__tests__/plan-swap.test.ts @@ -105,8 +105,7 @@ describe('selectTitanRoute', () => { it('should throw ProviderNotFoundError for unknown provider', () => { const quotes = createMockQuotes(); - expect(() => selectTitanRoute(quotes, { providerId: 'unknown-provider' })) - .toThrow(ProviderNotFoundError); + expect(() => selectTitanRoute(quotes, { providerId: 'unknown-provider' })).toThrow(ProviderNotFoundError); }); it('should include available providers in error', () => { diff --git a/packages/actions/src/titan/client.ts b/packages/actions/src/titan/client.ts index 58bf46f..8965f1a 100644 --- a/packages/actions/src/titan/client.ts +++ b/packages/actions/src/titan/client.ts @@ -7,14 +7,7 @@ */ import { decode } from '@msgpack/msgpack'; -import type { - SwapQuoteParams, - SwapQuotes, - ServerInfo, - ProviderInfo, - VenueInfo, - TitanPubkey, -} from './types.js'; +import type { SwapQuoteParams, SwapQuotes, ServerInfo, ProviderInfo, VenueInfo, TitanPubkey } from './types.js'; import { encodeBase58 } from './convert.js'; /** @@ -115,12 +108,7 @@ function pubkeyToString(pubkey: TitanPubkey | string): string { * ``` */ export function createTitanClient(config: TitanClientConfig = {}): TitanClient { - const { - baseUrl: baseUrlInput, - demoRegion = 'us1', - authToken, - fetch: customFetch = globalThis.fetch, - } = config; + const { baseUrl: baseUrlInput, demoRegion = 'us1', authToken, fetch: customFetch = globalThis.fetch } = config; const baseUrl = normalizeBaseUrl(baseUrlInput ?? TITAN_DEMO_BASE_URLS[demoRegion]); diff --git a/packages/actions/src/titan/index.ts b/packages/actions/src/titan/index.ts index c4e4ce2..978fec3 100644 --- a/packages/actions/src/titan/index.ts +++ b/packages/actions/src/titan/index.ts @@ -17,13 +17,7 @@ export { } from './client.js'; // Types -export type { - SwapQuoteParams, - SwapQuotes, - SwapRoute, - RoutePlanStep, - SwapMode, -} from './types.js'; +export type { SwapQuoteParams, SwapQuotes, SwapRoute, RoutePlanStep, SwapMode } from './types.js'; // Plan builders export { diff --git a/packages/actions/src/titan/plan-swap.ts b/packages/actions/src/titan/plan-swap.ts index b730050..4314480 100644 --- a/packages/actions/src/titan/plan-swap.ts +++ b/packages/actions/src/titan/plan-swap.ts @@ -7,22 +7,10 @@ */ import type { Address } from '@solana/addresses'; -import { - type InstructionPlan, - sequentialInstructionPlan, - singleInstructionPlan, -} from '@solana/instruction-plans'; +import { type InstructionPlan, sequentialInstructionPlan, singleInstructionPlan } from '@solana/instruction-plans'; import { createTitanClient, type TitanClient, type TitanClientConfig } from './client.js'; -import type { - SwapQuoteParams, - SwapQuotes, - SwapRoute, - SwapMode, -} from './types.js'; -import { - titanInstructionsToKit, - titanPubkeysToAddresses, -} from './convert.js'; +import type { SwapQuoteParams, SwapQuotes, SwapRoute, SwapMode } from './types.js'; +import { titanInstructionsToKit, titanPubkeysToAddresses } from './convert.js'; /** * Result of selecting a route from quotes. @@ -93,10 +81,7 @@ export interface TitanSwapPlanOptions { * }); * ``` */ -export async function getTitanSwapQuote( - client: TitanClient, - params: SwapQuoteParams, -): Promise { +export async function getTitanSwapQuote(client: TitanClient, params: SwapQuoteParams): Promise { return client.getSwapQuote(params); } @@ -117,10 +102,7 @@ export async function getTitanSwapQuote( * console.log(`Best route from ${providerId}: ${route.outAmount}`); * ``` */ -export function selectTitanRoute( - quotes: SwapQuotes, - options?: { providerId?: string }, -): SelectedRoute { +export function selectTitanRoute(quotes: SwapQuotes, options?: { providerId?: string }): SelectedRoute { const providerIds = Object.keys(quotes.quotes); if (providerIds.length === 0) { @@ -187,9 +169,7 @@ export function getTitanSwapInstructionPlanFromRoute(route: SwapRoute): Instruct } // Multiple instructions are sequential (setup → swap → cleanup) - return sequentialInstructionPlan( - instructions.map(ix => singleInstructionPlan(ix)), - ); + return sequentialInstructionPlan(instructions.map(ix => singleInstructionPlan(ix))); } /** diff --git a/packages/core/README.md b/packages/core/README.md index 998fdbb..aed4150 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -268,6 +268,7 @@ new TransactionBuilder({ ``` The `'simulate'` strategy uses Kit's `@solana-program/compute-budget` helpers to: + 1. Add a provisory compute unit limit instruction during message building 2. Simulate the transaction to get accurate CU consumption 3. Update the instruction with the estimated value before signing and sending diff --git a/packages/core/src/builder/builder.ts b/packages/core/src/builder/builder.ts index 63cafb7..46bc1ba 100644 --- a/packages/core/src/builder/builder.ts +++ b/packages/core/src/builder/builder.ts @@ -46,35 +46,40 @@ import type { Instruction } from '@solana/instructions'; import type { TransactionMessage } from '@solana/transaction-messages'; import type { Blockhash } from '@solana/rpc-types'; import type { - Rpc, - GetLatestBlockhashApi, - GetAccountInfoApi, - GetEpochInfoApi, - GetSignatureStatusesApi, - SendTransactionApi, - SimulateTransactionApi, + Rpc, + GetLatestBlockhashApi, + GetAccountInfoApi, + GetMultipleAccountsApi, + GetEpochInfoApi, + GetSignatureStatusesApi, + SendTransactionApi, + SimulateTransactionApi, } from '@solana/rpc'; -import type { - RpcSubscriptions, - SignatureNotificationsApi, - SlotNotificationsApi, -} from '@solana/rpc-subscriptions'; +import type { RpcSubscriptions, SignatureNotificationsApi, SlotNotificationsApi } from '@solana/rpc-subscriptions'; import { pipe } from '@solana/functional'; import { - createTransactionMessage, - setTransactionMessageFeePayer, - setTransactionMessageLifetimeUsingBlockhash, - setTransactionMessageLifetimeUsingDurableNonce, - appendTransactionMessageInstruction, + createTransactionMessage, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + setTransactionMessageLifetimeUsingDurableNonce, + appendTransactionMessageInstruction, } from '@solana/transaction-messages'; -import { signTransactionMessageWithSigners, addSignersToTransactionMessage, type TransactionSigner } from '@solana/signers'; -import { sendAndConfirmTransactionFactory, getSignatureFromTransaction } from '@solana/kit'; -import { - getBase64EncodedWireTransaction, - getTransactionEncoder, - getTransactionMessageSize, - TRANSACTION_SIZE_LIMIT, - type Base64EncodedWireTransaction, +import { + signTransactionMessageWithSigners, + addSignersToTransactionMessage, + type TransactionSigner, +} from '@solana/signers'; +import { + sendAndConfirmTransactionFactory, + getSignatureFromTransaction, + fetchAddressesForLookupTables, +} from '@solana/kit'; +import { + getBase64EncodedWireTransaction, + getTransactionEncoder, + getTransactionMessageSize, + TRANSACTION_SIZE_LIMIT, + type Base64EncodedWireTransaction, } from '@solana/transactions'; import { getBase58Decoder } from '@solana/codecs-strings'; import { SolanaError, SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING } from '@solana/errors'; @@ -83,31 +88,24 @@ import { validateTransaction, validateTransactionSize } from '../validation/inde // Import new modules import { - type PriorityFeeConfig, - type ComputeUnitConfig, - createSetComputeUnitPriceInstruction, - createSetComputeUnitLimitInstruction, - estimatePriorityFee, - PRIORITY_FEE_LEVELS, - type PriorityFeeLevel, + type PriorityFeeConfig, + type ComputeUnitConfig, + createSetComputeUnitPriceInstruction, + createSetComputeUnitLimitInstruction, + estimatePriorityFee, + PRIORITY_FEE_LEVELS, + type PriorityFeeLevel, } from '../compute-budget/index.js'; import { - fillProvisorySetComputeUnitLimitInstruction, - estimateComputeUnitLimitFactory, - estimateAndUpdateProvisoryComputeUnitLimitFactory, + fillProvisorySetComputeUnitLimitInstruction, + estimateComputeUnitLimitFactory, + estimateAndUpdateProvisoryComputeUnitLimitFactory, } from '@solana-program/compute-budget'; import { fetchNonceValue, type DurableNonceConfig } from '../nonce/index.js'; -import { - type AddressesByLookupTableAddress, - fetchAddressLookupTables, - compressTransactionMessage, -} from '../lookup-tables/index.js'; +import { type AddressesByLookupTableAddress, compressTransactionMessage } from '../lookup-tables/index.js'; // Execution strategies -import { - resolveExecutionConfig, - executeWithStrategy, -} from '../execution/strategies.js'; +import { resolveExecutionConfig, executeWithStrategy } from '../execution/strategies.js'; import { createTipInstruction } from '../execution/jito.js'; // ============================================================================ @@ -127,24 +125,24 @@ export type ExportFormat = 'base64' | 'base58' | 'bytes'; * The transaction was confirmed (included in a block) but the program returned an error. */ export class TransactionExecutionError extends Error { - readonly signature: string; - readonly err: unknown; - - constructor(signature: string, err: unknown) { - super(`Transaction execution failed: ${JSON.stringify(err)}`); - this.name = 'TransactionExecutionError'; - this.signature = signature; - this.err = err; - } + readonly signature: string; + readonly err: unknown; + + constructor(signature: string, err: unknown) { + super(`Transaction execution failed: ${JSON.stringify(err)}`); + this.name = 'TransactionExecutionError'; + this.signature = signature; + this.err = err; + } } /** * Exported transaction in various formats. */ -export type ExportedTransaction = - | { format: 'base64'; data: Base64EncodedWireTransaction } - | { format: 'base58'; data: string } - | { format: 'bytes'; data: Uint8Array }; +export type ExportedTransaction = + | { format: 'base64'; data: Base64EncodedWireTransaction } + | { format: 'base58'; data: string } + | { format: 'bytes'; data: Uint8Array }; // Note: Compute budget constants and helpers are now imported from ../compute-budget/index.js @@ -152,1003 +150,991 @@ export type ExportedTransaction = * Configuration for transaction builder. */ export interface TransactionBuilderConfig { - /** - * Transaction version (0 for versioned transactions, 'legacy' for legacy). - */ - version?: 0 | 'legacy'; - - /** - * RPC client for auto-fetching blockhash when not explicitly provided. - */ - rpc?: Rpc; - - /** - * Auto-retry failed transactions. - * - `true`: Use default retry (3 attempts, exponential backoff) - * - `false`: No retry - * - Object: Custom retry configuration - */ - autoRetry?: boolean | { maxAttempts: number; backoff: 'linear' | 'exponential' }; - - /** - * Logging level. - */ - logLevel?: 'silent' | 'minimal' | 'verbose'; - - /** - * Priority fee configuration. - * - PriorityFeeLevel string: Use preset level ('none', 'low', 'medium', 'high', 'veryHigh') - * - PriorityFeeConfig object: Use custom configuration with strategy - */ - priorityFee?: PriorityFeeLevel | PriorityFeeConfig; - - /** - * Compute unit configuration. - * - 'auto': Use default (200,000 CU, no explicit instruction) - * - number: Use fixed compute unit limit - * - ComputeUnitConfig object: Use custom configuration with strategy - */ - computeUnits?: 'auto' | number | ComputeUnitConfig; - - /** - * Address lookup table addresses to fetch and use for compression. - * Only works with version 0 transactions. - */ - lookupTableAddresses?: Address[]; - - /** - * Pre-fetched lookup table data. - * Use this to avoid fetching if you already have the data. - */ - addressesByLookupTable?: AddressesByLookupTableAddress; + /** + * Transaction version (0 for versioned transactions, 'legacy' for legacy). + */ + version?: 0 | 'legacy'; + + /** + * RPC client for auto-fetching blockhash when not explicitly provided. + * If using lookupTableAddresses, the RPC must also support GetMultipleAccountsApi. + */ + rpc?: Rpc; + + /** + * Auto-retry failed transactions. + * - `true`: Use default retry (3 attempts, exponential backoff) + * - `false`: No retry + * - Object: Custom retry configuration + */ + autoRetry?: boolean | { maxAttempts: number; backoff: 'linear' | 'exponential' }; + + /** + * Logging level. + */ + logLevel?: 'silent' | 'minimal' | 'verbose'; + + /** + * Priority fee configuration. + * - PriorityFeeLevel string: Use preset level ('none', 'low', 'medium', 'high', 'veryHigh') + * - PriorityFeeConfig object: Use custom configuration with strategy + */ + priorityFee?: PriorityFeeLevel | PriorityFeeConfig; + + /** + * Compute unit configuration. + * - 'auto': Use default (200,000 CU, no explicit instruction) + * - number: Use fixed compute unit limit + * - ComputeUnitConfig object: Use custom configuration with strategy + */ + computeUnits?: 'auto' | number | ComputeUnitConfig; + + /** + * Address lookup table addresses to fetch and use for compression. + * Only works with version 0 transactions. + */ + lookupTableAddresses?: Address[]; + + /** + * Pre-fetched lookup table data. + * Use this to avoid fetching if you already have the data. + */ + addressesByLookupTable?: AddressesByLookupTableAddress; } /** * Result from transaction simulation. */ export interface SimulationResult { - /** - * Error if simulation failed, null otherwise. - */ - err: unknown | null; - /** - * Log messages from simulation. - */ - logs: string[] | null; - /** - * Compute units consumed during simulation. - */ - unitsConsumed: bigint | undefined; - /** - * Return data from program execution. - */ - returnData: any; + /** + * Error if simulation failed, null otherwise. + */ + err: unknown | null; + /** + * Log messages from simulation. + */ + logs: string[] | null; + /** + * Compute units consumed during simulation. + */ + unitsConsumed: bigint | undefined; + /** + * Return data from program execution. + */ + returnData: any; } /** * Unified transaction builder with type-safe state tracking and smart defaults. */ export class TransactionBuilder { - private feePayer?: Address; - private feePayerSigner?: TransactionSigner; - private lifetime?: LifetimeConstraint; - private instructions: Instruction[] = []; - - private config: { - version: 0 | 'legacy'; - rpc: Rpc | undefined; - autoRetry: boolean | { maxAttempts: number; backoff: 'linear' | 'exponential' }; - logLevel: 'silent' | 'minimal' | 'verbose'; - priorityFee: PriorityFeeLevel | PriorityFeeConfig; - computeUnits: 'auto' | number | ComputeUnitConfig; - lookupTableAddresses?: Address[]; - addressesByLookupTable?: AddressesByLookupTableAddress; - }; - - constructor(config: TransactionBuilderConfig = {}) { - this.config = { - version: config.version ?? 0, - rpc: config.rpc, - autoRetry: config.autoRetry ?? { maxAttempts: 3, backoff: 'exponential' }, - logLevel: config.logLevel ?? 'silent', - priorityFee: config.priorityFee ?? 'medium', - computeUnits: config.computeUnits ?? 'auto', - ...(config.lookupTableAddresses && { lookupTableAddresses: config.lookupTableAddresses }), - ...(config.addressesByLookupTable && { addressesByLookupTable: config.addressesByLookupTable }), - }; - } - - /** - * Create a TransactionBuilder configured for durable nonce transactions. - * Automatically fetches the current nonce value from the account. - * - * @param config - Durable nonce configuration - * @returns TransactionBuilder with nonce lifetime already set - * - * @example - * ```ts - * const builder = await TransactionBuilder.withDurableNonce({ - * rpc, - * nonceAccountAddress: address('...'), - * nonceAuthorityAddress: address('...'), - * }); - * - * await builder - * .setFeePayer(feePayer) - * .addInstruction(ix) - * .execute({ rpcSubscriptions }); - * ``` - */ - static async withDurableNonce( - config: DurableNonceConfig & { rpc: Rpc } & Omit - ): Promise> { - const { nonceAccountAddress, nonceAuthorityAddress, nonce: providedNonce, rpc, ...builderConfig } = config; - - // Fetch nonce if not provided - const nonce = providedNonce ?? await fetchNonceValue(rpc, nonceAccountAddress); - - // Create builder with nonce lifetime already set - const builder = new TransactionBuilder({ rpc, ...builderConfig }); - builder.lifetime = { - type: 'nonce', - nonce, - nonceAccountAddress, - nonceAuthorityAddress, - }; - - return builder as TransactionBuilder<{ lifetime: true }>; - } - - /** - * Set the fee payer for the transaction using just an address. - * Note: When using execute(), you should use setFeePayerSigner() instead - * to properly sign the transaction. - */ - setFeePayer( - feePayer: Address - ): TransactionBuilder { - const builder = this.clone(); - builder.feePayer = feePayer; - return builder as TransactionBuilder; - } - - /** - * Set the fee payer for the transaction using a signer. - * This is the recommended method when using execute() as it properly - * signs the transaction. - */ - setFeePayerSigner( - signer: TransactionSigner - ): TransactionBuilder { - const builder = this.clone(); - builder.feePayer = signer.address; - builder.feePayerSigner = signer; - return builder as TransactionBuilder; - } - - /** - * Set blockhash lifetime for the transaction. - */ - setBlockhashLifetime( - blockhash: Blockhash, - lastValidBlockHeight: bigint - ): TransactionBuilder { - const builder = this.clone(); - builder.lifetime = { - type: 'blockhash', - blockhash, - lastValidBlockHeight, - }; - return builder as TransactionBuilder; - } - - /** - * Set durable nonce lifetime for the transaction. - */ - setDurableNonceLifetime( - nonce: string, - nonceAccountAddress: Address, - nonceAuthorityAddress: Address - ): TransactionBuilder { - const builder = this.clone(); - builder.lifetime = { - type: 'nonce', - nonce, - nonceAccountAddress, - nonceAuthorityAddress, + private feePayer?: Address; + private feePayerSigner?: TransactionSigner; + private lifetime?: LifetimeConstraint; + private instructions: Instruction[] = []; + + private config: { + version: 0 | 'legacy'; + rpc: Rpc | undefined; + autoRetry: boolean | { maxAttempts: number; backoff: 'linear' | 'exponential' }; + logLevel: 'silent' | 'minimal' | 'verbose'; + priorityFee: PriorityFeeLevel | PriorityFeeConfig; + computeUnits: 'auto' | number | ComputeUnitConfig; + lookupTableAddresses?: Address[]; + addressesByLookupTable?: AddressesByLookupTableAddress; }; - return builder as TransactionBuilder; - } - - /** - * Add a single instruction to the transaction. - */ - addInstruction( - instruction: Instruction - ): TransactionBuilder { - const builder = this.clone(); - builder.instructions.push(instruction); - return builder; - } - - /** - * Add multiple instructions to the transaction. - */ - addInstructions( - instructions: readonly Instruction[] - ): TransactionBuilder { - const builder = this.clone(); - builder.instructions.push(...instructions); - return builder; - } - - /** - * Build the transaction message. - * Only available when all required fields (feePayer, lifetime) are set. - * - * If RPC was provided in constructor and lifetime not set, automatically fetches latest blockhash. - * Automatically prepends compute budget instructions if configured. - * Applies address lookup table compression if configured (version 0 only). - */ - async build( - this: TransactionBuilder - ): Promise { - if (!this.feePayer) { - throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); - } - // AUTO-FETCH: If lifetime not set but RPC available, fetch latest blockhash - if (!this.lifetime && this.config.rpc) { - const { value } = await this.config.rpc.getLatestBlockhash().send(); - this.lifetime = { - type: 'blockhash', - blockhash: value.blockhash, - lastValidBlockHeight: value.lastValidBlockHeight, - }; + constructor(config: TransactionBuilderConfig = {}) { + this.config = { + version: config.version ?? 0, + rpc: config.rpc, + autoRetry: config.autoRetry ?? { maxAttempts: 3, backoff: 'exponential' }, + logLevel: config.logLevel ?? 'silent', + priorityFee: config.priorityFee ?? 'medium', + computeUnits: config.computeUnits ?? 'auto', + ...(config.lookupTableAddresses && { lookupTableAddresses: config.lookupTableAddresses }), + ...(config.addressesByLookupTable && { addressesByLookupTable: config.addressesByLookupTable }), + }; } - if (!this.lifetime) { - throw new Error( - 'Lifetime required. Provide blockhash via setBlockhashLifetime() or pass rpc to constructor for auto-fetch.' - ); - } + /** + * Create a TransactionBuilder configured for durable nonce transactions. + * Automatically fetches the current nonce value from the account. + * + * @param config - Durable nonce configuration + * @returns TransactionBuilder with nonce lifetime already set + * + * @example + * ```ts + * const builder = await TransactionBuilder.withDurableNonce({ + * rpc, + * nonceAccountAddress: address('...'), + * nonceAuthorityAddress: address('...'), + * }); + * + * await builder + * .setFeePayer(feePayer) + * .addInstruction(ix) + * .execute({ rpcSubscriptions }); + * ``` + */ + static async withDurableNonce( + config: DurableNonceConfig & { rpc: Rpc } & Omit< + TransactionBuilderConfig, + 'rpc' + >, + ): Promise> { + const { nonceAccountAddress, nonceAuthorityAddress, nonce: providedNonce, rpc, ...builderConfig } = config; - // Fetch lookup tables if addresses provided but data not - let lookupTableData = this.config.addressesByLookupTable; - if (!lookupTableData && this.config.lookupTableAddresses?.length && this.config.rpc) { - lookupTableData = await fetchAddressLookupTables( - this.config.rpc, - this.config.lookupTableAddresses - ); - } + // Fetch nonce if not provided + const nonce = providedNonce ?? (await fetchNonceValue(rpc, nonceAccountAddress)); - // Build using Kit's functional API with pipe - let message: any = pipe( - createTransactionMessage({ version: this.config.version }), - (tx) => setTransactionMessageFeePayer(this.feePayer!, tx), - (tx) => this.lifetime!.type === 'blockhash' - ? setTransactionMessageLifetimeUsingBlockhash( - { - blockhash: this.lifetime!.blockhash as any, - lastValidBlockHeight: this.lifetime!.lastValidBlockHeight, - }, - tx - ) - : setTransactionMessageLifetimeUsingDurableNonce( - { - nonce: this.lifetime!.nonce as any, - nonceAccountAddress: this.lifetime!.nonceAccountAddress, - nonceAuthorityAddress: this.lifetime!.nonceAuthorityAddress, - }, - tx - ) - ); - - // Attach fee payer signer if available - if (this.feePayerSigner) { - message = addSignersToTransactionMessage([this.feePayerSigner], message); - } + // Create builder with nonce lifetime already set + const builder = new TransactionBuilder({ rpc, ...builderConfig }); + builder.lifetime = { + type: 'nonce', + nonce, + nonceAccountAddress, + nonceAuthorityAddress, + }; - // ADD COMPUTE BUDGET INSTRUCTIONS FIRST (if configured) - // Order matters: limit first, then price - - // 1. Compute unit limit - const computeUnits = await this.resolveComputeUnits(); - if (computeUnits === TransactionBuilder.PROVISORY_CU_SENTINEL) { - // Use provisory instruction - will be estimated via simulation during execute() - message = fillProvisorySetComputeUnitLimitInstruction(message); - } else if (computeUnits !== null) { - message = appendTransactionMessageInstruction( - createSetComputeUnitLimitInstruction(computeUnits), - message - ); - } - - // 2. Priority fee / compute unit price - const priorityFee = await this.resolvePriorityFee(); - if (this.config.logLevel !== 'silent') { - console.log(`[Pipeit] Priority fee: ${priorityFee.toLocaleString()} micro-lamports/CU (${(priorityFee / 1_000_000).toFixed(3)} lamports/CU)`); - } - if (priorityFee > 0) { - message = appendTransactionMessageInstruction( - createSetComputeUnitPriceInstruction(priorityFee), - message - ); + return builder as TransactionBuilder<{ lifetime: true }>; } - // Add user's instructions after compute budget instructions - for (const instruction of this.instructions) { - message = appendTransactionMessageInstruction(instruction, message); + /** + * Set the fee payer for the transaction using just an address. + * Note: When using execute(), you should use setFeePayerSigner() instead + * to properly sign the transaction. + */ + setFeePayer(feePayer: Address): TransactionBuilder { + const builder = this.clone(); + builder.feePayer = feePayer; + return builder as TransactionBuilder; } - // Apply address lookup table compression (version 0 only) - if (lookupTableData && this.config.version === 0) { - message = compressTransactionMessage(message, lookupTableData); + /** + * Set the fee payer for the transaction using a signer. + * This is the recommended method when using execute() as it properly + * signs the transaction. + */ + setFeePayerSigner(signer: TransactionSigner): TransactionBuilder { + const builder = this.clone(); + builder.feePayer = signer.address; + builder.feePayerSigner = signer; + return builder as TransactionBuilder; } - // Auto-validate before returning - validateTransaction(message); - validateTransactionSize(message); - - return message; - } - - /** - * Resolve priority fee based on configuration. - */ - private async resolvePriorityFee(): Promise { - const { priorityFee } = this.config; - - // String level (preset) - if (typeof priorityFee === 'string') { - return PRIORITY_FEE_LEVELS[priorityFee] ?? 0; - } - - // Config object - if (priorityFee.strategy === 'none') { - return 0; - } - - if (priorityFee.strategy === 'fixed') { - return priorityFee.microLamports ?? 0; - } - - // Percentile strategy - requires RPC - if (priorityFee.strategy === 'percentile' && this.config.rpc) { - const estimate = await estimatePriorityFee( - this.config.rpc as any, - priorityFee - ); - return estimate.microLamports; - } - - // Fallback to medium - return PRIORITY_FEE_LEVELS.medium; - } - - /** - * Sentinel value indicating provisory CU instruction should be used. - * The actual CU limit will be estimated via simulation during execute(). - */ - private static readonly PROVISORY_CU_SENTINEL = -1; - - /** - * Check if the current compute units config uses the simulate strategy. - */ - private isSimulateCUStrategy(): boolean { - const { computeUnits } = this.config; - if (typeof computeUnits === 'object' && computeUnits.strategy === 'simulate') { - return true; - } - return false; - } - - /** - * Resolve compute units based on configuration. - * Returns null if no compute unit instruction should be added. - * Returns PROVISORY_CU_SENTINEL if a provisory instruction should be added - * (will be updated via simulation during execute()). - */ - private async resolveComputeUnits(): Promise { - const { computeUnits } = this.config; - - // 'auto' = no explicit instruction - if (computeUnits === 'auto') { - return null; - } - - // Fixed number - if (typeof computeUnits === 'number') { - return computeUnits; - } - - // Config object - if (computeUnits.strategy === 'auto') { - return null; - } - - if (computeUnits.strategy === 'fixed') { - return computeUnits.units ?? 200_000; - } - - // Simulate strategy - use provisory pattern - // The actual CU limit will be estimated via simulation during execute() - if (computeUnits.strategy === 'simulate') { - return TransactionBuilder.PROVISORY_CU_SENTINEL; - } - - return null; - } - - /** - * Simulate the transaction without sending it. - * Useful for testing and debugging before execution. - * - * Note: Requires feePayer to be set and RPC in config. - */ - async simulate(params?: { - commitment?: 'processed' | 'confirmed' | 'finalized'; - }): Promise { - const commitment = params?.commitment ?? 'confirmed'; - - if (!this.feePayer) { - throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); - } - - if (!this.config.rpc) { - throw new Error('RPC required for simulation. Pass rpc in constructor.'); - } - - // Build message using the unified build method - const message = await (this as any).build(); - - // Sign for simulation - const signedTransaction: any = await signTransactionMessageWithSigners(message); - - // Simulate using Kit's API - const rpcWithSim = this.config.rpc as unknown as Rpc; - const result = await rpcWithSim.simulateTransaction(signedTransaction, { - commitment, - replaceRecentBlockhash: true, - }).send(); - - return { - err: result.value.err, - logs: result.value.logs, - unitsConsumed: result.value.unitsConsumed, - returnData: result.value.returnData, - }; - } - - /** - * Sign and export the transaction in specified format WITHOUT sending. - * - * Use this when you want to: - * - Send via custom transport or different RPC - * - Store signed transactions for batch sending - * - Use with hardware wallets - * - Generate QR codes for mobile wallets - * - Pass transactions to other systems - * - * @param format - Export format: 'base64' (default), 'base58', or 'bytes' - * @returns Serialized signed transaction - * - * @example - * ```ts - * // Export for custom RPC - * const { data: base64Tx } = await builder.export('base64'); - * await customRpc.sendTransaction(base64Tx, { encoding: 'base64' }); - * - * // Export for hardware wallet - * const { data: bytes } = await builder.export('bytes'); - * await ledger.signTransaction(bytes); - * - * // Export for QR code - * const { data: base58Tx } = await builder.export('base58'); - * displayQRCode(base58Tx); - * ``` - */ - async export(format: ExportFormat = 'base64'): Promise { - if (!this.feePayer) { - throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); - } - - if (!this.config.rpc) { - throw new Error('RPC required for export. Pass rpc in constructor.'); - } - - // Build message using the unified build method - let message = await (this as any).build(); - - // If using simulate strategy, estimate and update the provisory CU instruction - if (this.isSimulateCUStrategy()) { - const rpcWithSim = this.config.rpc as unknown as Rpc; - const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: rpcWithSim }); - const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); - message = await estimateAndSetCULimit(message); - } - - // Sign transaction - const signedTransaction: any = await signTransactionMessageWithSigners(message); - - // Serialize in requested format - switch (format) { - case 'base64': { - const base64 = getBase64EncodedWireTransaction(signedTransaction); - return { format: 'base64', data: base64 }; - } - case 'base58': { - const encoder = getTransactionEncoder(); - const bytes = encoder.encode(signedTransaction); - const base58Decoder = getBase58Decoder(); - const base58 = base58Decoder.decode(new Uint8Array(bytes)); - return { format: 'base58', data: base58 }; - } - case 'bytes': { - const encoder = getTransactionEncoder(); - const bytes = encoder.encode(signedTransaction); - return { format: 'bytes', data: new Uint8Array(bytes) }; - } - } - } - - /** - * Execute the transaction with smart defaults. - * - * Supports advanced sending options like skipPreflight and maxRetries, - * as well as execution strategies for Jito bundles and parallel submission. - * - * Note: Requires feePayer to be set and RPC in config. - * - * @example - * ```ts - * // Basic execution - * const sig = await builder.execute({ rpcSubscriptions }); - * - * // With execution strategy preset - * const sig = await builder.execute({ - * rpcSubscriptions, - * execution: 'fast', // Jito + parallel for max speed - * }); - * - * // With custom execution config - * const sig = await builder.execute({ - * rpcSubscriptions, - * execution: { - * jito: { enabled: true, tipLamports: 50_000n }, - * parallel: { enabled: true, endpoints: ['https://my-rpc.com'] }, - * }, - * }); - * - * // With sending options - * const sig = await builder.execute({ - * rpcSubscriptions, - * skipPreflight: false, - * skipPreflightOnRetry: true, - * maxRetries: 5, - * preflightCommitment: 'confirmed', - * }); - * ``` - */ - async execute(params: { - rpcSubscriptions: RpcSubscriptions; - } & ExecuteConfig): Promise { - const { - rpcSubscriptions, - commitment = 'confirmed', - skipPreflight = false, - skipPreflightOnRetry = true, - preflightCommitment = 'confirmed', - maxRetries, - execution, - } = params; - - if (!this.feePayer) { - throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); + /** + * Set blockhash lifetime for the transaction. + */ + setBlockhashLifetime( + blockhash: Blockhash, + lastValidBlockHeight: bigint, + ): TransactionBuilder { + const builder = this.clone(); + builder.lifetime = { + type: 'blockhash', + blockhash, + lastValidBlockHeight, + }; + return builder as TransactionBuilder; } - - if (!this.config.rpc) { - throw new Error('RPC required for execute. Pass rpc in constructor.'); + + /** + * Set durable nonce lifetime for the transaction. + */ + setDurableNonceLifetime( + nonce: string, + nonceAccountAddress: Address, + nonceAuthorityAddress: Address, + ): TransactionBuilder { + const builder = this.clone(); + builder.lifetime = { + type: 'nonce', + nonce, + nonceAccountAddress, + nonceAuthorityAddress, + }; + return builder as TransactionBuilder; } - - const rpc = this.config.rpc as unknown as Rpc; - - // Resolve execution strategy - const executionConfig = resolveExecutionConfig(execution); - - // If Jito is enabled, we need to add the tip instruction before building - // Clone the builder to avoid mutating the original - let builderToUse: TransactionBuilder = this; - - if (executionConfig.jito.enabled && executionConfig.jito.tipLamports > 0n) { - builderToUse = this.clone(); - const tipInstruction = createTipInstruction( - this.feePayer, - executionConfig.jito.tipLamports - ); - builderToUse.instructions.push(tipInstruction); - - if (this.config.logLevel !== 'silent') { - console.log(`[Pipeit] Adding Jito tip: ${executionConfig.jito.tipLamports} lamports`); - } + + /** + * Add a single instruction to the transaction. + */ + addInstruction(instruction: Instruction): TransactionBuilder { + const builder = this.clone(); + builder.instructions.push(instruction); + return builder; } - - // Build message using the unified build method - let message = await (builderToUse as any).build(); - - // If using simulate strategy, estimate and update the provisory CU instruction - if (builderToUse.isSimulateCUStrategy()) { - const rpcWithSim = this.config.rpc as unknown as Rpc; - const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: rpcWithSim }); - const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); - message = await estimateAndSetCULimit(message); - - if (this.config.logLevel !== 'silent') { - console.log(`[Pipeit] Estimated compute units via simulation`); - } + + /** + * Add multiple instructions to the transaction. + */ + addInstructions(instructions: readonly Instruction[]): TransactionBuilder { + const builder = this.clone(); + builder.instructions.push(...instructions); + return builder; } - - // Sign transaction - const signedTransaction: any = await signTransactionMessageWithSigners(message); - - // Get base64 encoded transaction for execution strategies - const base64Tx = getBase64EncodedWireTransaction(signedTransaction); - - // Check if we should use execution strategies (Jito, parallel, or TPU enabled) - const useExecutionStrategy = executionConfig.jito.enabled || executionConfig.parallel.enabled || executionConfig.tpu.enabled; - - if (useExecutionStrategy) { - // Use execution strategy - if (this.config.logLevel !== 'silent') { - const strategyName = executionConfig.tpu.enabled - ? 'TPU Direct' - : executionConfig.jito.enabled && executionConfig.parallel.enabled - ? 'Jito + Parallel' - : executionConfig.jito.enabled - ? 'Jito' - : 'Parallel'; - console.log(`[Pipeit] Using ${strategyName} execution strategy`); - } - - // Extract RPC URL from the RPC client - // Note: We need to get the URL somehow - for now, assume it's available - // In practice, users should provide endpoints in parallel config - const rpcUrl = this.getRpcUrl(); - - const result = await executeWithStrategy(base64Tx, executionConfig, { - ...(rpcUrl && { rpcUrl }), - feePayer: this.feePayer, - ...(params.abortSignal && { abortSignal: params.abortSignal }), - }); - - // For TPU with continuous resubmission, confirmation happens server-side - if (result.landedVia === 'tpu') { - if (this.config.logLevel !== 'silent') { - if (result.confirmed) { - console.log( - `[Pipeit] ✅ Transaction CONFIRMED on-chain via TPU!\n` + - ` Rounds: ${result.rounds ?? 'N/A'}, Leaders sent: ${result.leaderCount ?? 'N/A'}\n` + - ` Latency: ${result.latencyMs ?? 'N/A'}ms` + + /** + * Build the transaction message. + * Only available when all required fields (feePayer, lifetime) are set. + * + * If RPC was provided in constructor and lifetime not set, automatically fetches latest blockhash. + * Automatically prepends compute budget instructions if configured. + * Applies address lookup table compression if configured (version 0 only). + */ + async build(this: TransactionBuilder): Promise { + if (!this.feePayer) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); + } + + // AUTO-FETCH: If lifetime not set but RPC available, fetch latest blockhash + if (!this.lifetime && this.config.rpc) { + const { value } = await this.config.rpc.getLatestBlockhash().send(); + this.lifetime = { + type: 'blockhash', + blockhash: value.blockhash, + lastValidBlockHeight: value.lastValidBlockHeight, + }; + } + + if (!this.lifetime) { + throw new Error( + 'Lifetime required. Provide blockhash via setBlockhashLifetime() or pass rpc to constructor for auto-fetch.', ); - } else { - console.warn( - `[Pipeit] ⚠️ TPU submission completed but transaction NOT confirmed.\n` + - ` Rounds: ${result.rounds ?? 'N/A'}, Leaders sent: ${result.leaderCount ?? 'N/A'}\n` + - ` Signature: ${result.signature}\n` + - ` The transaction may still land - check explorer.` + } + + // Fetch lookup tables if addresses provided but data not + let lookupTableData = this.config.addressesByLookupTable; + if (!lookupTableData && this.config.lookupTableAddresses?.length && this.config.rpc) { + // Cast to GetMultipleAccountsApi - users must provide an RPC that supports this when using lookupTableAddresses + lookupTableData = await fetchAddressesForLookupTables( + this.config.lookupTableAddresses, + this.config.rpc as unknown as Rpc, ); - } } - - // Return signature - for TPU, confirmation already happened server-side - return result.signature; - } - - // For non-TPU strategies (Jito, parallel), use standard confirmation - if (this.config.logLevel !== 'silent') { - console.log(`[Pipeit] Transaction sent via ${result.landedVia}${result.latencyMs ? ` in ${result.latencyMs}ms` : ''}`); - } - - // Confirm via WebSocket subscription - try { - await this.confirmTransaction(result.signature, rpcSubscriptions, commitment); + + // Build using Kit's functional API with pipe + let message: any = pipe( + createTransactionMessage({ version: this.config.version }), + tx => setTransactionMessageFeePayer(this.feePayer!, tx), + tx => + this.lifetime!.type === 'blockhash' + ? setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: this.lifetime!.blockhash as any, + lastValidBlockHeight: this.lifetime!.lastValidBlockHeight, + }, + tx, + ) + : setTransactionMessageLifetimeUsingDurableNonce( + { + nonce: this.lifetime!.nonce as any, + nonceAccountAddress: this.lifetime!.nonceAccountAddress, + nonceAuthorityAddress: this.lifetime!.nonceAuthorityAddress, + }, + tx, + ), + ); + + // Attach fee payer signer if available + if (this.feePayerSigner) { + message = addSignersToTransactionMessage([this.feePayerSigner], message); + } + + // ADD COMPUTE BUDGET INSTRUCTIONS FIRST (if configured) + // Order matters: limit first, then price + + // 1. Compute unit limit + const computeUnits = await this.resolveComputeUnits(); + if (computeUnits === TransactionBuilder.PROVISORY_CU_SENTINEL) { + // Use provisory instruction - will be estimated via simulation during execute() + message = fillProvisorySetComputeUnitLimitInstruction(message); + } else if (computeUnits !== null) { + message = appendTransactionMessageInstruction(createSetComputeUnitLimitInstruction(computeUnits), message); + } + + // 2. Priority fee / compute unit price + const priorityFee = await this.resolvePriorityFee(); if (this.config.logLevel !== 'silent') { - console.log(`[Pipeit] ✅ Transaction confirmed via WebSocket subscription`); + console.log( + `[Pipeit] Priority fee: ${priorityFee.toLocaleString()} micro-lamports/CU (${(priorityFee / 1_000_000).toFixed(3)} lamports/CU)`, + ); } - } catch (confirmError) { - throw confirmError; - } - - // Verify transaction execution status (catch false positives) - await this.verifyTransactionSuccess(rpc, result.signature); - - return result.signature; - } - - // Standard execution path (no Jito, no parallel) - const sendAndConfirm = sendAndConfirmTransactionFactory({ - rpc, - rpcSubscriptions - }); - - // Add retry logic if enabled - if (this.config.autoRetry) { - return this.executeWithRetry(sendAndConfirm, signedTransaction, commitment, rpc, { - skipPreflightOnRetry, - preflightCommitment, - }); + if (priorityFee > 0) { + message = appendTransactionMessageInstruction(createSetComputeUnitPriceInstruction(priorityFee), message); + } + + // Add user's instructions after compute budget instructions + for (const instruction of this.instructions) { + message = appendTransactionMessageInstruction(instruction, message); + } + + // Apply address lookup table compression (version 0 only) + if (lookupTableData && this.config.version === 0) { + message = compressTransactionMessage(message, lookupTableData); + } + + // Auto-validate before returning + validateTransaction(message); + validateTransactionSize(message); + + return message; } - - // Prepare send options (avoid undefined for exactOptionalPropertyTypes) - const sendOptions: Parameters[1] = { - commitment, - ...(skipPreflight && { skipPreflight }), - ...(!skipPreflight && { preflightCommitment }), - ...(maxRetries !== undefined && { maxRetries: BigInt(maxRetries) }), - }; - - await sendAndConfirm(signedTransaction, sendOptions); - const signature = getSignatureFromTransaction(signedTransaction); - - // Verify transaction execution status (catch false positives) - await this.verifyTransactionSuccess(rpc, signature); - - return signature; - } - - /** - * Get the RPC URL from the configured RPC client. - * This is a best-effort extraction - may not work for all RPC client types. - */ - private getRpcUrl(): string | undefined { - // The RPC client from @solana/rpc doesn't expose the URL directly - // Users should configure parallel.endpoints if they want parallel submission - // For now, return undefined and let the execution strategy handle it - return undefined; - } - - /** - * Confirm a transaction signature using WebSocket subscriptions. - */ - private async confirmTransaction( - signature: string, - rpcSubscriptions: RpcSubscriptions, - commitment: 'processed' | 'confirmed' | 'finalized' - ): Promise { - // Subscribe to signature notifications - const notifications = await rpcSubscriptions - .signatureNotifications(signature as any, { commitment }) - .subscribe({ abortSignal: AbortSignal.timeout(60_000) }); - - // Wait for confirmation - for await (const notification of notifications) { - if (notification.value.err) { - throw new TransactionExecutionError(signature, notification.value.err); - } - // Transaction confirmed - return; + + /** + * Resolve priority fee based on configuration. + */ + private async resolvePriorityFee(): Promise { + const { priorityFee } = this.config; + + // String level (preset) + if (typeof priorityFee === 'string') { + return PRIORITY_FEE_LEVELS[priorityFee] ?? 0; + } + + // Config object + if (priorityFee.strategy === 'none') { + return 0; + } + + if (priorityFee.strategy === 'fixed') { + return priorityFee.microLamports ?? 0; + } + + // Percentile strategy - requires RPC + if (priorityFee.strategy === 'percentile' && this.config.rpc) { + const estimate = await estimatePriorityFee(this.config.rpc as any, priorityFee); + return estimate.microLamports; + } + + // Fallback to medium + return PRIORITY_FEE_LEVELS.medium; } - } - - /** - * Verify that a transaction executed successfully (no program errors). - * This catches false positives where a transaction is confirmed but failed execution. - * - * Uses `searchTransactionHistory: true` to perform a ledger lookup instead of relying - * on the RPC's in-memory cache. Retries with exponential backoff if the status is null - * (not yet available), throwing only after all attempts are exhausted. - */ - private async verifyTransactionSuccess( - rpc: Rpc, - signature: string, - options?: { - /** Maximum number of retry attempts (default: 12) */ - maxAttempts?: number; - /** Initial delay in milliseconds before first retry (default: 1000) */ - initialDelayMs?: number; - /** Maximum delay in milliseconds between retries (default: 8000) */ - maxDelayMs?: number; + + /** + * Sentinel value indicating provisory CU instruction should be used. + * The actual CU limit will be estimated via simulation during execute(). + */ + private static readonly PROVISORY_CU_SENTINEL = -1; + + /** + * Check if the current compute units config uses the simulate strategy. + */ + private isSimulateCUStrategy(): boolean { + const { computeUnits } = this.config; + if (typeof computeUnits === 'object' && computeUnits.strategy === 'simulate') { + return true; + } + return false; } - ): Promise { - const { - maxAttempts = 12, // Increased from 5 - gives more time for RPC to index - initialDelayMs = 1000, // Increased from 500 - start with longer delay - maxDelayMs = 8000, // Increased from 4000 - longer max delay for slow RPCs - } = options ?? {}; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const { value: statuses } = await rpc - .getSignatureStatuses([signature as any], { - searchTransactionHistory: true, - }) - .send(); - - const status = statuses[0]; - - // If we got a definitive status, check for errors - if (status !== null) { - if (status.err) { - throw new TransactionExecutionError(signature, status.err); + + /** + * Resolve compute units based on configuration. + * Returns null if no compute unit instruction should be added. + * Returns PROVISORY_CU_SENTINEL if a provisory instruction should be added + * (will be updated via simulation during execute()). + */ + private async resolveComputeUnits(): Promise { + const { computeUnits } = this.config; + + // 'auto' = no explicit instruction + if (computeUnits === 'auto') { + return null; } - // Transaction executed successfully - return; - } - - // Status is null - not yet available in ledger - if (attempt === maxAttempts) { - // Exhausted all attempts without getting a definitive status - throw new Error( - `Unable to verify transaction status after ${maxAttempts} attempts. ` + - `Signature: ${signature}. The transaction may have landed but status could not be confirmed.` - ); - } - - // Calculate delay with exponential backoff, capped at maxDelayMs - const delay = Math.min( - initialDelayMs * Math.pow(2, attempt - 1), - maxDelayMs - ); - - if (this.config.logLevel === 'verbose') { - console.log(`[Pipeit] Transaction status not yet available, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`); - } - - await new Promise(resolve => setTimeout(resolve, delay)); + + // Fixed number + if (typeof computeUnits === 'number') { + return computeUnits; + } + + // Config object + if (computeUnits.strategy === 'auto') { + return null; + } + + if (computeUnits.strategy === 'fixed') { + return computeUnits.units ?? 200_000; + } + + // Simulate strategy - use provisory pattern + // The actual CU limit will be estimated via simulation during execute() + if (computeUnits.strategy === 'simulate') { + return TransactionBuilder.PROVISORY_CU_SENTINEL; + } + + return null; } - } - - /** - * Get current transaction size information. - * Useful before calling build() to check if more instructions can fit. - * - * Note: This builds the message to calculate accurate size. - * Requires feePayer to be set and RPC in config for auto-blockhash. - * - * @example - * ```ts - * const info = await builder.getSizeInfo(); - * console.log(`Using ${info.percentUsed.toFixed(1)}% of transaction space`); - * console.log(`${info.remaining} bytes remaining`); - * ``` - */ - async getSizeInfo(): Promise<{ - size: number; - limit: number; - remaining: number; - percentUsed: number; - canFitMore: boolean; - }> { - // Build message to get accurate size - const message = await (this as any).build(); - const size = getTransactionMessageSize(message); - return { - size, - limit: TRANSACTION_SIZE_LIMIT, - remaining: TRANSACTION_SIZE_LIMIT - size, - percentUsed: (size / TRANSACTION_SIZE_LIMIT) * 100, - canFitMore: size < TRANSACTION_SIZE_LIMIT, - }; - } - - /** - * Execute transaction with retry logic. - */ - private async executeWithRetry( - sendAndConfirm: ReturnType, - transaction: any, - commitment: 'processed' | 'confirmed' | 'finalized', - rpc: Rpc, - options?: { - skipPreflightOnRetry?: boolean; - preflightCommitment?: 'processed' | 'confirmed' | 'finalized'; + + /** + * Simulate the transaction without sending it. + * Useful for testing and debugging before execution. + * + * Note: Requires feePayer to be set and RPC in config. + */ + async simulate(params?: { commitment?: 'processed' | 'confirmed' | 'finalized' }): Promise { + const commitment = params?.commitment ?? 'confirmed'; + + if (!this.feePayer) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); + } + + if (!this.config.rpc) { + throw new Error('RPC required for simulation. Pass rpc in constructor.'); + } + + // Build message using the unified build method + const message = await (this as any).build(); + + // Sign for simulation + const signedTransaction: any = await signTransactionMessageWithSigners(message); + + // Simulate using Kit's API + const rpcWithSim = this.config.rpc as unknown as Rpc; + const result = await rpcWithSim + .simulateTransaction(signedTransaction, { + commitment, + replaceRecentBlockhash: true, + }) + .send(); + + return { + err: result.value.err, + logs: result.value.logs, + unitsConsumed: result.value.unitsConsumed, + returnData: result.value.returnData, + }; } - ): Promise { - const retryConfig = this.config.autoRetry === true - ? { maxAttempts: 3, backoff: 'exponential' as const } - : this.config.autoRetry; - - if (!retryConfig || typeof retryConfig === 'boolean') { - throw new Error('Invalid retry configuration'); + + /** + * Sign and export the transaction in specified format WITHOUT sending. + * + * Use this when you want to: + * - Send via custom transport or different RPC + * - Store signed transactions for batch sending + * - Use with hardware wallets + * - Generate QR codes for mobile wallets + * - Pass transactions to other systems + * + * @param format - Export format: 'base64' (default), 'base58', or 'bytes' + * @returns Serialized signed transaction + * + * @example + * ```ts + * // Export for custom RPC + * const { data: base64Tx } = await builder.export('base64'); + * await customRpc.sendTransaction(base64Tx, { encoding: 'base64' }); + * + * // Export for hardware wallet + * const { data: bytes } = await builder.export('bytes'); + * await ledger.signTransaction(bytes); + * + * // Export for QR code + * const { data: base58Tx } = await builder.export('base58'); + * displayQRCode(base58Tx); + * ``` + */ + async export(format: ExportFormat = 'base64'): Promise { + if (!this.feePayer) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); + } + + if (!this.config.rpc) { + throw new Error('RPC required for export. Pass rpc in constructor.'); + } + + // Build message using the unified build method + let message = await (this as any).build(); + + // If using simulate strategy, estimate and update the provisory CU instruction + if (this.isSimulateCUStrategy()) { + const rpcWithSim = this.config.rpc as unknown as Rpc; + const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: rpcWithSim }); + const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); + message = await estimateAndSetCULimit(message); + } + + // Sign transaction + const signedTransaction: any = await signTransactionMessageWithSigners(message); + + // Serialize in requested format + switch (format) { + case 'base64': { + const base64 = getBase64EncodedWireTransaction(signedTransaction); + return { format: 'base64', data: base64 }; + } + case 'base58': { + const encoder = getTransactionEncoder(); + const bytes = encoder.encode(signedTransaction); + const base58Decoder = getBase58Decoder(); + const base58 = base58Decoder.decode(new Uint8Array(bytes)); + return { format: 'base58', data: base58 }; + } + case 'bytes': { + const encoder = getTransactionEncoder(); + const bytes = encoder.encode(signedTransaction); + return { format: 'bytes', data: new Uint8Array(bytes) }; + } + } } - - const { maxAttempts, backoff } = retryConfig; - const { skipPreflightOnRetry = true, preflightCommitment = 'confirmed' } = options ?? {}; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - if (this.config.logLevel !== 'silent') { - console.log(`[Pipeit] Transaction attempt ${attempt}/${maxAttempts}`); + + /** + * Execute the transaction with smart defaults. + * + * Supports advanced sending options like skipPreflight and maxRetries, + * as well as execution strategies for Jito bundles and parallel submission. + * + * Note: Requires feePayer to be set and RPC in config. + * + * @example + * ```ts + * // Basic execution + * const sig = await builder.execute({ rpcSubscriptions }); + * + * // With execution strategy preset + * const sig = await builder.execute({ + * rpcSubscriptions, + * execution: 'fast', // Jito + parallel for max speed + * }); + * + * // With custom execution config + * const sig = await builder.execute({ + * rpcSubscriptions, + * execution: { + * jito: { enabled: true, tipLamports: 50_000n }, + * parallel: { enabled: true, endpoints: ['https://my-rpc.com'] }, + * }, + * }); + * + * // With sending options + * const sig = await builder.execute({ + * rpcSubscriptions, + * skipPreflight: false, + * skipPreflightOnRetry: true, + * maxRetries: 5, + * preflightCommitment: 'confirmed', + * }); + * ``` + */ + async execute( + params: { + rpcSubscriptions: RpcSubscriptions; + } & ExecuteConfig, + ): Promise { + const { + rpcSubscriptions, + commitment = 'confirmed', + skipPreflight = false, + skipPreflightOnRetry = true, + preflightCommitment = 'confirmed', + maxRetries, + execution, + } = params; + + if (!this.feePayer) { + throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_MISSING); } - - // Skip preflight on retry attempts if enabled - const shouldSkipPreflight = attempt > 1 && skipPreflightOnRetry; + + if (!this.config.rpc) { + throw new Error('RPC required for execute. Pass rpc in constructor.'); + } + + const rpc = this.config.rpc as unknown as Rpc< + GetEpochInfoApi & GetSignatureStatusesApi & SendTransactionApi & GetLatestBlockhashApi + >; + + // Resolve execution strategy + const executionConfig = resolveExecutionConfig(execution); + + // If Jito is enabled, we need to add the tip instruction before building + // Clone the builder to avoid mutating the original + let builderToUse: TransactionBuilder = this; + + if (executionConfig.jito.enabled && executionConfig.jito.tipLamports > 0n) { + builderToUse = this.clone(); + const tipInstruction = createTipInstruction(this.feePayer, executionConfig.jito.tipLamports); + builderToUse.instructions.push(tipInstruction); + + if (this.config.logLevel !== 'silent') { + console.log(`[Pipeit] Adding Jito tip: ${executionConfig.jito.tipLamports} lamports`); + } + } + + // Build message using the unified build method + let message = await (builderToUse as any).build(); + + // If using simulate strategy, estimate and update the provisory CU instruction + if (builderToUse.isSimulateCUStrategy()) { + const rpcWithSim = this.config.rpc as unknown as Rpc; + const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: rpcWithSim }); + const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateCULimit); + message = await estimateAndSetCULimit(message); + + if (this.config.logLevel !== 'silent') { + console.log(`[Pipeit] Estimated compute units via simulation`); + } + } + + // Sign transaction + const signedTransaction: any = await signTransactionMessageWithSigners(message); + + // Get base64 encoded transaction for execution strategies + const base64Tx = getBase64EncodedWireTransaction(signedTransaction); + + // Check if we should use execution strategies (Jito, parallel, or TPU enabled) + const useExecutionStrategy = + executionConfig.jito.enabled || executionConfig.parallel.enabled || executionConfig.tpu.enabled; + + if (useExecutionStrategy) { + // Use execution strategy + if (this.config.logLevel !== 'silent') { + const strategyName = executionConfig.tpu.enabled + ? 'TPU Direct' + : executionConfig.jito.enabled && executionConfig.parallel.enabled + ? 'Jito + Parallel' + : executionConfig.jito.enabled + ? 'Jito' + : 'Parallel'; + console.log(`[Pipeit] Using ${strategyName} execution strategy`); + } + + // Extract RPC URL from the RPC client + // Note: We need to get the URL somehow - for now, assume it's available + // In practice, users should provide endpoints in parallel config + const rpcUrl = this.getRpcUrl(); + + const result = await executeWithStrategy(base64Tx, executionConfig, { + ...(rpcUrl && { rpcUrl }), + feePayer: this.feePayer, + ...(params.abortSignal && { abortSignal: params.abortSignal }), + }); + + // For TPU with continuous resubmission, confirmation happens server-side + if (result.landedVia === 'tpu') { + if (this.config.logLevel !== 'silent') { + if (result.confirmed) { + console.log( + `[Pipeit] ✅ Transaction CONFIRMED on-chain via TPU!\n` + + ` Rounds: ${result.rounds ?? 'N/A'}, Leaders sent: ${result.leaderCount ?? 'N/A'}\n` + + ` Latency: ${result.latencyMs ?? 'N/A'}ms`, + ); + } else { + console.warn( + `[Pipeit] ⚠️ TPU submission completed but transaction NOT confirmed.\n` + + ` Rounds: ${result.rounds ?? 'N/A'}, Leaders sent: ${result.leaderCount ?? 'N/A'}\n` + + ` Signature: ${result.signature}\n` + + ` The transaction may still land - check explorer.`, + ); + } + } + + // Return signature - for TPU, confirmation already happened server-side + return result.signature; + } + + // For non-TPU strategies (Jito, parallel), use standard confirmation + if (this.config.logLevel !== 'silent') { + console.log( + `[Pipeit] Transaction sent via ${result.landedVia}${result.latencyMs ? ` in ${result.latencyMs}ms` : ''}`, + ); + } + + // Confirm via WebSocket subscription + try { + await this.confirmTransaction(result.signature, rpcSubscriptions, commitment); + if (this.config.logLevel !== 'silent') { + console.log(`[Pipeit] ✅ Transaction confirmed via WebSocket subscription`); + } + } catch (confirmError) { + throw confirmError; + } + + // Verify transaction execution status (catch false positives) + await this.verifyTransactionSuccess(rpc, result.signature); + + return result.signature; + } + + // Standard execution path (no Jito, no parallel) + const sendAndConfirm = sendAndConfirmTransactionFactory({ + rpc, + rpcSubscriptions, + }); + + // Add retry logic if enabled + if (this.config.autoRetry) { + return this.executeWithRetry(sendAndConfirm, signedTransaction, commitment, rpc, { + skipPreflightOnRetry, + preflightCommitment, + }); + } + + // Prepare send options (avoid undefined for exactOptionalPropertyTypes) const sendOptions: Parameters[1] = { - commitment, - ...(shouldSkipPreflight && { skipPreflight: true }), - ...(!shouldSkipPreflight && { preflightCommitment }), + commitment, + ...(skipPreflight && { skipPreflight }), + ...(!skipPreflight && { preflightCommitment }), + ...(maxRetries !== undefined && { maxRetries: BigInt(maxRetries) }), }; - - await sendAndConfirm(transaction, sendOptions); - const signature = getSignatureFromTransaction(transaction); - + + await sendAndConfirm(signedTransaction, sendOptions); + const signature = getSignatureFromTransaction(signedTransaction); + // Verify transaction execution status (catch false positives) await this.verifyTransactionSuccess(rpc, signature); - + return signature; - } catch (error) { - if (attempt === maxAttempts) { - if (this.config.logLevel === 'verbose') { - console.error(`[Pipeit] Transaction failed after ${maxAttempts} attempts:`, error); - const cause = (error as any)?.cause; - if (cause) { - console.error('[Pipeit] Error cause:', cause); - const causeLogs = - (cause as any)?.logs ?? - (cause as any)?.data?.logs ?? - (cause as any)?.simulationResponse?.logs; - if (causeLogs) { - const logs = Array.isArray(causeLogs) ? causeLogs : [String(causeLogs)]; - console.error('[Pipeit] Cause logs:\n' + logs.join('\n')); - } - } - const context = (error as any)?.context ?? (error as any)?.data; - if (context) { - console.error('[Pipeit] Error context:', context); + } + + /** + * Get the RPC URL from the configured RPC client. + * This is a best-effort extraction - may not work for all RPC client types. + */ + private getRpcUrl(): string | undefined { + // The RPC client from @solana/rpc doesn't expose the URL directly + // Users should configure parallel.endpoints if they want parallel submission + // For now, return undefined and let the execution strategy handle it + return undefined; + } + + /** + * Confirm a transaction signature using WebSocket subscriptions. + */ + private async confirmTransaction( + signature: string, + rpcSubscriptions: RpcSubscriptions, + commitment: 'processed' | 'confirmed' | 'finalized', + ): Promise { + // Subscribe to signature notifications + const notifications = await rpcSubscriptions + .signatureNotifications(signature as any, { commitment }) + .subscribe({ abortSignal: AbortSignal.timeout(60_000) }); + + // Wait for confirmation + for await (const notification of notifications) { + if (notification.value.err) { + throw new TransactionExecutionError(signature, notification.value.err); } - } - const maybeLogs = - (error as any)?.logs ?? - (error as any)?.data?.logs ?? - (error as any)?.simulationResponse?.logs; - if (maybeLogs) { - const logs = Array.isArray(maybeLogs) ? maybeLogs : [String(maybeLogs)]; - console.error('[Pipeit] Simulation logs:\n' + logs.join('\n')); - } else if (this.config.logLevel === 'verbose') { - console.error('[Pipeit] Transaction error details (no logs found):', error); - } - throw error; + // Transaction confirmed + return; } - - const delay = backoff === 'exponential' - ? Math.pow(2, attempt - 1) * 1000 - : attempt * 1000; - - if (this.config.logLevel === 'verbose') { - console.log(`[Pipeit] Retrying in ${delay}ms...`); + } + + /** + * Verify that a transaction executed successfully (no program errors). + * This catches false positives where a transaction is confirmed but failed execution. + * + * Uses `searchTransactionHistory: true` to perform a ledger lookup instead of relying + * on the RPC's in-memory cache. Retries with exponential backoff if the status is null + * (not yet available), throwing only after all attempts are exhausted. + */ + private async verifyTransactionSuccess( + rpc: Rpc, + signature: string, + options?: { + /** Maximum number of retry attempts (default: 12) */ + maxAttempts?: number; + /** Initial delay in milliseconds before first retry (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds between retries (default: 8000) */ + maxDelayMs?: number; + }, + ): Promise { + const { + maxAttempts = 12, // Increased from 5 - gives more time for RPC to index + initialDelayMs = 1000, // Increased from 500 - start with longer delay + maxDelayMs = 8000, // Increased from 4000 - longer max delay for slow RPCs + } = options ?? {}; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const { value: statuses } = await rpc + .getSignatureStatuses([signature as any], { + searchTransactionHistory: true, + }) + .send(); + + const status = statuses[0]; + + // If we got a definitive status, check for errors + if (status !== null) { + if (status.err) { + throw new TransactionExecutionError(signature, status.err); + } + // Transaction executed successfully + return; + } + + // Status is null - not yet available in ledger + if (attempt === maxAttempts) { + // Exhausted all attempts without getting a definitive status + throw new Error( + `Unable to verify transaction status after ${maxAttempts} attempts. ` + + `Signature: ${signature}. The transaction may have landed but status could not be confirmed.`, + ); + } + + // Calculate delay with exponential backoff, capped at maxDelayMs + const delay = Math.min(initialDelayMs * Math.pow(2, attempt - 1), maxDelayMs); + + if (this.config.logLevel === 'verbose') { + console.log( + `[Pipeit] Transaction status not yet available, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`, + ); + } + + await new Promise(resolve => setTimeout(resolve, delay)); } - - await new Promise(resolve => setTimeout(resolve, delay)); - } } - - throw new Error('Transaction failed after retries'); - } - - /** - * Clone the builder for immutability. - */ - private clone(): TransactionBuilder { - const builder = new TransactionBuilder({ - version: this.config.version, - ...(this.config.rpc && { rpc: this.config.rpc }), - autoRetry: this.config.autoRetry, - logLevel: this.config.logLevel, - priorityFee: this.config.priorityFee, - computeUnits: this.config.computeUnits, - ...(this.config.lookupTableAddresses && { lookupTableAddresses: this.config.lookupTableAddresses }), - ...(this.config.addressesByLookupTable && { addressesByLookupTable: this.config.addressesByLookupTable }), - }); - if (this.feePayer !== undefined) { - builder.feePayer = this.feePayer; + + /** + * Get current transaction size information. + * Useful before calling build() to check if more instructions can fit. + * + * Note: This builds the message to calculate accurate size. + * Requires feePayer to be set and RPC in config for auto-blockhash. + * + * @example + * ```ts + * const info = await builder.getSizeInfo(); + * console.log(`Using ${info.percentUsed.toFixed(1)}% of transaction space`); + * console.log(`${info.remaining} bytes remaining`); + * ``` + */ + async getSizeInfo(): Promise<{ + size: number; + limit: number; + remaining: number; + percentUsed: number; + canFitMore: boolean; + }> { + // Build message to get accurate size + const message = await (this as any).build(); + const size = getTransactionMessageSize(message); + return { + size, + limit: TRANSACTION_SIZE_LIMIT, + remaining: TRANSACTION_SIZE_LIMIT - size, + percentUsed: (size / TRANSACTION_SIZE_LIMIT) * 100, + canFitMore: size < TRANSACTION_SIZE_LIMIT, + }; } - if (this.feePayerSigner !== undefined) { - builder.feePayerSigner = this.feePayerSigner; + + /** + * Execute transaction with retry logic. + */ + private async executeWithRetry( + sendAndConfirm: ReturnType, + transaction: any, + commitment: 'processed' | 'confirmed' | 'finalized', + rpc: Rpc, + options?: { + skipPreflightOnRetry?: boolean; + preflightCommitment?: 'processed' | 'confirmed' | 'finalized'; + }, + ): Promise { + const retryConfig = + this.config.autoRetry === true + ? { maxAttempts: 3, backoff: 'exponential' as const } + : this.config.autoRetry; + + if (!retryConfig || typeof retryConfig === 'boolean') { + throw new Error('Invalid retry configuration'); + } + + const { maxAttempts, backoff } = retryConfig; + const { skipPreflightOnRetry = true, preflightCommitment = 'confirmed' } = options ?? {}; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (this.config.logLevel !== 'silent') { + console.log(`[Pipeit] Transaction attempt ${attempt}/${maxAttempts}`); + } + + // Skip preflight on retry attempts if enabled + const shouldSkipPreflight = attempt > 1 && skipPreflightOnRetry; + const sendOptions: Parameters[1] = { + commitment, + ...(shouldSkipPreflight && { skipPreflight: true }), + ...(!shouldSkipPreflight && { preflightCommitment }), + }; + + await sendAndConfirm(transaction, sendOptions); + const signature = getSignatureFromTransaction(transaction); + + // Verify transaction execution status (catch false positives) + await this.verifyTransactionSuccess(rpc, signature); + + return signature; + } catch (error) { + if (attempt === maxAttempts) { + if (this.config.logLevel === 'verbose') { + console.error(`[Pipeit] Transaction failed after ${maxAttempts} attempts:`, error); + const cause = (error as any)?.cause; + if (cause) { + console.error('[Pipeit] Error cause:', cause); + const causeLogs = + (cause as any)?.logs ?? + (cause as any)?.data?.logs ?? + (cause as any)?.simulationResponse?.logs; + if (causeLogs) { + const logs = Array.isArray(causeLogs) ? causeLogs : [String(causeLogs)]; + console.error('[Pipeit] Cause logs:\n' + logs.join('\n')); + } + } + const context = (error as any)?.context ?? (error as any)?.data; + if (context) { + console.error('[Pipeit] Error context:', context); + } + } + const maybeLogs = + (error as any)?.logs ?? (error as any)?.data?.logs ?? (error as any)?.simulationResponse?.logs; + if (maybeLogs) { + const logs = Array.isArray(maybeLogs) ? maybeLogs : [String(maybeLogs)]; + console.error('[Pipeit] Simulation logs:\n' + logs.join('\n')); + } else if (this.config.logLevel === 'verbose') { + console.error('[Pipeit] Transaction error details (no logs found):', error); + } + throw error; + } + + const delay = backoff === 'exponential' ? Math.pow(2, attempt - 1) * 1000 : attempt * 1000; + + if (this.config.logLevel === 'verbose') { + console.log(`[Pipeit] Retrying in ${delay}ms...`); + } + + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw new Error('Transaction failed after retries'); } - if (this.lifetime !== undefined) { - builder.lifetime = this.lifetime; + + /** + * Clone the builder for immutability. + */ + private clone(): TransactionBuilder { + const builder = new TransactionBuilder({ + version: this.config.version, + ...(this.config.rpc && { rpc: this.config.rpc }), + autoRetry: this.config.autoRetry, + logLevel: this.config.logLevel, + priorityFee: this.config.priorityFee, + computeUnits: this.config.computeUnits, + ...(this.config.lookupTableAddresses && { lookupTableAddresses: this.config.lookupTableAddresses }), + ...(this.config.addressesByLookupTable && { addressesByLookupTable: this.config.addressesByLookupTable }), + }); + if (this.feePayer !== undefined) { + builder.feePayer = this.feePayer; + } + if (this.feePayerSigner !== undefined) { + builder.feePayerSigner = this.feePayerSigner; + } + if (this.lifetime !== undefined) { + builder.lifetime = this.lifetime; + } + builder.instructions = [...this.instructions]; + return builder; } - builder.instructions = [...this.instructions]; - return builder; - } } - diff --git a/packages/core/src/compute-budget/__tests__/compute-units.test.ts b/packages/core/src/compute-budget/__tests__/compute-units.test.ts index 67b94bd..f95e28c 100644 --- a/packages/core/src/compute-budget/__tests__/compute-units.test.ts +++ b/packages/core/src/compute-budget/__tests__/compute-units.test.ts @@ -137,10 +137,7 @@ describe('getComputeUnitLimit', () => { }); it('should include simulated units with buffer', () => { - const limit = getComputeUnitLimit( - { strategy: 'simulate', buffer: 1.1 }, - 200_000n, - ); + const limit = getComputeUnitLimit({ strategy: 'simulate', buffer: 1.1 }, 200_000n); expect(limit).toBe(Math.ceil(200_000 * 1.1)); }); }); diff --git a/packages/core/src/errors/tpu-errors.ts b/packages/core/src/errors/tpu-errors.ts index 65efbba..307c218 100644 --- a/packages/core/src/errors/tpu-errors.ts +++ b/packages/core/src/errors/tpu-errors.ts @@ -24,12 +24,7 @@ export type TpuErrorCode = /** * Error codes that are safe to retry. */ -export const TPU_RETRYABLE_ERRORS: TpuErrorCode[] = [ - 'CONNECTION_FAILED', - 'STREAM_CLOSED', - 'RATE_LIMITED', - 'TIMEOUT', -]; +export const TPU_RETRYABLE_ERRORS: TpuErrorCode[] = ['CONNECTION_FAILED', 'STREAM_CLOSED', 'RATE_LIMITED', 'TIMEOUT']; /** * Error thrown when TPU submission fails. @@ -73,17 +68,9 @@ export class TpuSubmissionError extends Error { /** * Creates a TpuSubmissionError from a raw error code string. */ - static fromCode( - code: string, - message?: string, - validatorIdentity?: string, - ): TpuSubmissionError { + static fromCode(code: string, message?: string, validatorIdentity?: string): TpuSubmissionError { const errorCode = code.toUpperCase() as TpuErrorCode; - return new TpuSubmissionError( - errorCode, - message || `TPU submission failed: ${code}`, - validatorIdentity, - ); + return new TpuSubmissionError(errorCode, message || `TPU submission failed: ${code}`, validatorIdentity); } } diff --git a/packages/core/src/execution/__tests__/jito.test.ts b/packages/core/src/execution/__tests__/jito.test.ts index eeb5a83..8c023db 100644 --- a/packages/core/src/execution/__tests__/jito.test.ts +++ b/packages/core/src/execution/__tests__/jito.test.ts @@ -294,6 +294,3 @@ describe('JitoBundleError', () => { expect(error.bundleId).toBe('bundle-123'); }); }); - - - diff --git a/packages/core/src/execution/__tests__/parallel.test.ts b/packages/core/src/execution/__tests__/parallel.test.ts index 42f9354..3f2d614 100644 --- a/packages/core/src/execution/__tests__/parallel.test.ts +++ b/packages/core/src/execution/__tests__/parallel.test.ts @@ -265,6 +265,3 @@ describe('ParallelSubmitError', () => { expect(error.errors[1].endpoint).toBe('https://rpc2.example.com'); }); }); - - - diff --git a/packages/core/src/execution/__tests__/strategies.test.ts b/packages/core/src/execution/__tests__/strategies.test.ts index b97a7cd..cb0fba2 100644 --- a/packages/core/src/execution/__tests__/strategies.test.ts +++ b/packages/core/src/execution/__tests__/strategies.test.ts @@ -221,6 +221,3 @@ describe('utility functions', () => { }); }); }); - - - diff --git a/packages/core/src/execution/index.ts b/packages/core/src/execution/index.ts index 8ad46fe..db0bd3e 100644 --- a/packages/core/src/execution/index.ts +++ b/packages/core/src/execution/index.ts @@ -53,6 +53,3 @@ export { getTipAmount, ExecutionStrategyError, } from './strategies.js'; - - - diff --git a/packages/core/src/execution/jito.ts b/packages/core/src/execution/jito.ts index 94b25bb..8786cdb 100644 --- a/packages/core/src/execution/jito.ts +++ b/packages/core/src/execution/jito.ts @@ -376,6 +376,3 @@ export async function sendTransactionViaJito(transaction: string, options: SendB // Single transaction bundles are valid in Jito return sendBundle([transaction], options); } - - - diff --git a/packages/core/src/execution/parallel.ts b/packages/core/src/execution/parallel.ts index 202c43f..6075e4d 100644 --- a/packages/core/src/execution/parallel.ts +++ b/packages/core/src/execution/parallel.ts @@ -334,6 +334,3 @@ export async function submitToRpc( return data.result; } - - - diff --git a/packages/core/src/execution/strategies.ts b/packages/core/src/execution/strategies.ts index 7eed99b..de1b138 100644 --- a/packages/core/src/execution/strategies.ts +++ b/packages/core/src/execution/strategies.ts @@ -516,7 +516,7 @@ interface TpuSubmissionResult { latencyMs: number; /** Error message if any */ error?: string; - + // Backwards compatibility fields /** @deprecated Use confirmed instead */ delivered?: boolean; @@ -592,11 +592,11 @@ async function submitToTpu( totalLeadersSent: result.totalLeadersSent, latencyMs: result.latencyMs, }; - + if (result.error) { response.error = result.error; } - + return response; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') { @@ -617,7 +617,10 @@ let tpuClientConfig: ResolvedExecutionConfig['tpu'] | null = null; */ async function getTpuClientSingleton(config: ResolvedExecutionConfig['tpu']): Promise<{ sendTransaction: (tx: Buffer) => Promise<{ delivered: boolean; latencyMs: number; leaderCount: number }>; - sendUntilConfirmed: (tx: Buffer, timeoutMs?: number) => Promise<{ + sendUntilConfirmed: ( + tx: Buffer, + timeoutMs?: number, + ) => Promise<{ confirmed: boolean; signature: string; rounds: number; @@ -636,7 +639,10 @@ async function getTpuClientSingleton(config: ResolvedExecutionConfig['tpu']): Pr ) { return tpuClientInstance as { sendTransaction: (tx: Buffer) => Promise<{ delivered: boolean; latencyMs: number; leaderCount: number }>; - sendUntilConfirmed: (tx: Buffer, timeoutMs?: number) => Promise<{ + sendUntilConfirmed: ( + tx: Buffer, + timeoutMs?: number, + ) => Promise<{ confirmed: boolean; signature: string; rounds: number; @@ -698,7 +704,7 @@ async function executeTpuStrategy( // Check if confirmed (new behavior) or delivered (backwards compat) const isConfirmed = result.confirmed ?? result.delivered ?? false; - + if (!isConfirmed && result.error) { throw new ExecutionStrategyError(`TPU submission failed: ${result.error}`); } diff --git a/packages/core/src/lookup-tables/compress.ts b/packages/core/src/lookup-tables/compress.ts index 9f58baf..b36d949 100644 --- a/packages/core/src/lookup-tables/compress.ts +++ b/packages/core/src/lookup-tables/compress.ts @@ -4,40 +4,13 @@ * @packageDocumentation */ -import type { Address } from '@solana/addresses'; -import type { AccountLookupMeta, AccountMeta, AccountRole, Instruction } from '@solana/instructions'; -import type { TransactionMessage } from '@solana/transaction-messages'; +import { isSignerRole } from '@solana/instructions'; +import { + compressTransactionMessageUsingAddressLookupTables, + type TransactionMessage, +} from '@solana/transaction-messages'; import type { AddressesByLookupTableAddress } from './types.js'; -/** - * Check if an account role is a signer role. - */ -function isSignerRole(role: AccountRole): boolean { - return role === 0b0011 || role === 0b0010; // READONLY_SIGNER or WRITABLE_SIGNER -} - -/** - * Find an address in lookup tables and return a lookup meta if found. - */ -function findAddressInLookupTables( - address: Address, - role: AccountRole, - addressesByLookupTableAddress: AddressesByLookupTableAddress, -): AccountLookupMeta | undefined { - for (const [lookupTableAddress, addresses] of Object.entries(addressesByLookupTableAddress)) { - const index = addresses.indexOf(address); - if (index !== -1) { - return { - address, - addressIndex: index, - lookupTableAddress: lookupTableAddress as Address, - role: role as 0b0000 | 0b0001, // READONLY or WRITABLE (non-signer) - }; - } - } - return undefined; -} - /** * Compress a transaction message using address lookup tables. * @@ -62,73 +35,15 @@ export function compressTransactionMessage( transactionMessage: TMessage, addressesByLookupTableAddress: AddressesByLookupTableAddress, ): TMessage { - // Only works with versioned transactions (version 0) - if (transactionMessage.version === 'legacy') { - return transactionMessage; - } - - // Build set of program addresses (cannot be in lookup tables) - const programAddresses = new Set(transactionMessage.instructions.map(ix => ix.programAddress)); - - // Build set of eligible lookup addresses - const eligibleLookupAddresses = new Set( - Object.values(addressesByLookupTableAddress) - .flat() - .filter(addr => !programAddresses.has(addr)), - ); - - if (eligibleLookupAddresses.size === 0) { - return transactionMessage; - } - - const newInstructions: Instruction[] = []; - let updatedAnyInstructions = false; - - for (const instruction of transactionMessage.instructions) { - if (!instruction.accounts || instruction.accounts.length === 0) { - newInstructions.push(instruction); - continue; - } - - const newAccounts: (AccountMeta | AccountLookupMeta)[] = []; - let updatedAnyAccounts = false; - - for (const account of instruction.accounts) { - // Skip if already a lookup, not in any lookup table, or is a signer - if ( - 'lookupTableAddress' in account || - !eligibleLookupAddresses.has(account.address) || - isSignerRole(account.role) - ) { - newAccounts.push(account); - continue; - } - - // Try to find in lookup tables - const lookupMeta = findAddressInLookupTables(account.address, account.role, addressesByLookupTableAddress); - - if (lookupMeta) { - newAccounts.push(Object.freeze(lookupMeta)); - updatedAnyAccounts = true; - updatedAnyInstructions = true; - } else { - newAccounts.push(account); - } - } - - newInstructions.push( - Object.freeze(updatedAnyAccounts ? { ...instruction, accounts: Object.freeze(newAccounts) } : instruction), - ); - } - - if (!updatedAnyInstructions) { - return transactionMessage; - } - - return Object.freeze({ - ...transactionMessage, - instructions: Object.freeze(newInstructions), - }) as TMessage; + // Address lookup tables only apply to v0 (versioned) transactions. + if (transactionMessage.version === 'legacy') return transactionMessage; + + // Delegate to Kit's implementation (re-exported from `@solana/kit`). + // We keep this wrapper to preserve Pipeit's legacy-safe signature. + return compressTransactionMessageUsingAddressLookupTables( + transactionMessage as Exclude, + addressesByLookupTableAddress, + ) as TMessage; } /** diff --git a/packages/core/src/plans/__typetests__/execute-plan-typetest.ts b/packages/core/src/plans/__typetests__/execute-plan-typetest.ts index 5391b64..2967b0b 100644 --- a/packages/core/src/plans/__typetests__/execute-plan-typetest.ts +++ b/packages/core/src/plans/__typetests__/execute-plan-typetest.ts @@ -9,7 +9,7 @@ import type { Address } from '@solana/addresses'; import type { Rpc, GetLatestBlockhashApi, - GetAccountInfoApi, + GetMultipleAccountsApi, GetEpochInfoApi, GetSignatureStatusesApi, SendTransactionApi, @@ -28,10 +28,10 @@ type BaseRpcApi = GetEpochInfoApi & GetLatestBlockhashApi & SimulateTransactionApi; -type RpcApiWithAccountInfo = BaseRpcApi & GetAccountInfoApi; +type RpcApiWithLookupFetch = BaseRpcApi & GetMultipleAccountsApi; const baseRpc = null as unknown as Rpc; -const rpcWithAccountInfo = null as unknown as Rpc; +const rpcWithLookupFetch = null as unknown as Rpc; const rpcSubscriptions = null as unknown as RpcSubscriptions; const signer = null as unknown as TransactionSigner; const altAddress = null as unknown as Address; @@ -74,10 +74,10 @@ const addressesByLookupTable = null as unknown as AddressesByLookupTableAddress; // [DESCRIBE] ExecutePlanConfig with lookupTableAddresses { - // It requires RPC with GetAccountInfoApi when lookupTableAddresses is provided + // It requires RPC with GetMultipleAccountsApi when lookupTableAddresses is provided { const config: ExecutePlanConfig = { - rpc: rpcWithAccountInfo, + rpc: rpcWithLookupFetch, rpcSubscriptions, signer, lookupTableAddresses: [altAddress], @@ -85,7 +85,7 @@ const addressesByLookupTable = null as unknown as AddressesByLookupTableAddress; config satisfies ExecutePlanConfig; } - // @ts-expect-error It rejects base RPC (without GetAccountInfoApi) when lookupTableAddresses is provided + // @ts-expect-error It rejects base RPC (without GetMultipleAccountsApi) when lookupTableAddresses is provided const _invalidConfig: ExecutePlanConfig = { rpc: baseRpc, rpcSubscriptions, @@ -124,7 +124,7 @@ const addressesByLookupTable = null as unknown as AddressesByLookupTableAddress; { // @ts-expect-error It rejects config with both lookupTableAddresses and addressesByLookupTable const _invalidConfig: ExecutePlanConfig = { - rpc: rpcWithAccountInfo, + rpc: rpcWithLookupFetch, rpcSubscriptions, signer, lookupTableAddresses: [altAddress], diff --git a/packages/core/src/plans/execute-plan.ts b/packages/core/src/plans/execute-plan.ts index ca30dbf..3103650 100644 --- a/packages/core/src/plans/execute-plan.ts +++ b/packages/core/src/plans/execute-plan.ts @@ -8,7 +8,7 @@ import type { Address } from '@solana/addresses'; import type { Rpc, GetLatestBlockhashApi, - GetAccountInfoApi, + GetMultipleAccountsApi, GetEpochInfoApi, GetSignatureStatusesApi, SendTransactionApi, @@ -28,32 +28,30 @@ import { setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, sendAndConfirmTransactionFactory, + fetchAddressesForLookupTables, } from '@solana/kit'; -import type { - BaseTransactionMessage, - TransactionMessageWithFeePayer, -} from '@solana/transaction-messages'; +import type { BaseTransactionMessage, TransactionMessageWithFeePayer } from '@solana/transaction-messages'; import { addSignersToTransactionMessage, type TransactionSigner } from '@solana/signers'; import { fillProvisorySetComputeUnitLimitInstruction, estimateComputeUnitLimitFactory, estimateAndUpdateProvisoryComputeUnitLimitFactory, } from '@solana-program/compute-budget'; -import { - type AddressesByLookupTableAddress, - fetchAddressLookupTables, - compressTransactionMessage, -} from '../lookup-tables/index.js'; +import { type AddressesByLookupTableAddress, compressTransactionMessage } from '../lookup-tables/index.js'; /** * Base RPC API required for executing instruction plans. */ -type BaseRpcApi = GetEpochInfoApi & GetSignatureStatusesApi & SendTransactionApi & GetLatestBlockhashApi & SimulateTransactionApi; +type BaseRpcApi = GetEpochInfoApi & + GetSignatureStatusesApi & + SendTransactionApi & + GetLatestBlockhashApi & + SimulateTransactionApi; /** - * RPC API required when fetching lookup tables (includes GetAccountInfoApi). + * RPC API required when fetching lookup tables (includes GetMultipleAccountsApi). */ -type RpcApiWithAccountInfo = BaseRpcApi & GetAccountInfoApi; +type RpcApiWithLookupFetch = BaseRpcApi & GetMultipleAccountsApi; /** * Base configuration for executing an instruction plan (no ALT support). @@ -102,13 +100,13 @@ interface ExecutePlanConfigNoAlt extends ExecutePlanConfigBase { /** * Configuration with lookup table addresses to fetch. - * Requires RPC client with GetAccountInfoApi. + * Requires RPC client with GetMultipleAccountsApi. */ interface ExecutePlanConfigWithLookupAddresses extends ExecutePlanConfigBase { /** - * RPC client with GetAccountInfoApi for fetching lookup tables. + * RPC client with GetMultipleAccountsApi for fetching lookup tables. */ - rpc: Rpc; + rpc: Rpc; /** * Address lookup table addresses to fetch and use for transaction compression. @@ -148,7 +146,7 @@ interface ExecutePlanConfigWithLookupData extends ExecutePlanConfigBase { * Configuration for executing an instruction plan. * * Supports optional address lookup table (ALT) compression: - * - Provide `lookupTableAddresses` to fetch and use ALTs (requires `GetAccountInfoApi` on RPC) + * - Provide `lookupTableAddresses` to fetch and use ALTs (requires `GetMultipleAccountsApi` on RPC) * - Provide `addressesByLookupTable` with pre-fetched data (no additional RPC requirements) * - Omit both for original behavior without ALT compression */ @@ -254,9 +252,7 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf const executor = createTransactionPlanExecutor({ executeTransactionMessage: async message => { // Apply ALT compression before CU estimation (if lookup tables provided) - const compressedMessage = lookupTableData - ? compressTransactionMessage(message, lookupTableData) - : message; + const compressedMessage = lookupTableData ? compressTransactionMessage(message, lookupTableData) : message; // Ensure signer is attached for CU simulation (and any later signing) const messageWithSigners = addSignersToTransactionMessage([signer], compressedMessage); @@ -288,9 +284,7 @@ export async function executePlan(plan: InstructionPlan, config: ExecutePlanConf * - If `lookupTableAddresses` is provided, fetch the tables. * - Otherwise, return undefined (no ALT compression). */ -async function resolveLookupTableData( - config: ExecutePlanConfig, -): Promise { +async function resolveLookupTableData(config: ExecutePlanConfig): Promise { // Use pre-fetched data if provided if (config.addressesByLookupTable) { return config.addressesByLookupTable; @@ -298,13 +292,11 @@ async function resolveLookupTableData( // Fetch tables if addresses provided if (config.lookupTableAddresses && config.lookupTableAddresses.length > 0) { - // TypeScript knows rpc has GetAccountInfoApi when lookupTableAddresses is provided - const rpcWithAccountInfo = config.rpc as Rpc; - return fetchAddressLookupTables( - rpcWithAccountInfo, - config.lookupTableAddresses, - config.commitment ?? 'confirmed', - ); + // TypeScript knows rpc has GetMultipleAccountsApi when lookupTableAddresses is provided + const rpcWithLookupFetch = config.rpc as Rpc; + return fetchAddressesForLookupTables(config.lookupTableAddresses, rpcWithLookupFetch, { + commitment: config.commitment ?? 'confirmed', + }); } // No ALT compression diff --git a/packages/core/src/server/tpu-handler.ts b/packages/core/src/server/tpu-handler.ts index ac4d017..fc506b1 100644 --- a/packages/core/src/server/tpu-handler.ts +++ b/packages/core/src/server/tpu-handler.ts @@ -98,7 +98,10 @@ interface TpuClientInstance { }>; retryCount: number; }>; - sendUntilConfirmed: (tx: Buffer, timeoutMs?: number) => Promise<{ + sendUntilConfirmed: ( + tx: Buffer, + timeoutMs?: number, + ) => Promise<{ confirmed: boolean; signature: string; rounds: number; @@ -202,21 +205,17 @@ async function getTpuClient(config: { rpcUrl: string; wsUrl: string; fanout: num /** * Wait for the TPU client to have enough known validators. - * + * * The client may be "ready" (slot listener started) but not have * leader sockets populated yet. This function waits until enough * validators are known or times out. - * + * * @param client - TPU client instance * @param minValidators - Minimum number of validators required (default: 10) * @param timeoutMs - Maximum time to wait in milliseconds (default: 10000) * @returns True if enough validators are available, false if timed out */ -async function waitForValidators( - client: TpuClientInstance, - minValidators = 10, - timeoutMs = 10000 -): Promise { +async function waitForValidators(client: TpuClientInstance, minValidators = 10, timeoutMs = 10000): Promise { const startTime = Date.now(); const pollInterval = 200; let lastValidatorCount = 0; @@ -227,7 +226,7 @@ async function waitForValidators( if (stats.knownValidators !== lastValidatorCount) { lastValidatorCount = stats.knownValidators; } - + // Need enough validators for good landing rate if (stats.knownValidators >= minValidators && stats.readyState === 'ready') { return true; @@ -364,7 +363,7 @@ export async function tpuHandler( delivered: result.confirmed, leaderCount: result.totalLeadersSent, }; - + if (result.error) { response.error = result.error; } diff --git a/packages/fastlane/index.d.ts b/packages/fastlane/index.d.ts index 1241e81..f81e514 100644 --- a/packages/fastlane/index.d.ts +++ b/packages/fastlane/index.d.ts @@ -5,81 +5,81 @@ /** Configuration for the TPU client. */ export interface TpuClientConfig { - /** RPC URL for fetching leader schedule and cluster info. */ - rpcUrl: string - /** WebSocket URL for slot update subscriptions. */ - wsUrl: string - /** - * Optional gRPC URL for Yellowstone slot subscriptions. - * When set, this takes precedence over WebSocket tracking. - */ - grpcUrl?: string - /** Optional gRPC x-token for authenticated Yellowstone endpoints. */ - grpcXToken?: string - /** Number of upcoming leaders to send transactions to (default: 2). */ - fanout?: number - /** Whether to pre-warm connections to upcoming leaders (default: true). */ - prewarmConnections?: boolean + /** RPC URL for fetching leader schedule and cluster info. */ + rpcUrl: string; + /** WebSocket URL for slot update subscriptions. */ + wsUrl: string; + /** + * Optional gRPC URL for Yellowstone slot subscriptions. + * When set, this takes precedence over WebSocket tracking. + */ + grpcUrl?: string; + /** Optional gRPC x-token for authenticated Yellowstone endpoints. */ + grpcXToken?: string; + /** Number of upcoming leaders to send transactions to (default: 2). */ + fanout?: number; + /** Whether to pre-warm connections to upcoming leaders (default: true). */ + prewarmConnections?: boolean; } /** Result for a single leader send attempt. */ export interface LeaderSendResult { - /** Validator identity pubkey. */ - identity: string - /** TPU socket address. */ - address: string - /** Whether send succeeded. */ - success: boolean - /** Latency for this leader in milliseconds. */ - latencyMs: number - /** Error message if failed. */ - error?: string - /** Error code for programmatic handling. */ - errorCode?: string - /** Number of attempts made for this leader. */ - attempts: number + /** Validator identity pubkey. */ + identity: string; + /** TPU socket address. */ + address: string; + /** Whether send succeeded. */ + success: boolean; + /** Latency for this leader in milliseconds. */ + latencyMs: number; + /** Error message if failed. */ + error?: string; + /** Error code for programmatic handling. */ + errorCode?: string; + /** Number of attempts made for this leader. */ + attempts: number; } /** Result from sending a transaction. */ export interface SendResult { - /** Whether the transaction was successfully delivered. */ - delivered: boolean - /** Total latency in milliseconds. */ - latencyMs: number - /** Number of leaders the transaction was sent to. */ - leaderCount: number - /** Per-leader breakdown of send results. */ - leaders: Array - /** Total retry attempts made across all leaders. */ - retryCount: number + /** Whether the transaction was successfully delivered. */ + delivered: boolean; + /** Total latency in milliseconds. */ + latencyMs: number; + /** Number of leaders the transaction was sent to. */ + leaderCount: number; + /** Per-leader breakdown of send results. */ + leaders: Array; + /** Total retry attempts made across all leaders. */ + retryCount: number; } /** Client health and statistics. */ export interface TpuClientStats { - /** Number of active QUIC connections. */ - connectionCount: number - /** Current estimated slot. */ - currentSlot: number - /** Number of QUIC endpoints. */ - endpointCount: number - /** Client ready state: "initializing", "ready", or "error". */ - readyState: string - /** Seconds since client was created. */ - uptimeSecs: number - /** Number of validators with known sockets. */ - knownValidators: number + /** Number of active QUIC connections. */ + connectionCount: number; + /** Current estimated slot. */ + currentSlot: number; + /** Number of QUIC endpoints. */ + endpointCount: number; + /** Client ready state: "initializing", "ready", or "error". */ + readyState: string; + /** Seconds since client was created. */ + uptimeSecs: number; + /** Number of validators with known sockets. */ + knownValidators: number; } /** Result from continuous send until confirmed. */ export interface SendUntilConfirmedResult { - /** Whether the transaction was confirmed on-chain. */ - confirmed: boolean - /** Transaction signature (base58). */ - signature: string - /** Number of send rounds attempted. */ - rounds: number - /** Total number of leader sends across all rounds. */ - totalLeadersSent: number - /** Total latency in milliseconds. */ - latencyMs: number - /** Error message if failed. */ - error?: string + /** Whether the transaction was confirmed on-chain. */ + confirmed: boolean; + /** Transaction signature (base58). */ + signature: string; + /** Number of send rounds attempted. */ + rounds: number; + /** Total number of leader sends across all rounds. */ + totalLeadersSent: number; + /** Total latency in milliseconds. */ + latencyMs: number; + /** Error message if failed. */ + error?: string; } /** * Native QUIC client for direct Solana TPU transaction submission. @@ -87,41 +87,41 @@ export interface SendUntilConfirmedResult { * Supports continuous resubmission until confirmed for high landing rates. */ export declare class TpuClient { - /** Creates a new TPU client instance. */ - constructor(config: TpuClientConfig) - /** - * Sends a serialized transaction to TPU endpoints (single attempt). - * - * Uses slot-aware leader selection when available, falling back to fanout. - * Returns detailed per-leader results including retry statistics. - * For higher landing rates, use `send_until_confirmed` instead. - */ - sendTransaction(transaction: Buffer): Promise - /** - * Sends a transaction continuously until confirmed or timeout. - * - * Uses slot-aware leader selection to minimize tx leakage: - * - Slots 0-2 of leader window: sends to current leader only - * - Slot 3 of leader window: sends to current + next leader (hedge) - * - * Falls back to fixed fanout if slot estimation is unreliable. - * - * # Arguments - * * `transaction` - Serialized signed transaction - * * `timeout_ms` - Maximum time to wait for confirmation (default: 30000ms) - * - * # Returns - * Result indicating whether the transaction was confirmed on-chain. - */ - sendUntilConfirmed(transaction: Buffer, timeoutMs?: number | undefined | null): Promise - /** Gets the current estimated slot number. */ - getCurrentSlot(): number - /** Gets the number of active QUIC connections. */ - getConnectionCount(): Promise - /** Gets comprehensive client statistics. */ - getStats(): Promise - /** Waits for the client to be fully initialized. */ - waitReady(): Promise - /** Shuts down the client and closes all connections. */ - shutdown(): void + /** Creates a new TPU client instance. */ + constructor(config: TpuClientConfig); + /** + * Sends a serialized transaction to TPU endpoints (single attempt). + * + * Uses slot-aware leader selection when available, falling back to fanout. + * Returns detailed per-leader results including retry statistics. + * For higher landing rates, use `send_until_confirmed` instead. + */ + sendTransaction(transaction: Buffer): Promise; + /** + * Sends a transaction continuously until confirmed or timeout. + * + * Uses slot-aware leader selection to minimize tx leakage: + * - Slots 0-2 of leader window: sends to current leader only + * - Slot 3 of leader window: sends to current + next leader (hedge) + * + * Falls back to fixed fanout if slot estimation is unreliable. + * + * # Arguments + * * `transaction` - Serialized signed transaction + * * `timeout_ms` - Maximum time to wait for confirmation (default: 30000ms) + * + * # Returns + * Result indicating whether the transaction was confirmed on-chain. + */ + sendUntilConfirmed(transaction: Buffer, timeoutMs?: number | undefined | null): Promise; + /** Gets the current estimated slot number. */ + getCurrentSlot(): number; + /** Gets the number of active QUIC connections. */ + getConnectionCount(): Promise; + /** Gets comprehensive client statistics. */ + getStats(): Promise; + /** Waits for the client to be fully initialized. */ + waitReady(): Promise; + /** Shuts down the client and closes all connections. */ + shutdown(): void; } diff --git a/packages/fastlane/index.js b/packages/fastlane/index.js index bcdd740..e537c19 100644 --- a/packages/fastlane/index.js +++ b/packages/fastlane/index.js @@ -5,311 +5,285 @@ /* auto-generated by NAPI-RS */ const { existsSync, readFileSync } = require('fs') -const { join } = require('path') +const { join } = require('path'); -const { platform, arch } = process +const { platform, arch } = process; -let nativeBinding = null -let localFileExisted = false -let loadError = null +let nativeBinding = null; +let localFileExisted = false; +let loadError = null; function isMusl() { - // For Node 10 - if (!process.report || typeof process.report.getReport !== 'function') { - try { - const lddPath = require('child_process').execSync('which ldd').toString().trim() - return readFileSync(lddPath, 'utf8').includes('musl') - } catch (e) { - return true - } - } else { - const { glibcVersionRuntime } = process.report.getReport().header - return !glibcVersionRuntime - } -} - -switch (platform) { - case 'android': - switch (arch) { - case 'arm64': - localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.android-arm64.node') - } else { - nativeBinding = require('@pipeit/fastlane-android-arm64') - } - } catch (e) { - loadError = e - } - break - case 'arm': - localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm-eabi.node')) + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { try { - if (localFileExisted) { - nativeBinding = require('./fastlane.android-arm-eabi.node') - } else { - nativeBinding = require('@pipeit/fastlane-android-arm-eabi') - } + const lddPath = require('child_process').execSync('which ldd').toString().trim(); + return readFileSync(lddPath, 'utf8').includes('musl'); } catch (e) { - loadError = e + return true; } - break - default: - throw new Error(`Unsupported architecture on Android ${arch}`) + } else { + const { glibcVersionRuntime } = process.report.getReport().header; + return !glibcVersionRuntime; } - break - case 'win32': - switch (arch) { - case 'x64': - localFileExisted = existsSync( - join(__dirname, 'fastlane.win32-x64-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.win32-x64-msvc.node') - } else { - nativeBinding = require('@pipeit/fastlane-win32-x64-msvc') - } - } catch (e) { - loadError = e - } - break - case 'ia32': - localFileExisted = existsSync( - join(__dirname, 'fastlane.win32-ia32-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.win32-ia32-msvc.node') - } else { - nativeBinding = require('@pipeit/fastlane-win32-ia32-msvc') - } - } catch (e) { - loadError = e - } - break - case 'arm64': - localFileExisted = existsSync( - join(__dirname, 'fastlane.win32-arm64-msvc.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.win32-arm64-msvc.node') - } else { - nativeBinding = require('@pipeit/fastlane-win32-arm64-msvc') - } - } catch (e) { - loadError = e +} + +switch (platform) { + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm64.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.android-arm64.node'); + } else { + nativeBinding = require('@pipeit/fastlane-android-arm64'); + } + } catch (e) { + loadError = e; + } + break; + case 'arm': + localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm-eabi.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.android-arm-eabi.node'); + } else { + nativeBinding = require('@pipeit/fastlane-android-arm-eabi'); + } + } catch (e) { + loadError = e; + } + break; + default: + throw new Error(`Unsupported architecture on Android ${arch}`); } - break - default: - throw new Error(`Unsupported architecture on Windows: ${arch}`) - } - break - case 'darwin': - localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-universal.node')) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.darwin-universal.node') - } else { - nativeBinding = require('@pipeit/fastlane-darwin-universal') - } - break - } catch {} - switch (arch) { - case 'x64': - localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.darwin-x64.node') - } else { - nativeBinding = require('@pipeit/fastlane-darwin-x64') - } - } catch (e) { - loadError = e + break; + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'fastlane.win32-x64-msvc.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.win32-x64-msvc.node'); + } else { + nativeBinding = require('@pipeit/fastlane-win32-x64-msvc'); + } + } catch (e) { + loadError = e; + } + break; + case 'ia32': + localFileExisted = existsSync(join(__dirname, 'fastlane.win32-ia32-msvc.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.win32-ia32-msvc.node'); + } else { + nativeBinding = require('@pipeit/fastlane-win32-ia32-msvc'); + } + } catch (e) { + loadError = e; + } + break; + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'fastlane.win32-arm64-msvc.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.win32-arm64-msvc.node'); + } else { + nativeBinding = require('@pipeit/fastlane-win32-arm64-msvc'); + } + } catch (e) { + loadError = e; + } + break; + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`); } - break - case 'arm64': - localFileExisted = existsSync( - join(__dirname, 'fastlane.darwin-arm64.node') - ) + break; + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-universal.node')); try { - if (localFileExisted) { - nativeBinding = require('./fastlane.darwin-arm64.node') - } else { - nativeBinding = require('@pipeit/fastlane-darwin-arm64') - } - } catch (e) { - loadError = e - } - break - default: - throw new Error(`Unsupported architecture on macOS: ${arch}`) - } - break - case 'freebsd': - if (arch !== 'x64') { - throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) - } - localFileExisted = existsSync(join(__dirname, 'fastlane.freebsd-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.freebsd-x64.node') - } else { - nativeBinding = require('@pipeit/fastlane-freebsd-x64') - } - } catch (e) { - loadError = e - } - break - case 'linux': - switch (arch) { - case 'x64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-x64-musl.node') - ) - try { if (localFileExisted) { - nativeBinding = require('./fastlane.linux-x64-musl.node') + nativeBinding = require('./fastlane.darwin-universal.node'); } else { - nativeBinding = require('@pipeit/fastlane-linux-x64-musl') + nativeBinding = require('@pipeit/fastlane-darwin-universal'); } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-x64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-x64-gnu.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-x64-gnu') - } - } catch (e) { - loadError = e - } + break; + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-x64.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.darwin-x64.node'); + } else { + nativeBinding = require('@pipeit/fastlane-darwin-x64'); + } + } catch (e) { + loadError = e; + } + break; + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-arm64.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.darwin-arm64.node'); + } else { + nativeBinding = require('@pipeit/fastlane-darwin-arm64'); + } + } catch (e) { + loadError = e; + } + break; + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`); } - break - case 'arm64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-arm64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm64-musl.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-arm64-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm64-gnu.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm64-gnu') - } - } catch (e) { - loadError = e - } + break; + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`); } - break - case 'arm': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-arm-musleabihf.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm-musleabihf.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm-musleabihf') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-arm-gnueabihf.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm-gnueabihf.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm-gnueabihf') - } - } catch (e) { - loadError = e - } - } - break - case 'riscv64': - if (isMusl()) { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-riscv64-musl.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-riscv64-musl.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-riscv64-musl') - } - } catch (e) { - loadError = e - } - } else { - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-riscv64-gnu.node') - ) - try { + localFileExisted = existsSync(join(__dirname, 'fastlane.freebsd-x64.node')); + try { if (localFileExisted) { - nativeBinding = require('./fastlane.linux-riscv64-gnu.node') + nativeBinding = require('./fastlane.freebsd-x64.node'); } else { - nativeBinding = require('@pipeit/fastlane-linux-riscv64-gnu') + nativeBinding = require('@pipeit/fastlane-freebsd-x64'); } - } catch (e) { - loadError = e - } - } - break - case 's390x': - localFileExisted = existsSync( - join(__dirname, 'fastlane.linux-s390x-gnu.node') - ) - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-s390x-gnu.node') - } else { - nativeBinding = require('@pipeit/fastlane-linux-s390x-gnu') - } } catch (e) { - loadError = e + loadError = e; } - break - default: - throw new Error(`Unsupported architecture on Linux: ${arch}`) - } - break - default: - throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) + break; + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-x64-musl.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-x64-musl.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-x64-musl'); + } + } catch (e) { + loadError = e; + } + } else { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-x64-gnu.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-x64-gnu.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-x64-gnu'); + } + } catch (e) { + loadError = e; + } + } + break; + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm64-musl.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm64-musl.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm64-musl'); + } + } catch (e) { + loadError = e; + } + } else { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm64-gnu.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm64-gnu.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm64-gnu'); + } + } catch (e) { + loadError = e; + } + } + break; + case 'arm': + if (isMusl()) { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm-musleabihf.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm-musleabihf.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm-musleabihf'); + } + } catch (e) { + loadError = e; + } + } else { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm-gnueabihf.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm-gnueabihf.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm-gnueabihf'); + } + } catch (e) { + loadError = e; + } + } + break; + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-riscv64-musl.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-riscv64-musl.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-riscv64-musl'); + } + } catch (e) { + loadError = e; + } + } else { + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-riscv64-gnu.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-riscv64-gnu.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-riscv64-gnu'); + } + } catch (e) { + loadError = e; + } + } + break; + case 's390x': + localFileExisted = existsSync(join(__dirname, 'fastlane.linux-s390x-gnu.node')); + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-s390x-gnu.node'); + } else { + nativeBinding = require('@pipeit/fastlane-linux-s390x-gnu'); + } + } catch (e) { + loadError = e; + } + break; + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`); + } + break; + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`); } if (!nativeBinding) { - if (loadError) { - throw loadError - } - throw new Error(`Failed to load native binding`) + if (loadError) { + throw loadError; + } + throw new Error(`Failed to load native binding`); } -const { TpuClient } = nativeBinding +const { TpuClient } = nativeBinding; -module.exports.TpuClient = TpuClient +module.exports.TpuClient = TpuClient; diff --git a/packages/fastlane/test.mjs b/packages/fastlane/test.mjs index 026082e..38d4181 100644 --- a/packages/fastlane/test.mjs +++ b/packages/fastlane/test.mjs @@ -58,11 +58,7 @@ describe('TpuClientConfig', async () => { // Missing required fields should throw // Note: NAPI converts snake_case to camelCase in error messages - assert.throws( - () => new TpuClient({}), - /rpcUrl|wsUrl|rpc_url|ws_url/i, - 'Should require rpc_url and ws_url' - ); + assert.throws(() => new TpuClient({}), /rpcUrl|wsUrl|rpc_url|ws_url/i, 'Should require rpc_url and ws_url'); console.log('✓ Config validation works'); }); @@ -173,10 +169,7 @@ describe('TpuClient API', async () => { ]; for (const method of expectedMethods) { - assert.ok( - typeof TpuClient.prototype[method] === 'function', - `TpuClient should have ${method} method` - ); + assert.ok(typeof TpuClient.prototype[method] === 'function', `TpuClient should have ${method} method`); } console.log('✓ TpuClient has all expected methods'); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73ab3dd..d421a28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: workspace:* version: link:../../packages/actions '@pipeit/core': - specifier: workspace:* - version: link:../../packages/core + specifier: ^0.2.5 + version: 0.2.5(59e1c826774811eacd0a7dd223ad1500) '@pipeit/fastlane': specifier: ^0.1.4 version: 0.1.4 From 4bbe3bfc0e41c2d81581c8c006707ab65e020cce Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 28 Dec 2025 14:52:55 -0800 Subject: [PATCH 12/12] chore: version bumps --- packages/core/package.json | 2 +- packages/fastlane/Cargo.lock | 2 +- packages/fastlane/Cargo.toml | 2 +- packages/fastlane/index.d.ts | 200 ++++++------ packages/fastlane/index.js | 536 +++++++++++++++++---------------- packages/fastlane/package.json | 2 +- 6 files changed, 385 insertions(+), 359 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6c150e3..2d23682 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pipeit/core", - "version": "0.2.5", + "version": "0.2.6", "description": "Type-safe transaction builder for Solana with smart defaults", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/fastlane/Cargo.lock b/packages/fastlane/Cargo.lock index 9b7bbe0..29ca04d 100644 --- a/packages/fastlane/Cargo.lock +++ b/packages/fastlane/Cargo.lock @@ -2234,7 +2234,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pipeit-fastlane" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "dashmap 6.1.0", diff --git a/packages/fastlane/Cargo.toml b/packages/fastlane/Cargo.toml index 18f3683..04133cd 100644 --- a/packages/fastlane/Cargo.toml +++ b/packages/fastlane/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pipeit-fastlane" -version = "0.1.5" +version = "0.1.6" edition = "2021" description = "Native QUIC client for direct Solana TPU transaction submission" license = "MIT" diff --git a/packages/fastlane/index.d.ts b/packages/fastlane/index.d.ts index f81e514..1241e81 100644 --- a/packages/fastlane/index.d.ts +++ b/packages/fastlane/index.d.ts @@ -5,81 +5,81 @@ /** Configuration for the TPU client. */ export interface TpuClientConfig { - /** RPC URL for fetching leader schedule and cluster info. */ - rpcUrl: string; - /** WebSocket URL for slot update subscriptions. */ - wsUrl: string; - /** - * Optional gRPC URL for Yellowstone slot subscriptions. - * When set, this takes precedence over WebSocket tracking. - */ - grpcUrl?: string; - /** Optional gRPC x-token for authenticated Yellowstone endpoints. */ - grpcXToken?: string; - /** Number of upcoming leaders to send transactions to (default: 2). */ - fanout?: number; - /** Whether to pre-warm connections to upcoming leaders (default: true). */ - prewarmConnections?: boolean; + /** RPC URL for fetching leader schedule and cluster info. */ + rpcUrl: string + /** WebSocket URL for slot update subscriptions. */ + wsUrl: string + /** + * Optional gRPC URL for Yellowstone slot subscriptions. + * When set, this takes precedence over WebSocket tracking. + */ + grpcUrl?: string + /** Optional gRPC x-token for authenticated Yellowstone endpoints. */ + grpcXToken?: string + /** Number of upcoming leaders to send transactions to (default: 2). */ + fanout?: number + /** Whether to pre-warm connections to upcoming leaders (default: true). */ + prewarmConnections?: boolean } /** Result for a single leader send attempt. */ export interface LeaderSendResult { - /** Validator identity pubkey. */ - identity: string; - /** TPU socket address. */ - address: string; - /** Whether send succeeded. */ - success: boolean; - /** Latency for this leader in milliseconds. */ - latencyMs: number; - /** Error message if failed. */ - error?: string; - /** Error code for programmatic handling. */ - errorCode?: string; - /** Number of attempts made for this leader. */ - attempts: number; + /** Validator identity pubkey. */ + identity: string + /** TPU socket address. */ + address: string + /** Whether send succeeded. */ + success: boolean + /** Latency for this leader in milliseconds. */ + latencyMs: number + /** Error message if failed. */ + error?: string + /** Error code for programmatic handling. */ + errorCode?: string + /** Number of attempts made for this leader. */ + attempts: number } /** Result from sending a transaction. */ export interface SendResult { - /** Whether the transaction was successfully delivered. */ - delivered: boolean; - /** Total latency in milliseconds. */ - latencyMs: number; - /** Number of leaders the transaction was sent to. */ - leaderCount: number; - /** Per-leader breakdown of send results. */ - leaders: Array; - /** Total retry attempts made across all leaders. */ - retryCount: number; + /** Whether the transaction was successfully delivered. */ + delivered: boolean + /** Total latency in milliseconds. */ + latencyMs: number + /** Number of leaders the transaction was sent to. */ + leaderCount: number + /** Per-leader breakdown of send results. */ + leaders: Array + /** Total retry attempts made across all leaders. */ + retryCount: number } /** Client health and statistics. */ export interface TpuClientStats { - /** Number of active QUIC connections. */ - connectionCount: number; - /** Current estimated slot. */ - currentSlot: number; - /** Number of QUIC endpoints. */ - endpointCount: number; - /** Client ready state: "initializing", "ready", or "error". */ - readyState: string; - /** Seconds since client was created. */ - uptimeSecs: number; - /** Number of validators with known sockets. */ - knownValidators: number; + /** Number of active QUIC connections. */ + connectionCount: number + /** Current estimated slot. */ + currentSlot: number + /** Number of QUIC endpoints. */ + endpointCount: number + /** Client ready state: "initializing", "ready", or "error". */ + readyState: string + /** Seconds since client was created. */ + uptimeSecs: number + /** Number of validators with known sockets. */ + knownValidators: number } /** Result from continuous send until confirmed. */ export interface SendUntilConfirmedResult { - /** Whether the transaction was confirmed on-chain. */ - confirmed: boolean; - /** Transaction signature (base58). */ - signature: string; - /** Number of send rounds attempted. */ - rounds: number; - /** Total number of leader sends across all rounds. */ - totalLeadersSent: number; - /** Total latency in milliseconds. */ - latencyMs: number; - /** Error message if failed. */ - error?: string; + /** Whether the transaction was confirmed on-chain. */ + confirmed: boolean + /** Transaction signature (base58). */ + signature: string + /** Number of send rounds attempted. */ + rounds: number + /** Total number of leader sends across all rounds. */ + totalLeadersSent: number + /** Total latency in milliseconds. */ + latencyMs: number + /** Error message if failed. */ + error?: string } /** * Native QUIC client for direct Solana TPU transaction submission. @@ -87,41 +87,41 @@ export interface SendUntilConfirmedResult { * Supports continuous resubmission until confirmed for high landing rates. */ export declare class TpuClient { - /** Creates a new TPU client instance. */ - constructor(config: TpuClientConfig); - /** - * Sends a serialized transaction to TPU endpoints (single attempt). - * - * Uses slot-aware leader selection when available, falling back to fanout. - * Returns detailed per-leader results including retry statistics. - * For higher landing rates, use `send_until_confirmed` instead. - */ - sendTransaction(transaction: Buffer): Promise; - /** - * Sends a transaction continuously until confirmed or timeout. - * - * Uses slot-aware leader selection to minimize tx leakage: - * - Slots 0-2 of leader window: sends to current leader only - * - Slot 3 of leader window: sends to current + next leader (hedge) - * - * Falls back to fixed fanout if slot estimation is unreliable. - * - * # Arguments - * * `transaction` - Serialized signed transaction - * * `timeout_ms` - Maximum time to wait for confirmation (default: 30000ms) - * - * # Returns - * Result indicating whether the transaction was confirmed on-chain. - */ - sendUntilConfirmed(transaction: Buffer, timeoutMs?: number | undefined | null): Promise; - /** Gets the current estimated slot number. */ - getCurrentSlot(): number; - /** Gets the number of active QUIC connections. */ - getConnectionCount(): Promise; - /** Gets comprehensive client statistics. */ - getStats(): Promise; - /** Waits for the client to be fully initialized. */ - waitReady(): Promise; - /** Shuts down the client and closes all connections. */ - shutdown(): void; + /** Creates a new TPU client instance. */ + constructor(config: TpuClientConfig) + /** + * Sends a serialized transaction to TPU endpoints (single attempt). + * + * Uses slot-aware leader selection when available, falling back to fanout. + * Returns detailed per-leader results including retry statistics. + * For higher landing rates, use `send_until_confirmed` instead. + */ + sendTransaction(transaction: Buffer): Promise + /** + * Sends a transaction continuously until confirmed or timeout. + * + * Uses slot-aware leader selection to minimize tx leakage: + * - Slots 0-2 of leader window: sends to current leader only + * - Slot 3 of leader window: sends to current + next leader (hedge) + * + * Falls back to fixed fanout if slot estimation is unreliable. + * + * # Arguments + * * `transaction` - Serialized signed transaction + * * `timeout_ms` - Maximum time to wait for confirmation (default: 30000ms) + * + * # Returns + * Result indicating whether the transaction was confirmed on-chain. + */ + sendUntilConfirmed(transaction: Buffer, timeoutMs?: number | undefined | null): Promise + /** Gets the current estimated slot number. */ + getCurrentSlot(): number + /** Gets the number of active QUIC connections. */ + getConnectionCount(): Promise + /** Gets comprehensive client statistics. */ + getStats(): Promise + /** Waits for the client to be fully initialized. */ + waitReady(): Promise + /** Shuts down the client and closes all connections. */ + shutdown(): void } diff --git a/packages/fastlane/index.js b/packages/fastlane/index.js index e537c19..bcdd740 100644 --- a/packages/fastlane/index.js +++ b/packages/fastlane/index.js @@ -5,285 +5,311 @@ /* auto-generated by NAPI-RS */ const { existsSync, readFileSync } = require('fs') -const { join } = require('path'); +const { join } = require('path') -const { platform, arch } = process; +const { platform, arch } = process -let nativeBinding = null; -let localFileExisted = false; -let loadError = null; +let nativeBinding = null +let localFileExisted = false +let loadError = null function isMusl() { - // For Node 10 - if (!process.report || typeof process.report.getReport !== 'function') { - try { - const lddPath = require('child_process').execSync('which ldd').toString().trim(); - return readFileSync(lddPath, 'utf8').includes('musl'); - } catch (e) { - return true; - } - } else { - const { glibcVersionRuntime } = process.report.getReport().header; - return !glibcVersionRuntime; + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { + try { + const lddPath = require('child_process').execSync('which ldd').toString().trim() + return readFileSync(lddPath, 'utf8').includes('musl') + } catch (e) { + return true } + } else { + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime + } } switch (platform) { - case 'android': - switch (arch) { - case 'arm64': - localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm64.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.android-arm64.node'); - } else { - nativeBinding = require('@pipeit/fastlane-android-arm64'); - } - } catch (e) { - loadError = e; - } - break; - case 'arm': - localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm-eabi.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.android-arm-eabi.node'); - } else { - nativeBinding = require('@pipeit/fastlane-android-arm-eabi'); - } - } catch (e) { - loadError = e; - } - break; - default: - throw new Error(`Unsupported architecture on Android ${arch}`); + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.android-arm64.node') + } else { + nativeBinding = require('@pipeit/fastlane-android-arm64') + } + } catch (e) { + loadError = e + } + break + case 'arm': + localFileExisted = existsSync(join(__dirname, 'fastlane.android-arm-eabi.node')) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.android-arm-eabi.node') + } else { + nativeBinding = require('@pipeit/fastlane-android-arm-eabi') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Android ${arch}`) + } + break + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync( + join(__dirname, 'fastlane.win32-x64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.win32-x64-msvc.node') + } else { + nativeBinding = require('@pipeit/fastlane-win32-x64-msvc') + } + } catch (e) { + loadError = e + } + break + case 'ia32': + localFileExisted = existsSync( + join(__dirname, 'fastlane.win32-ia32-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.win32-ia32-msvc.node') + } else { + nativeBinding = require('@pipeit/fastlane-win32-ia32-msvc') + } + } catch (e) { + loadError = e } - break; - case 'win32': - switch (arch) { - case 'x64': - localFileExisted = existsSync(join(__dirname, 'fastlane.win32-x64-msvc.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.win32-x64-msvc.node'); - } else { - nativeBinding = require('@pipeit/fastlane-win32-x64-msvc'); - } - } catch (e) { - loadError = e; - } - break; - case 'ia32': - localFileExisted = existsSync(join(__dirname, 'fastlane.win32-ia32-msvc.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.win32-ia32-msvc.node'); - } else { - nativeBinding = require('@pipeit/fastlane-win32-ia32-msvc'); - } - } catch (e) { - loadError = e; - } - break; - case 'arm64': - localFileExisted = existsSync(join(__dirname, 'fastlane.win32-arm64-msvc.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.win32-arm64-msvc.node'); - } else { - nativeBinding = require('@pipeit/fastlane-win32-arm64-msvc'); - } - } catch (e) { - loadError = e; - } - break; - default: - throw new Error(`Unsupported architecture on Windows: ${arch}`); + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'fastlane.win32-arm64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.win32-arm64-msvc.node') + } else { + nativeBinding = require('@pipeit/fastlane-win32-arm64-msvc') + } + } catch (e) { + loadError = e } - break; - case 'darwin': - localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-universal.node')); + break + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } + break + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-universal.node')) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.darwin-universal.node') + } else { + nativeBinding = require('@pipeit/fastlane-darwin-universal') + } + break + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-x64.node')) try { + if (localFileExisted) { + nativeBinding = require('./fastlane.darwin-x64.node') + } else { + nativeBinding = require('@pipeit/fastlane-darwin-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'fastlane.darwin-arm64.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.darwin-arm64.node') + } else { + nativeBinding = require('@pipeit/fastlane-darwin-arm64') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } + break + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) + } + localFileExisted = existsSync(join(__dirname, 'fastlane.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.freebsd-x64.node') + } else { + nativeBinding = require('@pipeit/fastlane-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-x64-musl.node') + ) + try { if (localFileExisted) { - nativeBinding = require('./fastlane.darwin-universal.node'); + nativeBinding = require('./fastlane.linux-x64-musl.node') } else { - nativeBinding = require('@pipeit/fastlane-darwin-universal'); + nativeBinding = require('@pipeit/fastlane-linux-x64-musl') } - break; - } catch {} - switch (arch) { - case 'x64': - localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-x64.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.darwin-x64.node'); - } else { - nativeBinding = require('@pipeit/fastlane-darwin-x64'); - } - } catch (e) { - loadError = e; - } - break; - case 'arm64': - localFileExisted = existsSync(join(__dirname, 'fastlane.darwin-arm64.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.darwin-arm64.node'); - } else { - nativeBinding = require('@pipeit/fastlane-darwin-arm64'); - } - } catch (e) { - loadError = e; - } - break; - default: - throw new Error(`Unsupported architecture on macOS: ${arch}`); + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-x64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-x64-gnu.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-x64-gnu') + } + } catch (e) { + loadError = e + } } - break; - case 'freebsd': - if (arch !== 'x64') { - throw new Error(`Unsupported architecture on FreeBSD: ${arch}`); + break + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-arm64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm64-musl.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-arm64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm64-gnu.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm64-gnu') + } + } catch (e) { + loadError = e + } } - localFileExisted = existsSync(join(__dirname, 'fastlane.freebsd-x64.node')); - try { + break + case 'arm': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-arm-musleabihf.node') + ) + try { if (localFileExisted) { - nativeBinding = require('./fastlane.freebsd-x64.node'); + nativeBinding = require('./fastlane.linux-arm-musleabihf.node') } else { - nativeBinding = require('@pipeit/fastlane-freebsd-x64'); + nativeBinding = require('@pipeit/fastlane-linux-arm-musleabihf') } - } catch (e) { - loadError = e; + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e + } + } + break + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-riscv64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-riscv64-musl.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-riscv64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-riscv64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-riscv64-gnu.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-riscv64-gnu') + } + } catch (e) { + loadError = e + } } - break; - case 'linux': - switch (arch) { - case 'x64': - if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-x64-musl.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-x64-musl.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-x64-musl'); - } - } catch (e) { - loadError = e; - } - } else { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-x64-gnu.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-x64-gnu.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-x64-gnu'); - } - } catch (e) { - loadError = e; - } - } - break; - case 'arm64': - if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm64-musl.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm64-musl.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm64-musl'); - } - } catch (e) { - loadError = e; - } - } else { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm64-gnu.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm64-gnu.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm64-gnu'); - } - } catch (e) { - loadError = e; - } - } - break; - case 'arm': - if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm-musleabihf.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm-musleabihf.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm-musleabihf'); - } - } catch (e) { - loadError = e; - } - } else { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-arm-gnueabihf.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-arm-gnueabihf.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-arm-gnueabihf'); - } - } catch (e) { - loadError = e; - } - } - break; - case 'riscv64': - if (isMusl()) { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-riscv64-musl.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-riscv64-musl.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-riscv64-musl'); - } - } catch (e) { - loadError = e; - } - } else { - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-riscv64-gnu.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-riscv64-gnu.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-riscv64-gnu'); - } - } catch (e) { - loadError = e; - } - } - break; - case 's390x': - localFileExisted = existsSync(join(__dirname, 'fastlane.linux-s390x-gnu.node')); - try { - if (localFileExisted) { - nativeBinding = require('./fastlane.linux-s390x-gnu.node'); - } else { - nativeBinding = require('@pipeit/fastlane-linux-s390x-gnu'); - } - } catch (e) { - loadError = e; - } - break; - default: - throw new Error(`Unsupported architecture on Linux: ${arch}`); + break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'fastlane.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./fastlane.linux-s390x-gnu.node') + } else { + nativeBinding = require('@pipeit/fastlane-linux-s390x-gnu') + } + } catch (e) { + loadError = e } - break; - default: - throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`); + break + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } + break + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) } if (!nativeBinding) { - if (loadError) { - throw loadError; - } - throw new Error(`Failed to load native binding`); + if (loadError) { + throw loadError + } + throw new Error(`Failed to load native binding`) } -const { TpuClient } = nativeBinding; +const { TpuClient } = nativeBinding -module.exports.TpuClient = TpuClient; +module.exports.TpuClient = TpuClient diff --git a/packages/fastlane/package.json b/packages/fastlane/package.json index 97185d4..40ea65a 100644 --- a/packages/fastlane/package.json +++ b/packages/fastlane/package.json @@ -1,6 +1,6 @@ { "name": "@pipeit/fastlane", - "version": "0.1.5", + "version": "0.1.6", "description": "Native QUIC client for direct Solana TPU transaction submission", "main": "index.js", "types": "index.d.ts",