Skip to content

Commit 7f1cdfd

Browse files
committed
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
1 parent 4774c05 commit 7f1cdfd

File tree

16 files changed

+1062
-97
lines changed

16 files changed

+1062
-97
lines changed

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 601 additions & 0 deletions
Large diffs are not rendered by default.

modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts

Lines changed: 75 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { BaseCoin as CoinConfig } from '@bitgo/statics';
22
import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core';
3-
import { TransactionInstruction } from '@solana/web3.js';
3+
import { PublicKey } from '@solana/web3.js';
44
import { Transaction } from './transaction';
55
import { TransactionBuilder } from './transactionBuilder';
66
import { InstructionBuilderTypes } from './constants';
77
import { CustomInstruction } from './iface';
88
import assert from 'assert';
99

10+
// Type alias for instruction parameters to make it cleaner
11+
type InstructionParams = {
12+
programId: string;
13+
keys: Array<{
14+
pubkey: string;
15+
isSigner: boolean;
16+
isWritable: boolean;
17+
}>;
18+
data: string;
19+
};
20+
1021
/**
1122
* Transaction builder for custom Solana instructions.
1223
* Allows building transactions with any set of raw Solana instructions.
@@ -19,7 +30,7 @@ export class CustomInstructionBuilder extends TransactionBuilder {
1930
}
2031

2132
protected get transactionType(): TransactionType {
22-
return TransactionType.Send;
33+
return TransactionType.SolanaCustomInstructions;
2334
}
2435

2536
/**
@@ -31,67 +42,44 @@ export class CustomInstructionBuilder extends TransactionBuilder {
3142
for (const instruction of this._instructionsData) {
3243
if (instruction.type === InstructionBuilderTypes.CustomInstruction) {
3344
const customInstruction = instruction as CustomInstruction;
34-
this.addCustomInstruction(customInstruction.params.instruction);
45+
this.addCustomInstruction(customInstruction.params);
3546
}
3647
}
3748
}
3849

3950
/**
40-
* Add a custom Solana instruction to the transaction
41-
*
42-
* @param instruction - The raw Solana TransactionInstruction
43-
* @returns This transaction builder
51+
* Add a custom instruction to the transaction
52+
* @param instruction - The custom instruction to add
53+
* @returns This builder instance
4454
*/
45-
addCustomInstruction(instruction: TransactionInstruction): this {
46-
if (!instruction) {
47-
throw new BuildTransactionError('Instruction cannot be null or undefined');
48-
}
49-
50-
if (!instruction.programId) {
51-
throw new BuildTransactionError('Instruction must have a valid programId');
52-
}
53-
54-
if (!instruction.keys || !Array.isArray(instruction.keys)) {
55-
throw new BuildTransactionError('Instruction must have valid keys array');
56-
}
57-
58-
if (!instruction.data || !Buffer.isBuffer(instruction.data)) {
59-
throw new BuildTransactionError('Instruction must have valid data buffer');
60-
}
61-
55+
addCustomInstruction(instruction: InstructionParams): this {
56+
this.validateInstruction(instruction);
6257
const customInstruction: CustomInstruction = {
6358
type: InstructionBuilderTypes.CustomInstruction,
64-
params: {
65-
instruction,
66-
},
59+
params: instruction,
6760
};
68-
6961
this._customInstructions.push(customInstruction);
7062
return this;
7163
}
7264

7365
/**
74-
* Add multiple custom Solana instructions to the transaction
75-
*
76-
* @param instructions - Array of raw Solana TransactionInstructions
77-
* @returns This transaction builder
66+
* Add multiple custom instructions to the transaction
67+
* @param instructions - Array of custom instructions to add
68+
* @returns This builder instance
7869
*/
79-
addCustomInstructions(instructions: TransactionInstruction[]): this {
70+
addCustomInstructions(instructions: InstructionParams[]): this {
8071
if (!Array.isArray(instructions)) {
8172
throw new BuildTransactionError('Instructions must be an array');
8273
}
83-
8474
for (const instruction of instructions) {
8575
this.addCustomInstruction(instruction);
8676
}
87-
8877
return this;
8978
}
9079

9180
/**
9281
* Clear all custom instructions
93-
*
94-
* @returns This transaction builder
82+
* @returns This builder instance
9583
*/
9684
clearInstructions(): this {
9785
this._customInstructions = [];
@@ -100,13 +88,62 @@ export class CustomInstructionBuilder extends TransactionBuilder {
10088

10189
/**
10290
* Get the current custom instructions
103-
*
10491
* @returns Array of custom instructions
10592
*/
10693
getInstructions(): CustomInstruction[] {
10794
return [...this._customInstructions];
10895
}
10996

97+
/**
98+
* Validate custom instruction format
99+
* @param instruction - The instruction to validate
100+
*/
101+
private validateInstruction(instruction: InstructionParams): void {
102+
if (!instruction) {
103+
throw new BuildTransactionError('Instruction cannot be null or undefined');
104+
}
105+
106+
if (!instruction.programId || typeof instruction.programId !== 'string') {
107+
throw new BuildTransactionError('Instruction must have a valid programId string');
108+
}
109+
110+
// Validate that programId is a valid Solana public key
111+
try {
112+
new PublicKey(instruction.programId);
113+
} catch (error) {
114+
throw new BuildTransactionError('Invalid programId format');
115+
}
116+
117+
if (!instruction.keys || !Array.isArray(instruction.keys)) {
118+
throw new BuildTransactionError('Instruction must have valid keys array');
119+
}
120+
121+
// Validate each key
122+
for (const key of instruction.keys) {
123+
if (!key.pubkey || typeof key.pubkey !== 'string') {
124+
throw new BuildTransactionError('Each key must have a valid pubkey string');
125+
}
126+
127+
try {
128+
new PublicKey(key.pubkey);
129+
} catch (error) {
130+
throw new BuildTransactionError('Invalid pubkey format in keys');
131+
}
132+
133+
if (typeof key.isSigner !== 'boolean') {
134+
throw new BuildTransactionError('Each key must have a boolean isSigner field');
135+
}
136+
137+
if (typeof key.isWritable !== 'boolean') {
138+
throw new BuildTransactionError('Each key must have a boolean isWritable field');
139+
}
140+
}
141+
142+
if (instruction.data === undefined || typeof instruction.data !== 'string') {
143+
throw new BuildTransactionError('Instruction must have valid data string');
144+
}
145+
}
146+
110147
/** @inheritdoc */
111148
protected async buildImplementation(): Promise<Transaction> {
112149
assert(this._customInstructions.length > 0, 'At least one custom instruction must be specified');

modules/sdk-coin-sol/src/lib/iface.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { TransactionExplanation as BaseTransactionExplanation, Recipient } from '@bitgo/sdk-core';
22
import { DecodedCloseAccountInstruction } from '@solana/spl-token';
3-
import {
4-
Blockhash,
5-
StakeInstructionType,
6-
SystemInstructionType,
7-
TransactionInstruction,
8-
TransactionSignature,
9-
} from '@solana/web3.js';
3+
import { Blockhash, StakeInstructionType, SystemInstructionType, TransactionSignature } from '@solana/web3.js';
104
import { InstructionBuilderTypes } from './constants';
115

126
// TODO(STLX-9890): Add the interfaces for validityWindow and SequenceId
@@ -211,7 +205,13 @@ export type StakingDelegateParams = {
211205
export interface CustomInstruction {
212206
type: InstructionBuilderTypes.CustomInstruction;
213207
params: {
214-
instruction: TransactionInstruction;
208+
programId: string;
209+
keys: Array<{
210+
pubkey: string;
211+
isSigner: boolean;
212+
isWritable: boolean;
213+
}>;
214+
data: string;
215215
};
216216
}
217217

modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
Transfer,
4646
WalletInit,
4747
SetPriorityFee,
48+
CustomInstruction,
4849
} from './iface';
4950
import { getInstructionType } from './utils';
5051

@@ -83,6 +84,8 @@ export function instructionParamsFactory(
8384
return parseStakingAuthorizeRawInstructions(instructions);
8485
case TransactionType.StakingDelegate:
8586
return parseStakingDelegateInstructions(instructions);
87+
case TransactionType.SolanaCustomInstructions:
88+
return parseCustomInstructions(instructions, instructionMetadata);
8689
default:
8790
throw new NotSupported('Invalid transaction, transaction type not supported: ' + type);
8891
}
@@ -918,6 +921,50 @@ function parseStakingAuthorizeRawInstructions(instructions: TransactionInstructi
918921
return instructionData;
919922
}
920923

924+
/**
925+
* Parses Solana instructions to custom instruction params
926+
*
927+
* @param {TransactionInstruction[]} instructions - containing custom solana instructions
928+
* @param {InstructionParams[]} instructionMetadata - the instruction metadata for the transaction
929+
* @returns {InstructionParams[]} An array containing instruction params for custom instructions
930+
*/
931+
function parseCustomInstructions(
932+
instructions: TransactionInstruction[],
933+
instructionMetadata?: InstructionParams[]
934+
): CustomInstruction[] {
935+
const instructionData: CustomInstruction[] = [];
936+
937+
for (let i = 0; i < instructions.length; i++) {
938+
const instruction = instructions[i];
939+
940+
// Check if we have metadata for this instruction position
941+
if (
942+
instructionMetadata &&
943+
instructionMetadata[i] &&
944+
instructionMetadata[i].type === InstructionBuilderTypes.CustomInstruction
945+
) {
946+
instructionData.push(instructionMetadata[i] as CustomInstruction);
947+
} else {
948+
// Convert the raw instruction to CustomInstruction format
949+
const customInstruction: CustomInstruction = {
950+
type: InstructionBuilderTypes.CustomInstruction,
951+
params: {
952+
programId: instruction.programId.toString(),
953+
keys: instruction.keys.map((key) => ({
954+
pubkey: key.pubkey.toString(),
955+
isSigner: key.isSigner,
956+
isWritable: key.isWritable,
957+
})),
958+
data: instruction.data.toString('base64'),
959+
},
960+
};
961+
instructionData.push(customInstruction);
962+
}
963+
}
964+
965+
return instructionData;
966+
}
967+
921968
function findTokenName(
922969
mintAddress: string,
923970
instructionMetadata?: InstructionParams[],

modules/sdk-coin-sol/src/lib/solInstructionFactory.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
SetPriorityFee,
4141
CustomInstruction,
4242
} from './iface';
43-
import { getSolTokenFromTokenName } from './utils';
43+
import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils';
4444

4545
/**
4646
* Construct Solana instructions from instructions params
@@ -551,15 +551,40 @@ function burnInstruction(data: Burn): TransactionInstruction[] {
551551
}
552552

553553
/**
554-
* Process custom instruction - simply returns the raw instruction
554+
* Process custom instruction - converts to TransactionInstruction
555+
* Handles conversion from string-based format to TransactionInstruction format
555556
*
556557
* @param {CustomInstruction} data - the data containing the custom instruction
557558
* @returns {TransactionInstruction[]} An array containing the custom instruction
558559
*/
559560
function customInstruction(data: InstructionParams): TransactionInstruction[] {
560-
const {
561-
params: { instruction },
562-
} = data as CustomInstruction;
563-
assert(instruction, 'Missing instruction param');
564-
return [instruction];
561+
const { params } = data as CustomInstruction;
562+
assert(params.programId, 'Missing programId in custom instruction');
563+
assert(params.keys && Array.isArray(params.keys), 'Missing or invalid keys in custom instruction');
564+
assert(params.data !== undefined, 'Missing data in custom instruction');
565+
566+
// Convert string data to Buffer
567+
let dataBuffer: Buffer;
568+
569+
if (isValidBase64(params.data)) {
570+
dataBuffer = Buffer.from(params.data, 'base64');
571+
} else if (isValidHex(params.data)) {
572+
dataBuffer = Buffer.from(params.data, 'hex');
573+
} else {
574+
// Fallback to UTF-8
575+
dataBuffer = Buffer.from(params.data, 'utf8');
576+
}
577+
578+
// Create a new TransactionInstruction with the converted data
579+
const convertedInstruction = new TransactionInstruction({
580+
programId: new PublicKey(params.programId),
581+
keys: params.keys.map((key) => ({
582+
pubkey: new PublicKey(key.pubkey),
583+
isSigner: key.isSigner,
584+
isWritable: key.isWritable,
585+
})),
586+
data: dataBuffer,
587+
});
588+
589+
return [convertedInstruction];
565590
}

modules/sdk-coin-sol/src/lib/transaction.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ export class Transaction extends BaseTransaction {
235235
case TransactionType.StakingDelegate:
236236
this.setTransactionType(TransactionType.StakingDelegate);
237237
break;
238+
case TransactionType.SolanaCustomInstructions:
239+
this.setTransactionType(TransactionType.SolanaCustomInstructions);
240+
break;
238241
}
239242
if (transactionType !== TransactionType.StakingAuthorizeRaw) {
240243
this.loadInputsAndOutputs();
@@ -398,6 +401,8 @@ export class Transaction extends BaseTransaction {
398401
break;
399402
case InstructionBuilderTypes.SetPriorityFee:
400403
break;
404+
case InstructionBuilderTypes.CustomInstruction:
405+
break;
401406
}
402407
}
403408
this._outputs = outputs;
@@ -473,6 +478,9 @@ export class Transaction extends BaseTransaction {
473478
break;
474479
case InstructionBuilderTypes.CreateAssociatedTokenAccount:
475480
break;
481+
case InstructionBuilderTypes.CustomInstruction:
482+
// Custom instructions are arbitrary and cannot be explained
483+
break;
476484
default:
477485
continue;
478486
}

modules/sdk-coin-sol/src/lib/utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,29 @@ export function isValidMemo(memo: string): boolean {
144144
return Buffer.from(memo).length <= MAX_MEMO_LENGTH;
145145
}
146146

147+
/**
148+
* Checks if a string is valid base64 encoded data
149+
* @param str - The string to validate
150+
* @returns True if the string is valid base64, false otherwise
151+
*/
152+
export function isValidBase64(str: string): boolean {
153+
try {
154+
const decoded = Buffer.from(str, 'base64').toString('base64');
155+
return decoded === str;
156+
} catch {
157+
return false;
158+
}
159+
}
160+
161+
/**
162+
* Checks if a string is valid hexadecimal data
163+
* @param str - The string to validate
164+
* @returns True if the string is valid hex, false otherwise
165+
*/
166+
export function isValidHex(str: string): boolean {
167+
return /^[0-9A-Fa-f]*$/.test(str) && str.length % 2 === 0;
168+
}
169+
147170
/**
148171
* Checks if raw transaction can be deserialized
149172
*

0 commit comments

Comments
 (0)