diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index 2c84113617..9ba1846129 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -49,6 +49,7 @@ export enum InstructionBuilderTypes { SetPriorityFee = 'SetPriorityFee', MintTo = 'MintTo', Burn = 'Burn', + CustomInstruction = 'CustomInstruction', } export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ diff --git a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts new file mode 100644 index 0000000000..fd239719cd --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts @@ -0,0 +1,119 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { TransactionInstruction } from '@solana/web3.js'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import { InstructionBuilderTypes } from './constants'; +import { CustomInstruction } from './iface'; +import assert from 'assert'; + +/** + * Transaction builder for custom Solana instructions. + * Allows building transactions with any set of raw Solana instructions. + */ +export class CustomInstructionBuilder extends TransactionBuilder { + private _customInstructions: CustomInstruction[] = []; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + /** + * Initialize the builder from an existing transaction + */ + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + for (const instruction of this._instructionsData) { + if (instruction.type === InstructionBuilderTypes.CustomInstruction) { + const customInstruction = instruction as CustomInstruction; + this.addCustomInstruction(customInstruction.params.instruction); + } + } + } + + /** + * Add a custom Solana instruction to the transaction + * + * @param instruction - The raw Solana TransactionInstruction + * @returns This transaction builder + */ + addCustomInstruction(instruction: TransactionInstruction): this { + if (!instruction) { + throw new BuildTransactionError('Instruction cannot be null or undefined'); + } + + if (!instruction.programId) { + throw new BuildTransactionError('Instruction must have a valid programId'); + } + + if (!instruction.keys || !Array.isArray(instruction.keys)) { + throw new BuildTransactionError('Instruction must have valid keys array'); + } + + if (!instruction.data || !Buffer.isBuffer(instruction.data)) { + throw new BuildTransactionError('Instruction must have valid data buffer'); + } + + const customInstruction: CustomInstruction = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + instruction, + }, + }; + + this._customInstructions.push(customInstruction); + return this; + } + + /** + * Add multiple custom Solana instructions to the transaction + * + * @param instructions - Array of raw Solana TransactionInstructions + * @returns This transaction builder + */ + addCustomInstructions(instructions: TransactionInstruction[]): this { + if (!Array.isArray(instructions)) { + throw new BuildTransactionError('Instructions must be an array'); + } + + for (const instruction of instructions) { + this.addCustomInstruction(instruction); + } + + return this; + } + + /** + * Clear all custom instructions + * + * @returns This transaction builder + */ + clearInstructions(): this { + this._customInstructions = []; + return this; + } + + /** + * Get the current custom instructions + * + * @returns Array of custom instructions + */ + getInstructions(): CustomInstruction[] { + return [...this._customInstructions]; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + assert(this._customInstructions.length > 0, 'At least one custom instruction must be specified'); + + // Set the instructions data to our custom instructions + this._instructionsData = [...this._customInstructions]; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index c4126809de..32f7ae9638 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -1,6 +1,12 @@ import { TransactionExplanation as BaseTransactionExplanation, Recipient } from '@bitgo/sdk-core'; import { DecodedCloseAccountInstruction } from '@solana/spl-token'; -import { Blockhash, StakeInstructionType, SystemInstructionType, TransactionSignature } from '@solana/web3.js'; +import { + Blockhash, + StakeInstructionType, + SystemInstructionType, + TransactionInstruction, + TransactionSignature, +} from '@solana/web3.js'; import { InstructionBuilderTypes } from './constants'; // TODO(STLX-9890): Add the interfaces for validityWindow and SequenceId @@ -40,7 +46,8 @@ export type InstructionParams = | StakingAuthorize | StakingDelegate | MintTo - | Burn; + | Burn + | CustomInstruction; export interface Memo { type: InstructionBuilderTypes.Memo; @@ -201,6 +208,13 @@ export type StakingDelegateParams = { validator: string; }; +export interface CustomInstruction { + type: InstructionBuilderTypes.CustomInstruction; + params: { + instruction: TransactionInstruction; + }; +} + export interface TransactionExplanation extends BaseTransactionExplanation { type: string; blockhash: Blockhash; diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 57d7ff8d05..9a325fe6f2 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -3,6 +3,7 @@ import * as Utils from './utils'; export { AtaInitializationBuilder } from './ataInitializationBuilder'; export { CloseAtaBuilder } from './closeAtaBuilder'; +export { CustomInstructionBuilder } from './customInstructionBuilder'; export { KeyPair } from './keyPair'; export { StakingActivateBuilder } from './stakingActivateBuilder'; export { StakingAuthorizeBuilder } from './stakingAuthorizeBuilder'; diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index 614c08863a..d081dc0831 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -38,6 +38,7 @@ import { Transfer, WalletInit, SetPriorityFee, + CustomInstruction, } from './iface'; import { getSolTokenFromTokenName } from './utils'; @@ -79,6 +80,8 @@ export function solInstructionFactory(instructionToBuild: InstructionParams): Tr return mintToInstruction(instructionToBuild); case InstructionBuilderTypes.Burn: return burnInstruction(instructionToBuild); + case InstructionBuilderTypes.CustomInstruction: + return customInstruction(instructionToBuild); default: throw new Error(`Invalid instruction type or not supported`); } @@ -546,3 +549,17 @@ function burnInstruction(data: Burn): TransactionInstruction[] { return [burnInstr]; } + +/** + * Process custom instruction - simply returns the raw instruction + * + * @param {CustomInstruction} data - the data containing the custom instruction + * @returns {TransactionInstruction[]} An array containing the custom instruction + */ +function customInstruction(data: InstructionParams): TransactionInstruction[] { + const { + params: { instruction }, + } = data as CustomInstruction; + assert(instruction, 'Missing instruction param'); + return [instruction]; +} diff --git a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts index 45e348d0bc..25dc9311ca 100644 --- a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts @@ -2,6 +2,7 @@ import { BaseTransactionBuilderFactory, InvalidTransactionError, TransactionType import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { AtaInitializationBuilder } from './ataInitializationBuilder'; import { CloseAtaBuilder } from './closeAtaBuilder'; +import { CustomInstructionBuilder } from './customInstructionBuilder'; import { StakingActivateBuilder } from './stakingActivateBuilder'; import { StakingAuthorizeBuilder } from './stakingAuthorizeBuilder'; import { StakingDeactivateBuilder } from './stakingDeactivateBuilder'; @@ -175,6 +176,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new CloseAtaBuilder(this._coinConfig)); } + /** + * Returns the builder to create transactions with custom Solana instructions. + */ + getCustomInstructionBuilder(tx?: Transaction): CustomInstructionBuilder { + return this.initializeBuilder(tx, new CustomInstructionBuilder(this._coinConfig)); + } + /** * Initialize the builder with the given transaction * diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts new file mode 100644 index 0000000000..2e02115c7f --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts @@ -0,0 +1,221 @@ +import should from 'should'; +import { SystemProgram, PublicKey, TransactionInstruction, ComputeBudgetProgram } from '@solana/web3.js'; +import { getBuilderFactory } from '../getBuilderFactory'; +import { KeyPair, Utils, Transaction } from '../../../src'; +import * as testData from '../../resources/sol'; + +describe('Sol Custom Instruction Builder', () => { + const factory = getBuilderFactory('tsol'); + + const customInstructionBuilder = () => { + const txBuilder = factory.getCustomInstructionBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + return txBuilder; + }; + + const authAccount = new KeyPair(testData.authAccount).getKeys(); + const otherAccount = new KeyPair({ prv: testData.prvKeys.prvKey1.base58 }).getKeys(); + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + const memo = 'test memo'; + + describe('Succeed', () => { + it('build a transaction with a single custom instruction', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const txBuilder = customInstructionBuilder(); + txBuilder.addCustomInstruction(transferInstruction); + const tx = await txBuilder.build(); + + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: authAccount.pub, + value: '1000000', + coin: 'tsol', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: otherAccount.pub, + value: '1000000', + coin: 'tsol', + }); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + + it('build a transaction with multiple custom instructions', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const priorityFeeInstruction = ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: 1000, + }); + + const txBuilder = customInstructionBuilder(); + txBuilder.addCustomInstructions([transferInstruction, priorityFeeInstruction]); + const tx = await txBuilder.build(); + + tx.inputs.length.should.equal(1); + tx.outputs.length.should.equal(1); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + + // Should have 2 instructions + (tx as Transaction).solTransaction.instructions.should.have.length(2); + }); + + it('build a transaction with custom instruction and memo', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const txBuilder = customInstructionBuilder(); + txBuilder.addCustomInstruction(transferInstruction); + txBuilder.memo(memo); + const tx = await txBuilder.build(); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + + // Should have instruction + memo + (tx as Transaction).solTransaction.instructions.should.have.length(2); + }); + + it('build a signed transaction with custom instruction', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const txBuilder = customInstructionBuilder(); + txBuilder.addCustomInstruction(transferInstruction); + txBuilder.sign({ key: authAccount.prv }); + const tx = await txBuilder.build(); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + + // Should be signed + (tx as Transaction).solTransaction.signatures.should.not.be.empty(); + }); + + it('clear instructions from builder', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const txBuilder = customInstructionBuilder(); + txBuilder.addCustomInstruction(transferInstruction); + txBuilder.getInstructions().should.have.length(1); + + txBuilder.clearInstructions(); + txBuilder.getInstructions().should.have.length(0); + }); + }); + + describe('Fail', () => { + it('for null instruction', () => { + const txBuilder = customInstructionBuilder(); + should(() => txBuilder.addCustomInstruction(null as unknown as TransactionInstruction)).throwError( + 'Instruction cannot be null or undefined' + ); + }); + + it('for undefined instruction', () => { + const txBuilder = customInstructionBuilder(); + should(() => txBuilder.addCustomInstruction(undefined as unknown as TransactionInstruction)).throwError( + 'Instruction cannot be null or undefined' + ); + }); + + it('for instruction without programId', () => { + const txBuilder = customInstructionBuilder(); + const invalidInstruction = { + keys: [], + data: Buffer.alloc(0), + } as unknown as TransactionInstruction; + + should(() => txBuilder.addCustomInstruction(invalidInstruction)).throwError( + 'Instruction must have a valid programId' + ); + }); + + it('for instruction without keys', () => { + const txBuilder = customInstructionBuilder(); + const invalidInstruction = { + programId: new PublicKey('11111111111111111111111111111112'), + data: Buffer.alloc(0), + } as TransactionInstruction; + + should(() => txBuilder.addCustomInstruction(invalidInstruction)).throwError( + 'Instruction must have valid keys array' + ); + }); + + it('for instruction without data', () => { + const txBuilder = customInstructionBuilder(); + const invalidInstruction = { + programId: new PublicKey('11111111111111111111111111111112'), + keys: [], + } as unknown as TransactionInstruction; + + should(() => txBuilder.addCustomInstruction(invalidInstruction)).throwError( + 'Instruction must have valid data buffer' + ); + }); + + it('for non-array in addCustomInstructions', () => { + const txBuilder = customInstructionBuilder(); + should(() => txBuilder.addCustomInstructions('invalid' as unknown as TransactionInstruction[])).throwError( + 'Instructions must be an array' + ); + }); + + it('when building without instructions', async () => { + const txBuilder = customInstructionBuilder(); + await txBuilder.build().should.be.rejectedWith('At least one custom instruction must be specified'); + }); + + it('when building without sender', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const txBuilder = factory.getCustomInstructionBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.addCustomInstruction(transferInstruction); + + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing sender'); + }); + + it('when building without nonce', async () => { + const transferInstruction = SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }); + + const txBuilder = factory.getCustomInstructionBuilder(); + txBuilder.sender(authAccount.pub); + txBuilder.addCustomInstruction(transferInstruction); + + await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing nonce blockhash'); + }); + }); +});