From 2ad49f7d2973f73d166f82a2fdffe38242c10773 Mon Sep 17 00:00:00 2001 From: Raj Date: Fri, 1 Aug 2025 13:02:34 +0530 Subject: [PATCH] feat: added custom tx builder for sol - added custom instruction builder for sol - use new intent type solInstruction to build the tx in WP TICKET: TMS-1218 --- modules/bitgo/test/v2/unit/wallet.ts | 601 ++++++++++++++++++ .../src/lib/customInstructionBuilder.ts | 113 ++-- modules/sdk-coin-sol/src/lib/iface.ts | 16 +- .../src/lib/instructionParamsFactory.ts | 47 ++ .../src/lib/solInstructionFactory.ts | 39 +- modules/sdk-coin-sol/src/lib/transaction.ts | 8 + modules/sdk-coin-sol/src/lib/utils.ts | 23 + modules/sdk-coin-sol/src/sol.ts | 10 + .../test/unit/solInstructionFactory.ts | 140 ++++ .../customInstructionBuilder.ts | 98 +-- .../sdk-core/src/account-lib/baseCoin/enum.ts | 2 + modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 11 +- .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 26 + .../sdk-core/src/bitgo/wallet/BuildParams.ts | 2 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 13 + modules/sdk-core/src/bitgo/wallet/wallet.ts | 12 + 16 files changed, 1064 insertions(+), 97 deletions(-) diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 3bfa9eb004..52b3ce0717 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -2780,6 +2780,285 @@ describe('V2 Wallet:', function () { }); }); + it('should build a custom instruction transaction', async function () { + const recipients = [ + { + address: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + amount: '1000', + }, + ]; + const solInstructions = [ + { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + isSigner: true, + isWritable: true, + }, + ], + data: 'SGVsbG8gV29ybGQ=', + }, + ]; + + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions, + recipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + recipients, + type: 'solInstruction', + solInstructions, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + recipients, + type: 'solInstruction', + solInstructions, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should build a custom instruction transaction with multiple instructions', async function () { + const recipients = []; + const solInstructions = [ + { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + isSigner: true, + isWritable: true, + }, + ], + data: 'SGVsbG8gV29ybGQ=', + }, + { + programId: 'ComputeBudget111111111111111111111111111111', + keys: [], + data: 'AwAA6AMAAAAA', + }, + ]; + + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions, + recipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + recipients, + type: 'solInstruction', + solInstructions, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + recipients, + type: 'solInstruction', + solInstructions, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should build a custom instruction transaction with memo', async function () { + const recipients = [ + { + address: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + amount: '1000', + }, + ]; + const solInstructions = [ + { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + isSigner: true, + isWritable: true, + }, + ], + data: 'SGVsbG8gV29ybGQ=', + }, + ]; + + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions, + recipients, + memo: { + type: 'type', + value: 'test memo', + }, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + recipients, + type: 'solInstruction', + solInstructions, + memo: { + type: 'type', + value: 'test memo', + }, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + recipients, + type: 'solInstruction', + solInstructions, + memo: { + type: 'type', + value: 'test memo', + }, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should build a custom instruction transaction with pending approval id', async function () { + const recipients = [ + { + address: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + amount: '1000', + }, + ]; + const solInstructions = [ + { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + isSigner: true, + isWritable: true, + }, + ], + data: 'SGVsbG8gV29ybGQ=', + }, + ]; + + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves({ ...txRequest, state: 'pendingApproval', pendingApprovalId: 'some-id' }); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions, + recipients, + }); + + const txPrebuild = await custodialTssSolWallet.prebuildTransaction({ + reqId, + recipients, + type: 'solInstruction', + solInstructions, + }); + + txPrebuild.should.deepEqual({ + walletId: custodialTssSolWallet.id(), + wallet: custodialTssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + pendingApprovalId: 'some-id', + buildParams: { + recipients, + type: 'solInstruction', + solInstructions, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should build a custom instruction transaction for cold wallets', async function () { + const recipients = []; + const solInstructions = [ + { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: '6DadkZcx9JZgeQUDbHh12cmqCpaqehmVxv6sGy49jrah', + isSigner: true, + isWritable: true, + }, + ], + data: 'SGVsbG8gV29ybGQ=', + }, + ]; + + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + txRequest.walletType = 'cold'; + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions, + recipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + recipients, + type: 'solInstruction', + solInstructions, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + recipients, + type: 'solInstruction', + solInstructions, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + it('should fail for non-transfer transaction types', async function () { await tssSolWallet .prebuildTransaction({ @@ -5100,4 +5379,326 @@ describe('V2 Wallet:', function () { sinon.restore(); }); }); + + describe('Solana Instruction Flow', function () { + const sandbox = sinon.createSandbox(); + + // Set up test data and wallets + const tsol = bitgo.coin('tsol'); + const walletData = { + id: '5b34252f1bf349930e34020a00000000', + coin: 'tsol', + keys: [ + '598f606cd8fc24710d2ebad89dce86c2', + '598f606cc8e43aef09fcb785221d9dd2', + '5935d59cf660764331bafcade1855fd7', + ], + coinSpecific: {}, + multisigType: 'tss', + }; + + const tssSolWallet = new Wallet(bitgo, tsol, walletData); + const custodialTssSolWallet = new Wallet(bitgo, tsol, { + ...walletData, + type: 'custodial', + }); + + const reqId = new RequestTracer(); + + const txRequest: TxRequest = { + txRequestId: 'id', + transactions: [], + intent: { + intentType: 'solInstruction', + }, + date: new Date().toISOString(), + latest: true, + state: 'pendingUserSignature', + userId: 'userId', + walletType: 'hot', + policiesChecked: false, + version: 1, + walletId: 'walletId', + unsignedTxs: [ + { + serializedTxHex: 'ababcdcd', + signableHex: 'deadbeef', + feeInfo: { + fee: 5000, + feeString: '5000', + }, + derivationPath: 'm/0', + }, + ], + }; + + afterEach(function () { + sandbox.verifyAndRestore(); + }); + + const testCustomInstruction = { + programId: '11111111111111111111111111111111', + keys: [ + { + pubkey: 'DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh', + isSigner: true, + isWritable: true, + }, + { + pubkey: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + isSigner: false, + isWritable: false, + }, + ], + data: '3Bxs3zt6KK5hN', + }; + + const testRecipients = [ + { + address: 'DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh', + amount: '1000000', + }, + ]; + + describe('prebuildTransaction with solInstruction type', function () { + it('should call prebuildTxWithIntent with correct parameters for custom instruction', async function () { + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle solInstruction with empty recipients', async function () { + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: [], + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [testCustomInstruction], + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'solInstruction', + solInstructions: [testCustomInstruction], + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle multiple custom instructions', async function () { + const secondInstruction = { + programId: '22222222222222222222222222222222', + keys: [ + { + pubkey: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + isSigner: false, + isWritable: true, + }, + ], + data: 'testData123', + }; + + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions: [testCustomInstruction, secondInstruction], + recipients: testRecipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [testCustomInstruction, secondInstruction], + recipients: testRecipients, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'solInstruction', + solInstructions: [testCustomInstruction, secondInstruction], + recipients: testRecipients, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle solInstruction with memo parameter', async function () { + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + memo: { + type: 'type', + value: 'test memo', + }, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + memo: { + type: 'type', + value: 'test memo', + }, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + memo: { + type: 'type', + value: 'test memo', + }, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle solInstruction with pending approval ID', async function () { + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves({ ...txRequest, state: 'pendingApproval', pendingApprovalId: 'some-id' }); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }); + + const txPrebuild = await custodialTssSolWallet.prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }); + + txPrebuild.should.deepEqual({ + walletId: custodialTssSolWallet.id(), + wallet: custodialTssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + pendingApprovalId: 'some-id', + buildParams: { + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should throw error for empty solInstructions array', async function () { + await tssSolWallet + .prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [], + recipients: testRecipients, + }) + .should.be.rejectedWith(`'solInstructions' is a required parameter for solInstruction intent`); + }); + + it('should support solInstruction for cold wallets', async function () { + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + txRequest.walletType = 'cold'; + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'solInstruction', + solInstructions: [testCustomInstruction], + recipients: testRecipients, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + }); + }); }); diff --git a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts index fd239719cd..0edb225beb 100644 --- a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts @@ -1,12 +1,23 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; -import { TransactionInstruction } from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; import { InstructionBuilderTypes } from './constants'; import { CustomInstruction } from './iface'; import assert from 'assert'; +// Type alias for instruction parameters to make it cleaner +type InstructionParams = { + programId: string; + keys: Array<{ + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }>; + data: string; +}; + /** * Transaction builder for custom Solana instructions. * Allows building transactions with any set of raw Solana instructions. @@ -19,7 +30,7 @@ export class CustomInstructionBuilder extends TransactionBuilder { } protected get transactionType(): TransactionType { - return TransactionType.Send; + return TransactionType.SolanaCustomInstructions; } /** @@ -31,67 +42,44 @@ export class CustomInstructionBuilder extends TransactionBuilder { for (const instruction of this._instructionsData) { if (instruction.type === InstructionBuilderTypes.CustomInstruction) { const customInstruction = instruction as CustomInstruction; - this.addCustomInstruction(customInstruction.params.instruction); + this.addCustomInstruction(customInstruction.params); } } } /** - * Add a custom Solana instruction to the transaction - * - * @param instruction - The raw Solana TransactionInstruction - * @returns This transaction builder + * Add a custom instruction to the transaction + * @param instruction - The custom instruction to add + * @returns This builder instance */ - 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'); - } - + addCustomInstruction(instruction: InstructionParams): this { + this.validateInstruction(instruction); const customInstruction: CustomInstruction = { type: InstructionBuilderTypes.CustomInstruction, - params: { - instruction, - }, + 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 + * Add multiple custom instructions to the transaction + * @param instructions - Array of custom instructions to add + * @returns This builder instance */ - addCustomInstructions(instructions: TransactionInstruction[]): this { + addCustomInstructions(instructions: InstructionParams[]): 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 + * @returns This builder instance */ clearInstructions(): this { this._customInstructions = []; @@ -100,13 +88,62 @@ export class CustomInstructionBuilder extends TransactionBuilder { /** * Get the current custom instructions - * * @returns Array of custom instructions */ getInstructions(): CustomInstruction[] { return [...this._customInstructions]; } + /** + * Validate custom instruction format + * @param instruction - The instruction to validate + */ + private validateInstruction(instruction: InstructionParams): void { + if (!instruction) { + throw new BuildTransactionError('Instruction cannot be null or undefined'); + } + + if (!instruction.programId || typeof instruction.programId !== 'string') { + throw new BuildTransactionError('Instruction must have a valid programId string'); + } + + // Validate that programId is a valid Solana public key + try { + new PublicKey(instruction.programId); + } catch (error) { + throw new BuildTransactionError('Invalid programId format'); + } + + if (!instruction.keys || !Array.isArray(instruction.keys)) { + throw new BuildTransactionError('Instruction must have valid keys array'); + } + + // Validate each key + for (const key of instruction.keys) { + if (!key.pubkey || typeof key.pubkey !== 'string') { + throw new BuildTransactionError('Each key must have a valid pubkey string'); + } + + try { + new PublicKey(key.pubkey); + } catch (error) { + throw new BuildTransactionError('Invalid pubkey format in keys'); + } + + if (typeof key.isSigner !== 'boolean') { + throw new BuildTransactionError('Each key must have a boolean isSigner field'); + } + + if (typeof key.isWritable !== 'boolean') { + throw new BuildTransactionError('Each key must have a boolean isWritable field'); + } + } + + if (instruction.data === undefined || typeof instruction.data !== 'string') { + throw new BuildTransactionError('Instruction must have valid data string'); + } + } + /** @inheritdoc */ protected async buildImplementation(): Promise { assert(this._customInstructions.length > 0, 'At least one custom instruction must be specified'); diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index 363cc226e7..1ab8b98beb 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -1,12 +1,6 @@ import { TransactionExplanation as BaseTransactionExplanation, Recipient } from '@bitgo/sdk-core'; import { DecodedCloseAccountInstruction } from '@solana/spl-token'; -import { - Blockhash, - StakeInstructionType, - SystemInstructionType, - TransactionInstruction, - TransactionSignature, -} from '@solana/web3.js'; +import { Blockhash, StakeInstructionType, SystemInstructionType, TransactionSignature } from '@solana/web3.js'; import { InstructionBuilderTypes } from './constants'; import { StakePoolInstructionType } from '@solana/spl-stake-pool'; @@ -214,7 +208,13 @@ export type StakingDelegateParams = { export interface CustomInstruction { type: InstructionBuilderTypes.CustomInstruction; params: { - instruction: TransactionInstruction; + programId: string; + keys: Array<{ + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }>; + data: string; }; } diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index fc53ab1f97..3e6630162b 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -50,6 +50,7 @@ import { Transfer, WalletInit, SetPriorityFee, + CustomInstruction, } from './iface'; import { getInstructionType } from './utils'; import { DepositSolParams } from '@solana/spl-stake-pool'; @@ -90,6 +91,8 @@ export function instructionParamsFactory( return parseStakingAuthorizeRawInstructions(instructions); case TransactionType.StakingDelegate: return parseStakingDelegateInstructions(instructions); + case TransactionType.SolanaCustomInstructions: + return parseCustomInstructions(instructions, instructionMetadata); default: throw new NotSupported('Invalid transaction, transaction type not supported: ' + type); } @@ -964,6 +967,50 @@ function parseStakingAuthorizeRawInstructions(instructions: TransactionInstructi return instructionData; } +/** + * Parses Solana instructions to custom instruction params + * + * @param {TransactionInstruction[]} instructions - containing custom solana instructions + * @param {InstructionParams[]} instructionMetadata - the instruction metadata for the transaction + * @returns {InstructionParams[]} An array containing instruction params for custom instructions + */ +function parseCustomInstructions( + instructions: TransactionInstruction[], + instructionMetadata?: InstructionParams[] +): CustomInstruction[] { + const instructionData: CustomInstruction[] = []; + + for (let i = 0; i < instructions.length; i++) { + const instruction = instructions[i]; + + // Check if we have metadata for this instruction position + if ( + instructionMetadata && + instructionMetadata[i] && + instructionMetadata[i].type === InstructionBuilderTypes.CustomInstruction + ) { + instructionData.push(instructionMetadata[i] as CustomInstruction); + } else { + // Convert the raw instruction to CustomInstruction format + const customInstruction: CustomInstruction = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + programId: instruction.programId.toString(), + keys: instruction.keys.map((key) => ({ + pubkey: key.pubkey.toString(), + isSigner: key.isSigner, + isWritable: key.isWritable, + })), + data: instruction.data.toString('base64'), + }, + }; + instructionData.push(customInstruction); + } + } + + return instructionData; +} + function findTokenName( mintAddress: string, instructionMetadata?: InstructionParams[], diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index 8ca8225688..d93136caad 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -48,7 +48,7 @@ import { SetPriorityFee, CustomInstruction, } from './iface'; -import { getSolTokenFromTokenName } from './utils'; +import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils'; import { depositSolInstructions } from './jitoStakePoolOperations'; /** @@ -593,15 +593,40 @@ function burnInstruction(data: Burn): TransactionInstruction[] { } /** - * Process custom instruction - simply returns the raw instruction + * Process custom instruction - converts to TransactionInstruction + * Handles conversion from string-based format to TransactionInstruction format * * @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]; + const { params } = data as CustomInstruction; + assert(params.programId, 'Missing programId in custom instruction'); + assert(params.keys && Array.isArray(params.keys), 'Missing or invalid keys in custom instruction'); + assert(params.data !== undefined, 'Missing data in custom instruction'); + + // Convert string data to Buffer + let dataBuffer: Buffer; + + if (isValidBase64(params.data)) { + dataBuffer = Buffer.from(params.data, 'base64'); + } else if (isValidHex(params.data)) { + dataBuffer = Buffer.from(params.data, 'hex'); + } else { + // Fallback to UTF-8 + dataBuffer = Buffer.from(params.data, 'utf8'); + } + + // Create a new TransactionInstruction with the converted data + const convertedInstruction = new TransactionInstruction({ + programId: new PublicKey(params.programId), + keys: params.keys.map((key) => ({ + pubkey: new PublicKey(key.pubkey), + isSigner: key.isSigner, + isWritable: key.isWritable, + })), + data: dataBuffer, + }); + + return [convertedInstruction]; } diff --git a/modules/sdk-coin-sol/src/lib/transaction.ts b/modules/sdk-coin-sol/src/lib/transaction.ts index d475562aac..53e5051b4b 100644 --- a/modules/sdk-coin-sol/src/lib/transaction.ts +++ b/modules/sdk-coin-sol/src/lib/transaction.ts @@ -235,6 +235,9 @@ export class Transaction extends BaseTransaction { case TransactionType.StakingDelegate: this.setTransactionType(TransactionType.StakingDelegate); break; + case TransactionType.SolanaCustomInstructions: + this.setTransactionType(TransactionType.SolanaCustomInstructions); + break; } if (transactionType !== TransactionType.StakingAuthorizeRaw) { this.loadInputsAndOutputs(); @@ -398,6 +401,8 @@ export class Transaction extends BaseTransaction { break; case InstructionBuilderTypes.SetPriorityFee: break; + case InstructionBuilderTypes.CustomInstruction: + break; } } this._outputs = outputs; @@ -473,6 +478,9 @@ export class Transaction extends BaseTransaction { break; case InstructionBuilderTypes.CreateAssociatedTokenAccount: break; + case InstructionBuilderTypes.CustomInstruction: + // Custom instructions are arbitrary and cannot be explained + break; default: continue; } diff --git a/modules/sdk-coin-sol/src/lib/utils.ts b/modules/sdk-coin-sol/src/lib/utils.ts index 062e607a7e..f8ff013d5a 100644 --- a/modules/sdk-coin-sol/src/lib/utils.ts +++ b/modules/sdk-coin-sol/src/lib/utils.ts @@ -147,6 +147,29 @@ export function isValidMemo(memo: string): boolean { return Buffer.from(memo).length <= MAX_MEMO_LENGTH; } +/** + * Checks if a string is valid base64 encoded data + * @param str - The string to validate + * @returns True if the string is valid base64, false otherwise + */ +export function isValidBase64(str: string): boolean { + try { + const decoded = Buffer.from(str, 'base64').toString('base64'); + return decoded === str; + } catch { + return false; + } +} + +/** + * Checks if a string is valid hexadecimal data + * @param str - The string to validate + * @returns True if the string is valid hex, false otherwise + */ +export function isValidHex(str: string): boolean { + return /^[0-9A-Fa-f]*$/.test(str) && str.length % 2 === 0; +} + /** * Checks if raw transaction can be deserialized * diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 9f0f103e44..7ce44cb6bf 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -43,6 +43,8 @@ import { MultisigType, multisigTypes, AuditDecryptedKeyParams, + PopulatedIntent, + PrebuildTransactionWithIntentOptions, } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; @@ -1444,4 +1446,12 @@ export class Sol extends BaseCoin { } auditEddsaPrivateKey(prv, publicKey ?? ''); } + + /** @inheritDoc */ + setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void { + // Handle custom instructions for Solana + if (params.solInstructions) { + intent.solInstructions = params.solInstructions; + } + } } diff --git a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts index f9c8cf73a3..1adb43ce29 100644 --- a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts @@ -339,6 +339,98 @@ describe('Instruction Builder Tests: ', function () { ), ]); }); + + it('Custom instruction with TSS format', () => { + const tssInstruction = { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: 'CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA', + isSigner: true, + isWritable: true, + }, + ], + data: 'dGVzdCBpbnN0cnVjdGlvbiBkYXRh', // "test instruction data" in base64 + }; + + const customInstructionParams: InstructionParams = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + ...tssInstruction, + }, + }; + + const result = solInstructionFactory(customInstructionParams, COIN_CONFIG); + + result.should.have.length(1); + const resultInstruction = result[0]; + + resultInstruction.programId.toString().should.equal('11111111111111111111111111111112'); + resultInstruction.keys.should.have.length(1); + resultInstruction.keys[0].pubkey.toString().should.equal('CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA'); + resultInstruction.keys[0].isSigner.should.equal(true); + resultInstruction.keys[0].isWritable.should.equal(true); + resultInstruction.data.toString().should.equal('test instruction data'); + }); + + it('Custom instruction with TSS format - hex data', () => { + const tssInstruction = { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: 'CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA', + isSigner: false, + isWritable: false, + }, + ], + data: '74657374', // "test" in hex + }; + + const customInstructionParams: InstructionParams = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + ...tssInstruction, + }, + }; + + const result = solInstructionFactory(customInstructionParams, COIN_CONFIG); + + result.should.have.length(1); + const resultInstruction = result[0]; + + resultInstruction.programId.toString().should.equal('11111111111111111111111111111112'); + resultInstruction.keys.should.have.length(1); + resultInstruction.keys[0].pubkey.toString().should.equal('CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA'); + resultInstruction.keys[0].isSigner.should.equal(false); + resultInstruction.keys[0].isWritable.should.equal(false); + // Note: hex data will fall back to base64 then UTF-8, so it should contain the hex string + resultInstruction.data.should.be.instanceOf(Buffer); + }); + + it('Custom instruction with TSS format - UTF-8 fallback', () => { + const tssInstruction = { + programId: '11111111111111111111111111111112', + keys: [], + data: 'hello world', // plain text, should use UTF-8 encoding + }; + + const customInstructionParams: InstructionParams = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + ...tssInstruction, + }, + }; + + const result = solInstructionFactory(customInstructionParams, COIN_CONFIG); + + result.should.have.length(1); + const resultInstruction = result[0]; + + resultInstruction.programId.toString().should.equal('11111111111111111111111111111112'); + resultInstruction.keys.should.have.length(0); + // Since "hello world" is not valid base64, it should fall back to UTF-8 + resultInstruction.data.toString('utf8').should.equal('hello world'); + }); }); describe('Fail ', function () { @@ -348,5 +440,53 @@ describe('Instruction Builder Tests: ', function () { 'Invalid instruction type or not supported' ); }); + + it('Custom instruction - missing programId', () => { + const customInstructionParams = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + keys: [], + data: 'test', + }, + } as unknown as InstructionParams; + + should(() => solInstructionFactory(customInstructionParams, COIN_CONFIG)).throwError( + 'Missing programId in custom instruction' + ); + }); + + it('Custom instruction - missing keys', () => { + const customInstructionParams = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + programId: '11111111111111111111111111111112', + data: 'test', + }, + } as unknown as InstructionParams; + + should(() => solInstructionFactory(customInstructionParams, COIN_CONFIG)).throwError( + 'Missing or invalid keys in custom instruction' + ); + }); + + it('Custom instruction - missing data', () => { + const customInstructionParams = { + type: InstructionBuilderTypes.CustomInstruction, + params: { + programId: '11111111111111111111111111111112', + keys: [ + { + pubkey: 'CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA', + isSigner: true, + isWritable: true, + }, + ], + }, + } as unknown as InstructionParams; + + should(() => solInstructionFactory(customInstructionParams, COIN_CONFIG)).throwError( + 'Missing data in custom instruction' + ); + }); }); }); diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts index 2e02115c7f..2b09fa16d6 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts @@ -19,6 +19,17 @@ describe('Sol Custom Instruction Builder', () => { const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; const memo = 'test memo'; + // Helper function to convert TransactionInstruction to the expected format + const convertInstructionToParams = (instruction: TransactionInstruction) => ({ + programId: instruction.programId.toString(), + keys: instruction.keys.map((key) => ({ + pubkey: key.pubkey.toString(), + isSigner: key.isSigner, + isWritable: key.isWritable, + })), + data: instruction.data.toString('hex'), + }); + describe('Succeed', () => { it('build a transaction with a single custom instruction', async () => { const transferInstruction = SystemProgram.transfer({ @@ -28,21 +39,10 @@ describe('Sol Custom Instruction Builder', () => { }); const txBuilder = customInstructionBuilder(); - txBuilder.addCustomInstruction(transferInstruction); + txBuilder.addCustomInstruction(convertInstructionToParams(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', - }); + tx.inputs.length.should.equal(0); const rawTx = tx.toBroadcastFormat(); should.equal(Utils.isValidRawTransaction(rawTx), true); @@ -60,11 +60,14 @@ describe('Sol Custom Instruction Builder', () => { }); const txBuilder = customInstructionBuilder(); - txBuilder.addCustomInstructions([transferInstruction, priorityFeeInstruction]); + txBuilder.addCustomInstructions([ + convertInstructionToParams(transferInstruction), + convertInstructionToParams(priorityFeeInstruction), + ]); const tx = await txBuilder.build(); - tx.inputs.length.should.equal(1); - tx.outputs.length.should.equal(1); + tx.inputs.length.should.equal(0); + tx.outputs.length.should.equal(0); const rawTx = tx.toBroadcastFormat(); should.equal(Utils.isValidRawTransaction(rawTx), true); @@ -81,7 +84,7 @@ describe('Sol Custom Instruction Builder', () => { }); const txBuilder = customInstructionBuilder(); - txBuilder.addCustomInstruction(transferInstruction); + txBuilder.addCustomInstruction(convertInstructionToParams(transferInstruction)); txBuilder.memo(memo); const tx = await txBuilder.build(); @@ -100,7 +103,7 @@ describe('Sol Custom Instruction Builder', () => { }); const txBuilder = customInstructionBuilder(); - txBuilder.addCustomInstruction(transferInstruction); + txBuilder.addCustomInstruction(convertInstructionToParams(transferInstruction)); txBuilder.sign({ key: authAccount.prv }); const tx = await txBuilder.build(); @@ -119,7 +122,7 @@ describe('Sol Custom Instruction Builder', () => { }); const txBuilder = customInstructionBuilder(); - txBuilder.addCustomInstruction(transferInstruction); + txBuilder.addCustomInstruction(convertInstructionToParams(transferInstruction)); txBuilder.getInstructions().should.have.length(1); txBuilder.clearInstructions(); @@ -127,62 +130,71 @@ describe('Sol Custom Instruction Builder', () => { }); }); + // Type for testing invalid instruction formats + interface InvalidInstruction { + programId?: string; + keys?: unknown; + data?: unknown; + } + describe('Fail', () => { it('for null instruction', () => { const txBuilder = customInstructionBuilder(); - should(() => txBuilder.addCustomInstruction(null as unknown as TransactionInstruction)).throwError( - 'Instruction cannot be null or undefined' - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + should(() => txBuilder.addCustomInstruction(null as any)).throwError('Instruction cannot be null or undefined'); }); it('for undefined instruction', () => { const txBuilder = customInstructionBuilder(); - should(() => txBuilder.addCustomInstruction(undefined as unknown as TransactionInstruction)).throwError( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + should(() => txBuilder.addCustomInstruction(undefined as any)).throwError( 'Instruction cannot be null or undefined' ); }); it('for instruction without programId', () => { const txBuilder = customInstructionBuilder(); - const invalidInstruction = { + const invalidInstruction: InvalidInstruction = { keys: [], - data: Buffer.alloc(0), - } as unknown as TransactionInstruction; + data: '', + }; - should(() => txBuilder.addCustomInstruction(invalidInstruction)).throwError( - 'Instruction must have a valid programId' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + should(() => txBuilder.addCustomInstruction(invalidInstruction as any)).throwError( + 'Instruction must have a valid programId string' ); }); it('for instruction without keys', () => { const txBuilder = customInstructionBuilder(); - const invalidInstruction = { - programId: new PublicKey('11111111111111111111111111111112'), - data: Buffer.alloc(0), - } as TransactionInstruction; + const invalidInstruction: InvalidInstruction = { + programId: '11111111111111111111111111111112', + data: '', + }; - should(() => txBuilder.addCustomInstruction(invalidInstruction)).throwError( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + should(() => txBuilder.addCustomInstruction(invalidInstruction as any)).throwError( 'Instruction must have valid keys array' ); }); it('for instruction without data', () => { const txBuilder = customInstructionBuilder(); - const invalidInstruction = { - programId: new PublicKey('11111111111111111111111111111112'), + const invalidInstruction: InvalidInstruction = { + programId: '11111111111111111111111111111112', keys: [], - } as unknown as TransactionInstruction; + }; - should(() => txBuilder.addCustomInstruction(invalidInstruction)).throwError( - 'Instruction must have valid data buffer' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + should(() => txBuilder.addCustomInstruction(invalidInstruction as any)).throwError( + 'Instruction must have valid data string' ); }); it('for non-array in addCustomInstructions', () => { const txBuilder = customInstructionBuilder(); - should(() => txBuilder.addCustomInstructions('invalid' as unknown as TransactionInstruction[])).throwError( - 'Instructions must be an array' - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + should(() => txBuilder.addCustomInstructions('invalid' as any)).throwError('Instructions must be an array'); }); it('when building without instructions', async () => { @@ -199,7 +211,7 @@ describe('Sol Custom Instruction Builder', () => { const txBuilder = factory.getCustomInstructionBuilder(); txBuilder.nonce(recentBlockHash); - txBuilder.addCustomInstruction(transferInstruction); + txBuilder.addCustomInstruction(convertInstructionToParams(transferInstruction)); await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing sender'); }); @@ -213,7 +225,7 @@ describe('Sol Custom Instruction Builder', () => { const txBuilder = factory.getCustomInstructionBuilder(); txBuilder.sender(authAccount.pub); - txBuilder.addCustomInstruction(transferInstruction); + txBuilder.addCustomInstruction(convertInstructionToParams(transferInstruction)); await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing nonce blockhash'); }); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 40aca4de3f..b72b795fd8 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -66,6 +66,8 @@ export enum TransactionType { StakingDelegate, // Custom transaction (e.g. SUI) CustomTx, + // Custom Solana instructions + SolanaCustomInstructions, StakingRedelegate, AddPermissionlessDelegator, AddPermissionlessValidator, diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index adbbc8a422..5823455c78 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -117,7 +117,16 @@ export abstract class MpcUtils { populateIntent(baseCoin: IBaseCoin, params: PrebuildTransactionWithIntentOptions): PopulatedIntent { const chain = this.baseCoin.getChain(); - if (!['acceleration', 'fillNonce', 'transferToken', 'tokenApproval'].includes(params.intentType)) { + if (params.intentType === 'solInstruction') { + assert( + params.solInstructions && params.solInstructions.length > 0, + `'solInstructions' is a required parameter for solInstruction intent` + ); + } + + if ( + !['acceleration', 'fillNonce', 'transferToken', 'tokenApproval', 'customInstructions'].includes(params.intentType) + ) { assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`); } const intentRecipients = params.recipients?.map((recipient) => { diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 5f3c109aaf..eb60778221 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -204,6 +204,19 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase * This feature is supported only for specific coins, like ADA. */ senderAddress?: string; + /** + * Custom Solana instructions for use with the customInstructions intent type. + * Each instruction should contain programId, keys, and data fields. + */ + solInstructions?: { + programId: string; + keys: Array<{ + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }>; + data: string; + }[]; } export interface IntentRecipient { address: { @@ -257,6 +270,19 @@ export interface PopulatedIntent extends PopulatedIntentBase { custodianTransactionId?: string; custodianMessageId?: string; tokenName?: string; + /** + * Custom Solana instructions for use with the customInstructions intent type. + * Each instruction should contain programId, keys, and data fields. + */ + solInstructions?: { + programId: string; + keys: Array<{ + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }>; + data: string; + }[]; } export type TxRequestState = diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 67cd6c2a76..843fb47a96 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -104,6 +104,8 @@ export const BuildParams = t.exact( // param to set emergency flag on a custodial transaction. // This transaction should be performed in less than 1 hour or it will fail. emergency: t.unknown, + // Solana custom instructions for transaction building + solInstructions: t.unknown, }), ]) ); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 89f6462e3f..c9b055b1ef 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -167,6 +167,19 @@ export interface PrebuildTransactionOptions { * the legacy format defined by bitcoinjs-lib, or the 'psbt' format, which follows the BIP-174. */ txFormat?: 'legacy' | 'psbt'; + /** + * Custom Solana instructions to include in the transaction. + * Each instruction contains a program ID, accounts array, and data buffer. + */ + solInstructions?: { + programId: string; + keys: { + pubkey: string; + isSigner: boolean; + isWritable: boolean; + }[]; + data: string; + }[]; } export interface PrebuildAndSignTransactionOptions extends PrebuildTransactionOptions, WalletSignTransactionOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index aaf662528b..88bb51a527 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3361,6 +3361,18 @@ export class Wallet implements IWallet { params.preview ); break; + case 'solInstruction': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'solInstruction', + solInstructions: params.solInstructions, + recipients: params.recipients || [], + }, + apiVersion, + params.preview + ); + break; default: throw new Error(`transaction type not supported: ${params.type}`); }