Skip to content

Commit 05827d5

Browse files
authored
Merge pull request #6606 from BitGo/TMS-1218-custom-sol-intent
feat: added custom tx builder for sol
2 parents d5a4e64 + 6139616 commit 05827d5

File tree

15 files changed

+1039
-102
lines changed

15 files changed

+1039
-102
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: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2-
import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core';
3-
import { TransactionInstruction } from '@solana/web3.js';
2+
import { BuildTransactionError, SolInstruction, TransactionType } from '@bitgo/sdk-core';
3+
import { PublicKey } from '@solana/web3.js';
44
import { Transaction } from './transaction';
55
import { TransactionBuilder } from './transactionBuilder';
66
import { InstructionBuilderTypes } from './constants';
@@ -19,7 +19,7 @@ export class CustomInstructionBuilder extends TransactionBuilder {
1919
}
2020

2121
protected get transactionType(): TransactionType {
22-
return TransactionType.Send;
22+
return TransactionType.CustomTx;
2323
}
2424

2525
/**
@@ -31,67 +31,44 @@ export class CustomInstructionBuilder extends TransactionBuilder {
3131
for (const instruction of this._instructionsData) {
3232
if (instruction.type === InstructionBuilderTypes.CustomInstruction) {
3333
const customInstruction = instruction as CustomInstruction;
34-
this.addCustomInstruction(customInstruction.params.instruction);
34+
this.addCustomInstruction(customInstruction.params);
3535
}
3636
}
3737
}
3838

3939
/**
40-
* Add a custom Solana instruction to the transaction
41-
*
42-
* @param instruction - The raw Solana TransactionInstruction
43-
* @returns This transaction builder
40+
* Add a custom instruction to the transaction
41+
* @param instruction - The custom instruction to add
42+
* @returns This builder instance
4443
*/
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-
44+
addCustomInstruction(instruction: SolInstruction): this {
45+
this.validateInstruction(instruction);
6246
const customInstruction: CustomInstruction = {
6347
type: InstructionBuilderTypes.CustomInstruction,
64-
params: {
65-
instruction,
66-
},
48+
params: instruction,
6749
};
68-
6950
this._customInstructions.push(customInstruction);
7051
return this;
7152
}
7253

