Skip to content

feat: added custom tx builder for sol #6606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
601 changes: 601 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts

Large diffs are not rendered by default.

113 changes: 75 additions & 38 deletions modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -19,7 +30,7 @@ export class CustomInstructionBuilder extends TransactionBuilder {
}

protected get transactionType(): TransactionType {
return TransactionType.Send;
return TransactionType.SolanaCustomInstructions;
}

/**
Expand All @@ -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 = [];
Expand All @@ -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<Transaction> {
assert(this._customInstructions.length > 0, 'At least one custom instruction must be specified');
Expand Down
16 changes: 8 additions & 8 deletions modules/sdk-coin-sol/src/lib/iface.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
};
}

Expand Down
47 changes: 47 additions & 0 deletions modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
Transfer,
WalletInit,
SetPriorityFee,
CustomInstruction,
} from './iface';
import { getInstructionType } from './utils';
import { DepositSolParams } from '@solana/spl-stake-pool';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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[],
Expand Down
39 changes: 32 additions & 7 deletions modules/sdk-coin-sol/src/lib/solInstructionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import {
SetPriorityFee,
CustomInstruction,
} from './iface';
import { getSolTokenFromTokenName } from './utils';
import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils';
import { depositSolInstructions } from './jitoStakePoolOperations';

/**
Expand Down Expand Up @@ -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');
Comment on lines +607 to +617
Copy link
Preview

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data conversion logic tries base64 first, then hex, then UTF-8. However, some valid hex strings might also be valid base64, leading to incorrect interpretation. Consider adding explicit format indicators or more strict validation.

Suggested change
// 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');
assert(params.dataFormat === 'base64' || params.dataFormat === 'hex' || params.dataFormat === 'utf8', 'Missing or invalid dataFormat in custom instruction');
// Convert string data to Buffer using explicit format
let dataBuffer: Buffer;
switch (params.dataFormat) {
case 'base64':
assert(isValidBase64(params.data), 'data is not valid base64');
dataBuffer = Buffer.from(params.data, 'base64');
break;
case 'hex':
assert(isValidHex(params.data), 'data is not valid hex');
dataBuffer = Buffer.from(params.data, 'hex');
break;
case 'utf8':
dataBuffer = Buffer.from(params.data, 'utf8');
break;
default:
throw new Error('Unsupported dataFormat in custom instruction');

Copilot uses AI. Check for mistakes.

}

// 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];
}
8 changes: 8 additions & 0 deletions modules/sdk-coin-sol/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -398,6 +401,8 @@ export class Transaction extends BaseTransaction {
break;
case InstructionBuilderTypes.SetPriorityFee:
break;
case InstructionBuilderTypes.CustomInstruction:
break;
}
}
this._outputs = outputs;
Expand Down Expand Up @@ -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;
}
Expand Down
23 changes: 23 additions & 0 deletions modules/sdk-coin-sol/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Preview

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex allows empty strings to be considered valid hex, but an empty string has length 0 which satisfies the even length condition. This could lead to unexpected behavior when processing empty data strings.

Suggested change
return /^[0-9A-Fa-f]*$/.test(str) && str.length % 2 === 0;
return str.length > 0 && /^[0-9A-Fa-f]*$/.test(str) && str.length % 2 === 0;

Copilot uses AI. Check for mistakes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

/**
* Checks if raw transaction can be deserialized
*
Expand Down
Loading