7354
/**
74-
* Add multiple custom Solana instructions to the transaction
75-
*
76-
* @param instructions - Array of raw Solana TransactionInstructions
77-
* @returns This transaction builder
55+
* Add multiple custom instructions to the transaction
56+
* @param instructions - Array of custom instructions to add
57+
* @returns This builder instance
7858
*/
79-
addCustomInstructions(instructions: TransactionInstruction[]): this {
59+
addCustomInstructions(instructions: SolInstruction[]): this {
8060
if (!Array.isArray(instructions)) {
8161
throw new BuildTransactionError('Instructions must be an array');
8262
}
83-
8463
for (const instruction of instructions) {
8564
this.addCustomInstruction(instruction);
8665
}
87-
8866
return this;
8967
}
9068

9169
/**
9270
* Clear all custom instructions
93-
*
94-
* @returns This transaction builder
71+
* @returns This builder instance
9572
*/
9673
clearInstructions(): this {
9774
this._customInstructions = [];
@@ -100,13 +77,62 @@ export class CustomInstructionBuilder extends TransactionBuilder {
10077

10178
/**
10279
* Get the current custom instructions
103-
*
10480
* @returns Array of custom instructions
10581
*/
10682
getInstructions(): CustomInstruction[] {
10783
return [...this._customInstructions];
10884
}
10985

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

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

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
1-
import { TransactionExplanation as BaseTransactionExplanation, Recipient } from '@bitgo/sdk-core';
1+
import { TransactionExplanation as BaseTransactionExplanation, Recipient, SolInstruction } 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
import { StakePoolInstructionType } from '@solana/spl-stake-pool';
126

@@ -15,7 +9,6 @@ export interface SolanaKeys {
159
prv?: Uint8Array | string;
1610
pub: string;
1711
}
18-
1912
export interface DurableNonceParams {
2013
walletNonceAddress: string;
2114
authWalletAddress: string;
@@ -213,9 +206,7 @@ export type StakingDelegateParams = {
213206

214207
export interface CustomInstruction {
215208
type: InstructionBuilderTypes.CustomInstruction;
216-
params: {
217-
instruction: TransactionInstruction;
218-
};
209+
params: SolInstruction;
219210
}
220211

221212
export interface TransactionExplanation extends BaseTransactionExplanation {

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
Transfer,
5151
WalletInit,
5252
SetPriorityFee,
53+
CustomInstruction,
5354
} from './iface';
5455
import { getInstructionType } from './utils';
5556
import { DepositSolParams } from '@solana/spl-stake-pool';
@@ -90,6 +91,8 @@ export function instructionParamsFactory(
9091
return parseStakingAuthorizeRawInstructions(instructions);
9192
case TransactionType.StakingDelegate:
9293
return parseStakingDelegateInstructions(instructions);
94+
case TransactionType.CustomTx:
95+
return parseCustomInstructions(instructions, instructionMetadata);
9396
default:
9497
throw new NotSupported('Invalid transaction, transaction type not supported: ' + type);
9598
}
@@ -964,6 +967,50 @@ function parseStakingAuthorizeRawInstructions(instructions: TransactionInstructi
964967
return instructionData;
965968
}
966969

970+
/**
971+
* Parses Solana instructions to custom instruction params
972+
*
973+
* @param {TransactionInstruction[]} instructions - containing custom solana instructions
974+
* @param {InstructionParams[]} instructionMetadata - the instruction metadata for the transaction
975+
* @returns {InstructionParams[]} An array containing instruction params for custom instructions
976+
*/
977+
function parseCustomInstructions(
978+
instructions: TransactionInstruction[],
979+
instructionMetadata?: InstructionParams[]
980+
): CustomInstruction[] {
981+
const instructionData: CustomInstruction[] = [];
982+
983+
for (let i = 0; i < instructions.length; i++) {
984+
const instruction = instructions[i];
985+
986+
// Check if we have metadata for this instruction position
987+
if (
988+
instructionMetadata &&
989+
instructionMetadata[i] &&
990+
instructionMetadata[i].type === InstructionBuilderTypes.CustomInstruction
991+
) {
992+
instructionData.push(instructionMetadata[i] as CustomInstruction);
993+
} else {
994+
// Convert the raw instruction to CustomInstruction format
995+
const customInstruction: CustomInstruction = {
996+
type: InstructionBuilderTypes.CustomInstruction,
997+
params: {
998+
programId: instruction.programId.toString(),
999+
keys: instruction.keys.map((key) => ({
1000+
pubkey: key.pubkey.toString(),
1001+
isSigner: key.isSigner,
1002+
isWritable: key.isWritable,
1003+
})),
1004+
data: instruction.data.toString('base64'),
1005+
},
1006+
};
1007+
instructionData.push(customInstruction);
1008+
}
1009+
}
1010+
1011+
return instructionData;
1012+
}
1013+
9671014
function findTokenName(
9681015
mintAddress: string,
9691016
instructionMetadata?: InstructionParams[],

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import {
4848
SetPriorityFee,
4949
CustomInstruction,
5050
} from './iface';
51-
import { getSolTokenFromTokenName } from './utils';
51+
import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils';
5252
import { depositSolInstructions } from './jitoStakePoolOperations';
5353

5454
/**
@@ -593,15 +593,40 @@ function burnInstruction(data: Burn): TransactionInstruction[] {
593593
}
594594

595595
/**
596-
* Process custom instruction - simply returns the raw instruction
596+
* Process custom instruction - converts to TransactionInstruction
597+
* Handles conversion from string-based format to TransactionInstruction format
597598
*
598599
* @param {CustomInstruction} data - the data containing the custom instruction
599600
* @returns {TransactionInstruction[]} An array containing the custom instruction
600601
*/
601602
function customInstruction(data: InstructionParams): TransactionInstruction[] {
602-
const {
603-
params: { instruction },
604-
} = data as CustomInstruction;
605-
assert(instruction, 'Missing instruction param');
606-
return [instruction];
603+
const { params } = data as CustomInstruction;
604+
assert(params.programId, 'Missing programId in custom instruction');
605+
assert(params.keys && Array.isArray(params.keys), 'Missing or invalid keys in custom instruction');
606+
assert(params.data !== undefined, 'Missing data in custom instruction');
607+
608+
// Convert string data to Buffer
609+
let dataBuffer: Buffer;
610+
611+
if (isValidBase64(params.data)) {
612+
dataBuffer = Buffer.from(params.data, 'base64');
613+
} else if (isValidHex(params.data)) {
614+
dataBuffer = Buffer.from(params.data, 'hex');
615+
} else {
616+
// Fallback to UTF-8
617+
dataBuffer = Buffer.from(params.data, 'utf8');
618+
}
619+
620+
// Create a new TransactionInstruction with the converted data
621+
const convertedInstruction = new TransactionInstruction({
622+
programId: new PublicKey(params.programId),
623+
keys: params.keys.map((key) => ({
624+
pubkey: new PublicKey(key.pubkey),
625+
isSigner: key.isSigner,
626+
isWritable: key.isWritable,
627+
})),
628+
data: dataBuffer,
629+
});
630+
631+
return [convertedInstruction];
607632
}

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.CustomTx:
239+
this.setTransactionType(TransactionType.CustomTx);
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
@@ -147,6 +147,29 @@ export function isValidMemo(memo: string): boolean {
147147
return Buffer.from(memo).length <= MAX_MEMO_LENGTH;
148148
}
149149

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

0 commit comments

Comments
 (0)