diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 0ac7d0485b..c4a67372ae 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -26,7 +26,7 @@ "webpack-dev": "cross-env NODE_ENV=development webpack", "webpack-prod": "NODE_OPTIONS=--max-old-space-size=4096 cross-env NODE_ENV=production webpack", "test": "npm run coverage", - "unit-test": "mocha 'test/v2/unit/**/*.ts' 'test/unit/**/*.ts'", + "unit-test": "NODE_OPTIONS=--max-old-space-size=8192 mocha 'test/v2/unit/**/*.ts' 'test/unit/**/*.ts'", "coverage": "nyc -- npm run unit-test", "integration-test": "nyc -- mocha \"test/v2/integration/**/*.ts\"", "browser-test": "karma start karma.conf.js", diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 8cb194d00a..91e8b6f618 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -5672,7 +5672,7 @@ describe('V2 Wallet:', function () { solInstructions: [], recipients: testRecipients, }) - .should.be.rejectedWith(`'solInstructions' is a required parameter for customTx intent`); + .should.be.rejectedWith(`'solInstructions' or 'solVersionedTransactionData' is required for customTx intent`); }); it('should support solInstruction for cold wallets', async function () { @@ -5710,6 +5710,209 @@ describe('V2 Wallet:', function () { }); }); }); + + describe('prebuildTransaction with solVersionedTransactionData type', function () { + function getTestVersionedTransactionData() { + return { + versionedInstructions: [ + { + programIdIndex: 10, + accountKeyIndexes: [0, 1, 2], + data: '0102030405', + }, + { + programIdIndex: 11, + accountKeyIndexes: [0, 3, 4, 5], + data: '060708090a', + }, + ], + addressLookupTables: [ + { + accountKey: '2sk6bVhjN53hz7sqE72eqHvhPfSc1snZzsJR6yA5hF7j', + writableIndexes: [0, 1], + readonlyIndexes: [2, 3], + }, + ], + staticAccountKeys: [ + '5hr5fisPi6DXNuuRpm5XUbzpiEnmdyxXuBDTwzwZj5Pe', + '2sk6bVhjN53hz7sqE72eqHvhPfSc1snZzsJR6yA5hF7j', + '11111111111111111111111111111111', + ], + messageHeader: { + numRequiredSignatures: 1, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + }, + }; + } + + it('should call prebuildTxWithIntent with correct parameters for versioned transaction', async function () { + const testVersionedTransactionData = getTestVersionedTransactionData(); + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle solVersionedTransactionData with empty recipients', async function () { + const testVersionedTransactionData = getTestVersionedTransactionData(); + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: [], + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle solVersionedTransactionData with memo parameter', async function () { + const testVersionedTransactionData = getTestVersionedTransactionData(); + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequest); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + memo: { + type: 'type', + value: 'test memo', + }, + }); + + const txPrebuild = await tssSolWallet.prebuildTransaction({ + reqId, + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + memo: { + type: 'type', + value: 'test memo', + }, + }); + + txPrebuild.should.deepEqual({ + walletId: tssSolWallet.id(), + wallet: tssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + buildParams: { + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + memo: { + type: 'type', + value: 'test memo', + }, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should handle solVersionedTransactionData with pending approval ID', async function () { + const testVersionedTransactionData = getTestVersionedTransactionData(); + const prebuildTxWithIntent = sandbox.stub(TssUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves({ ...txRequest, state: 'pendingApproval', pendingApprovalId: 'some-id' }); + prebuildTxWithIntent.calledOnceWithExactly({ + reqId, + intentType: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + }); + + const txPrebuild = await custodialTssSolWallet.prebuildTransaction({ + reqId, + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + }); + + txPrebuild.should.deepEqual({ + walletId: custodialTssSolWallet.id(), + wallet: custodialTssSolWallet, + txRequestId: 'id', + txHex: 'ababcdcd', + pendingApprovalId: 'some-id', + buildParams: { + type: 'customTx', + solVersionedTransactionData: testVersionedTransactionData, + recipients: testRecipients, + }, + feeInfo: { + fee: 5000, + feeString: '5000', + }, + }); + }); + + it('should throw error for empty versionedInstructions array', async function () { + await tssSolWallet + .prebuildTransaction({ + reqId, + type: 'customTx', + solVersionedTransactionData: { + versionedInstructions: [], + addressLookupTables: [], + staticAccountKeys: ['test'], + messageHeader: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 }, + }, + recipients: testRecipients, + }) + .should.be.rejectedWith(`'solInstructions' or 'solVersionedTransactionData' is required for customTx intent`); + }); + }); }); describe('Aptos Custom transaction Flow', function () { diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index 88e2d68a87..8c0d212d2f 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -61,6 +61,7 @@ export enum InstructionBuilderTypes { MintTo = 'MintTo', Burn = 'Burn', CustomInstruction = 'CustomInstruction', + VersionedCustomInstruction = 'VersionedCustomInstruction', Approve = 'Approve', WithdrawStake = 'WithdrawStake', } diff --git a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts index b0b6a1f353..170dc05abe 100644 --- a/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/customInstructionBuilder.ts @@ -1,10 +1,11 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { BuildTransactionError, SolInstruction, TransactionType } from '@bitgo/sdk-core'; +import { BuildTransactionError, SolInstruction, SolVersionedInstruction, TransactionType } from '@bitgo/sdk-core'; import { PublicKey } from '@solana/web3.js'; import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; import { InstructionBuilderTypes } from './constants'; -import { CustomInstruction } from './iface'; +import { CustomInstruction, VersionedCustomInstruction, VersionedTransactionData } from './iface'; +import { isSolLegacyInstruction } from './utils'; import assert from 'assert'; /** @@ -12,7 +13,7 @@ import assert from 'assert'; * Allows building transactions with any set of raw Solana instructions. */ export class CustomInstructionBuilder extends TransactionBuilder { - private _customInstructions: CustomInstruction[] = []; + private _customInstructions: (CustomInstruction | VersionedCustomInstruction)[] = []; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -32,6 +33,9 @@ export class CustomInstructionBuilder extends TransactionBuilder { if (instruction.type === InstructionBuilderTypes.CustomInstruction) { const customInstruction = instruction as CustomInstruction; this.addCustomInstruction(customInstruction.params); + } else if (instruction.type === InstructionBuilderTypes.VersionedCustomInstruction) { + const versionedCustomInstruction = instruction as VersionedCustomInstruction; + this.addCustomInstruction(versionedCustomInstruction.params); } } } @@ -41,13 +45,23 @@ export class CustomInstructionBuilder extends TransactionBuilder { * @param instruction - The custom instruction to add * @returns This builder instance */ - addCustomInstruction(instruction: SolInstruction): this { + addCustomInstruction(instruction: SolInstruction | SolVersionedInstruction): this { this.validateInstruction(instruction); - const customInstruction: CustomInstruction = { - type: InstructionBuilderTypes.CustomInstruction, - params: instruction, - }; - this._customInstructions.push(customInstruction); + + if (isSolLegacyInstruction(instruction)) { + const customInstruction: CustomInstruction = { + type: InstructionBuilderTypes.CustomInstruction, + params: instruction, + }; + this._customInstructions.push(customInstruction); + } else { + const versionedCustomInstruction: VersionedCustomInstruction = { + type: InstructionBuilderTypes.VersionedCustomInstruction, + params: instruction, + }; + this._customInstructions.push(versionedCustomInstruction); + } + return this; } @@ -56,7 +70,7 @@ export class CustomInstructionBuilder extends TransactionBuilder { * @param instructions - Array of custom instructions to add * @returns This builder instance */ - addCustomInstructions(instructions: SolInstruction[]): this { + addCustomInstructions(instructions: (SolInstruction | SolVersionedInstruction)[]): this { if (!Array.isArray(instructions)) { throw new BuildTransactionError('Instructions must be an array'); } @@ -67,11 +81,59 @@ export class CustomInstructionBuilder extends TransactionBuilder { } /** - * Clear all custom instructions + * Build transaction from deconstructed VersionedTransaction data + * @param data - VersionedTransactionData containing instructions, ALTs, and account keys + * @returns This builder instance + */ + fromVersionedTransactionData(data: VersionedTransactionData): this { + try { + if (!data || typeof data !== 'object') { + throw new BuildTransactionError('VersionedTransactionData must be a valid object'); + } + + if (!Array.isArray(data.versionedInstructions) || data.versionedInstructions.length === 0) { + throw new BuildTransactionError('versionedInstructions must be a non-empty array'); + } + + if (!Array.isArray(data.addressLookupTables)) { + throw new BuildTransactionError('addressLookupTables must be an array'); + } + + if (!Array.isArray(data.staticAccountKeys) || data.staticAccountKeys.length === 0) { + throw new BuildTransactionError('staticAccountKeys must be a non-empty array'); + } + + this.addCustomInstructions(data.versionedInstructions); + + if (!this._transaction) { + this._transaction = new Transaction(this._coinConfig); + } + this._transaction.setVersionedTransactionData(data); + + this._transaction.setTransactionType(TransactionType.CustomTx); + + if (!this._sender && data.staticAccountKeys.length > 0) { + this._sender = data.staticAccountKeys[0]; + } + + return this; + } catch (error) { + if (error instanceof BuildTransactionError) { + throw error; + } + throw new BuildTransactionError(`Failed to process versioned transaction data: ${error.message}`); + } + } + + /** + * Clear all custom instructions and versioned transaction data * @returns This builder instance */ clearInstructions(): this { this._customInstructions = []; + if (this._transaction) { + this._transaction.setVersionedTransactionData(undefined); + } return this; } @@ -79,7 +141,7 @@ export class CustomInstructionBuilder extends TransactionBuilder { * Get the current custom instructions * @returns Array of custom instructions */ - getInstructions(): CustomInstruction[] { + getInstructions(): (CustomInstruction | VersionedCustomInstruction)[] { return [...this._customInstructions]; } @@ -87,11 +149,23 @@ export class CustomInstructionBuilder extends TransactionBuilder { * Validate custom instruction format * @param instruction - The instruction to validate */ - private validateInstruction(instruction: SolInstruction): void { + private validateInstruction(instruction: SolInstruction | SolVersionedInstruction): void { if (!instruction) { throw new BuildTransactionError('Instruction cannot be null or undefined'); } + if (isSolLegacyInstruction(instruction)) { + this.validateSolInstruction(instruction); + } else { + this.validateVersionedInstruction(instruction); + } + } + + /** + * Validate traditional SolInstruction format + * @param instruction - The traditional instruction to validate + */ + private validateSolInstruction(instruction: SolInstruction): void { if (!instruction.programId || typeof instruction.programId !== 'string') { throw new BuildTransactionError('Instruction must have a valid programId string'); } @@ -133,6 +207,31 @@ export class CustomInstructionBuilder extends TransactionBuilder { } } + /** + * Validate versioned instruction format + * @param instruction - The versioned instruction to validate + */ + private validateVersionedInstruction(instruction: SolVersionedInstruction): void { + if (typeof instruction.programIdIndex !== 'number' || instruction.programIdIndex < 0) { + throw new BuildTransactionError('Versioned instruction must have a valid programIdIndex number'); + } + + if (!instruction.accountKeyIndexes || !Array.isArray(instruction.accountKeyIndexes)) { + throw new BuildTransactionError('Versioned instruction must have valid accountKeyIndexes array'); + } + + // Validate each account key index + for (const index of instruction.accountKeyIndexes) { + if (typeof index !== 'number' || index < 0) { + throw new BuildTransactionError('Each accountKeyIndex must be a non-negative number'); + } + } + + if (instruction.data === undefined || typeof instruction.data !== 'string') { + throw new BuildTransactionError('Versioned 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 e48429810e..bfe8929686 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -1,5 +1,10 @@ import { SolStakingTypeEnum } from '@bitgo/public-types'; -import { TransactionExplanation as BaseTransactionExplanation, Recipient, SolInstruction } from '@bitgo/sdk-core'; +import { + TransactionExplanation as BaseTransactionExplanation, + Recipient, + SolInstruction, + SolVersionedInstruction, +} from '@bitgo/sdk-core'; import { DecodedCloseAccountInstruction } from '@solana/spl-token'; import { Blockhash, StakeInstructionType, SystemInstructionType, TransactionSignature } from '@solana/web3.js'; import { InstructionBuilderTypes } from './constants'; @@ -44,7 +49,8 @@ export type InstructionParams = | MintTo | Burn | Approve - | CustomInstruction; + | CustomInstruction + | VersionedCustomInstruction; export interface Memo { type: InstructionBuilderTypes.Memo; @@ -241,6 +247,28 @@ export interface CustomInstruction { params: SolInstruction; } +export interface VersionedCustomInstruction { + type: InstructionBuilderTypes.VersionedCustomInstruction; + params: SolVersionedInstruction; +} + +export interface VersionedTransactionData { + versionedInstructions: SolVersionedInstruction[]; + addressLookupTables: AddressLookupTable[]; + staticAccountKeys: string[]; + messageHeader: { + numRequiredSignatures: number; + numReadonlySignedAccounts: number; + numReadonlyUnsignedAccounts: number; + }; +} + +export interface AddressLookupTable { + accountKey: string; + writableIndexes: number[]; + readonlyIndexes: number[]; +} + export interface TransactionExplanation extends BaseTransactionExplanation { type: string; blockhash: Blockhash; diff --git a/modules/sdk-coin-sol/src/lib/transaction.ts b/modules/sdk-coin-sol/src/lib/transaction.ts index f5ba4bc969..7aac445fd5 100644 --- a/modules/sdk-coin-sol/src/lib/transaction.ts +++ b/modules/sdk-coin-sol/src/lib/transaction.ts @@ -9,7 +9,14 @@ import { TransactionType, } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { Blockhash, PublicKey, Signer, Transaction as SolTransaction, SystemInstruction } from '@solana/web3.js'; +import { + Blockhash, + PublicKey, + Signer, + Transaction as SolTransaction, + SystemInstruction, + VersionedTransaction, +} from '@solana/web3.js'; import BigNumber from 'bignumber.js'; import base58 from 'bs58'; import { KeyPair } from '.'; @@ -32,6 +39,7 @@ import { TransactionExplanation, Transfer, TxData, + VersionedTransactionData, WalletInit, } from './iface'; import { instructionParamsFactory } from './instructionParamsFactory'; @@ -51,6 +59,8 @@ export class Transaction extends BaseTransaction { protected _type: TransactionType; protected _instructionsData: InstructionParams[] = []; private _useTokenAddressTokenName = false; + private _versionedTransaction: VersionedTransaction | undefined; + private _versionedTransactionData: VersionedTransactionData | undefined; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -76,17 +86,28 @@ export class Transaction extends BaseTransaction { /** @inheritDoc */ get signablePayload(): Buffer { + if (this._versionedTransaction) { + return Buffer.from(this._versionedTransaction.message.serialize()); + } return this._solTransaction.serializeMessage(); } /** @inheritDoc **/ get id(): string { // Solana transaction ID === first signature: https://docs.solana.com/terminology#transaction-id + if (this._versionedTransaction) { + const sig = this._versionedTransaction.signatures?.[0]; + // Check if signature exists and is not a placeholder signature (all zeros) + if (sig && sig.some((byte) => byte !== 0)) { + return base58.encode(sig); + } + } + if (this._solTransaction.signature) { return base58.encode(this._solTransaction.signature); - } else { - return UNAVAILABLE_TEXT; } + + return UNAVAILABLE_TEXT; } get lamportsPerSignature(): number | undefined { @@ -109,9 +130,20 @@ export class Transaction extends BaseTransaction { get signature(): string[] { const signatures: string[] = []; - for (const solSignature of this._solTransaction.signatures) { - if (solSignature.signature) { - signatures.push(base58.encode(solSignature.signature)); + if (this._versionedTransaction) { + // Handle VersionedTransaction signatures + for (const sig of this._versionedTransaction.signatures) { + // Filters out placeholder signatures + if (sig && sig.some((b) => b !== 0)) { + signatures.push(base58.encode(sig)); + } + } + } else { + // Handle legacy transaction signatures + for (const solSignature of this._solTransaction.signatures) { + if (solSignature.signature) { + signatures.push(base58.encode(solSignature.signature)); + } } } @@ -143,6 +175,47 @@ export class Transaction extends BaseTransaction { setUseTokenAddressTokenName(value: boolean): void { this._useTokenAddressTokenName = value; } + + /** + * Check if this transaction is a VersionedTransaction + * @returns {boolean} True if this is a VersionedTransaction + */ + isVersionedTransaction(): boolean { + return !!this._versionedTransaction || !!this._versionedTransactionData; + } + + /** + * Get the original VersionedTransaction if this transaction was parsed from one + * @returns {VersionedTransaction | undefined} The VersionedTransaction or undefined + */ + get versionedTransaction(): VersionedTransaction | undefined { + return this._versionedTransaction; + } + + /** + * Set a built VersionedTransaction + * @param {VersionedTransaction} versionedTx The VersionedTransaction to set + */ + set versionedTransaction(versionedTx: VersionedTransaction | undefined) { + this._versionedTransaction = versionedTx; + } + + /** + * Get the stored VersionedTransactionData + * @returns {VersionedTransactionData | undefined} The stored data or undefined + */ + getVersionedTransactionData(): VersionedTransactionData | undefined { + return this._versionedTransactionData; + } + + /** + * Set the VersionedTransactionData for this transaction + * @param {VersionedTransactionData | undefined} data The versioned transaction data to store, or undefined to clear + */ + setVersionedTransactionData(data: VersionedTransactionData | undefined): void { + this._versionedTransactionData = data; + } + /** @inheritdoc */ canSign(): boolean { return true; @@ -154,12 +227,7 @@ export class Transaction extends BaseTransaction { * @param {KeyPair} keyPair Signer keys. */ async sign(keyPair: KeyPair[] | KeyPair): Promise { - if (!this._solTransaction || !this._solTransaction.recentBlockhash) { - throw new SigningError('Nonce is required before signing'); - } - if (!this._solTransaction || !this._solTransaction.feePayer) { - throw new SigningError('feePayer is required before signing'); - } + // Convert to array and build signers list const keyPairs = keyPair instanceof Array ? keyPair : [keyPair]; const signers: Signer[] = []; for (const kp of keyPairs) { @@ -169,15 +237,32 @@ export class Transaction extends BaseTransaction { } signers.push({ publicKey: new PublicKey(keys.pub), secretKey: keys.prv as Uint8Array }); } - try { - this._solTransaction.partialSign(...signers); - } catch (e) { - throw e; + + if (this._versionedTransaction) { + if (!this._versionedTransaction.message.recentBlockhash) { + throw new SigningError('Nonce is required before signing'); + } + this._versionedTransaction.sign(signers); + return; + } + + if (!this._solTransaction || !this._solTransaction.recentBlockhash) { + throw new SigningError('Nonce is required before signing'); + } + if (!this._solTransaction || !this._solTransaction.feePayer) { + throw new SigningError('feePayer is required before signing'); } + this._solTransaction.partialSign(...signers); } /** @inheritdoc */ toBroadcastFormat(): string { + if (this._versionedTransaction) { + // VersionedTransaction.serialize() doesn't need requireAllSignatures parameter + // It automatically handles whatever signatures are present + return Buffer.from(this._versionedTransaction.serialize()).toString('base64'); + } + if (!this._solTransaction) { throw new ParseTransactionError('Empty transaction'); } @@ -185,13 +270,9 @@ export class Transaction extends BaseTransaction { // In order to be able to serializer the txs, we have to change the requireAllSignatures based // on if the TX is fully signed or not const requireAllSignatures = requiresAllSignatures(this._solTransaction.signatures); - try { - // Based on the recomendation encoding found here https://docs.solana.com/developing/clients/jsonrpc-api#sendtransaction - // We use base64 encoding - return this._solTransaction.serialize({ requireAllSignatures }).toString('base64'); - } catch (e) { - throw e; - } + // Based on the recomendation encoding found here https://docs.solana.com/developing/clients/jsonrpc-api#sendtransaction + // We use base64 encoding + return this._solTransaction.serialize({ requireAllSignatures }).toString('base64'); } /** diff --git a/modules/sdk-coin-sol/src/lib/transactionBuilder.ts b/modules/sdk-coin-sol/src/lib/transactionBuilder.ts index 79c95bff25..bafdc802b2 100644 --- a/modules/sdk-coin-sol/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/transactionBuilder.ts @@ -9,10 +9,19 @@ import { PublicKey as BasePublicKey, Signature, SigningError, + SolVersionedTransactionData, TransactionType, } from '@bitgo/sdk-core'; import { Transaction } from './transaction'; -import { Blockhash, PublicKey, Transaction as SolTransaction } from '@solana/web3.js'; +import { + Blockhash, + MessageAddressTableLookup, + MessageV0, + PublicKey, + Transaction as SolTransaction, + VersionedTransaction, +} from '@solana/web3.js'; +import base58 from 'bs58'; import { isValidAddress, isValidAmount, @@ -117,7 +126,14 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { /** @inheritdoc */ protected async buildImplementation(): Promise { - this.transaction.solTransaction = this.buildSolTransaction(); + const builtTransaction = this.buildSolTransaction(); + + if (builtTransaction instanceof VersionedTransaction) { + this.transaction.versionedTransaction = builtTransaction; + } else { + this.transaction.solTransaction = builtTransaction; + } + this.transaction.setTransactionType(this.transactionType); this.transaction.setInstructionsData(this._instructionsData); this.transaction.loadInputsAndOutputs(); @@ -128,10 +144,22 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { /** * Builds the solana transaction. */ - protected buildSolTransaction(): SolTransaction { + protected buildSolTransaction(): SolTransaction | VersionedTransaction { assert(this._sender, new BuildTransactionError('sender is required before building')); assert(this._recentBlockhash, new BuildTransactionError('recent blockhash is required before building')); + // Check if we should build as VersionedTransaction + if (this._transaction.isVersionedTransaction()) { + return this.buildVersionedTransaction(); + } else { + return this.buildLegacyTransaction(); + } + } + + /** + * Builds a legacy Solana transaction. + */ + private buildLegacyTransaction(): SolTransaction { const tx = new SolTransaction(); if (this._transaction?.solTransaction?.signatures) { tx.signatures = this._transaction?.solTransaction?.signatures; @@ -179,6 +207,76 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { return tx; } + /** + * Builds a VersionedTransaction. + * + * @returns {VersionedTransaction} The built versioned transaction + */ + private buildVersionedTransaction(): VersionedTransaction { + const versionedTxData = this._transaction.getVersionedTransactionData(); + + if (!versionedTxData) { + throw new BuildTransactionError('Missing VersionedTransactionData'); + } + + const versionedTx = this.buildFromVersionedData(versionedTxData); + + this._transaction.lamportsPerSignature = this._lamportsPerSignature; + + for (const signer of this._signers) { + const publicKey = new PublicKey(signer.getKeys().pub); + const secretKey = signer.getKeys(true).prv; + assert(secretKey instanceof Uint8Array); + versionedTx.sign([{ publicKey, secretKey }]); + } + + for (const signature of this._signatures) { + const solPublicKey = new PublicKey(signature.publicKey.pub); + versionedTx.addSignature(solPublicKey, signature.signature); + } + + return versionedTx; + } + + /** + * Build a VersionedTransaction from deconstructed VersionedTransactionData + * @param data The versioned transaction data + * @returns The built versioned transaction + */ + private buildFromVersionedData(data: SolVersionedTransactionData): VersionedTransaction { + const staticAccountKeys = data.staticAccountKeys.map((key: string) => new PublicKey(key)); + + const addressTableLookups: MessageAddressTableLookup[] = data.addressLookupTables.map((alt) => ({ + accountKey: new PublicKey(alt.accountKey), + writableIndexes: alt.writableIndexes, + readonlyIndexes: alt.readonlyIndexes, + })); + + const compiledInstructions = data.versionedInstructions.map((instruction) => ({ + programIdIndex: instruction.programIdIndex, + accountKeyIndexes: instruction.accountKeyIndexes, + data: Buffer.from(base58.decode(instruction.data)), + })); + + if (!this._recentBlockhash) { + throw new BuildTransactionError('Missing nonce (recentBlockhash) for VersionedTransaction'); + } + + if (!this._sender) { + throw new BuildTransactionError('Missing sender (fee payer) for VersionedTransaction'); + } + + const messageV0 = new MessageV0({ + header: data.messageHeader, + staticAccountKeys, + recentBlockhash: this._recentBlockhash, + compiledInstructions, + addressTableLookups, + }); + + return new VersionedTransaction(messageV0); + } + // region Getters and Setters /** @inheritdoc */ protected get transaction(): Transaction { diff --git a/modules/sdk-coin-sol/src/lib/utils.ts b/modules/sdk-coin-sol/src/lib/utils.ts index d9c403cbbb..90e19f2cc2 100644 --- a/modules/sdk-coin-sol/src/lib/utils.ts +++ b/modules/sdk-coin-sol/src/lib/utils.ts @@ -3,6 +3,8 @@ import { isValidXpub, NotSupported, ParseTransactionError, + SolInstruction, + SolVersionedInstruction, TransactionType, UtilsError, } from '@bitgo/sdk-core'; @@ -616,3 +618,17 @@ export function validateOwnerAddress(ownerAddress: string): void { throw new BuildTransactionError('Invalid or missing ownerAddress, got: ' + ownerAddress); } } + +/** + * Type predicate to check if an instruction is a legacy SolInstruction. + * Uses 'programId' property as the discriminator since it's unique to legacy instructions + * Versioned instructions have 'programIdIndex' property. + * + * @param instruction - The instruction to check + * @returns True if the instruction is a SolInstruction (has programId field) + */ +export function isSolLegacyInstruction( + instruction: SolInstruction | SolVersionedInstruction +): instruction is SolInstruction { + return 'programId' in instruction; +} diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index f4a9777792..ec39cacc00 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -1596,5 +1596,10 @@ export class Sol extends BaseCoin { if (params.solInstructions) { intent.solInstructions = params.solInstructions; } + + // Handle versioned transaction data for Solana + if (params.solVersionedTransactionData) { + intent.solVersionedTransactionData = params.solVersionedTransactionData; + } } } diff --git a/modules/sdk-coin-sol/test/resources/sol.ts b/modules/sdk-coin-sol/test/resources/sol.ts index 550d9c31be..f6c0ce19eb 100644 --- a/modules/sdk-coin-sol/test/resources/sol.ts +++ b/modules/sdk-coin-sol/test/resources/sol.ts @@ -503,3 +503,7 @@ export const JITO_STAKE_POOL_DATA_PARSED: StakePool = StakePoolLayout.decode( ); export const JITO_STAKE_POOL_VALIDATOR_ADDRESS = 'BDn3HiXMTym7ZQofWFxDb7ZGQX6GomQzJYKfytTAqd5g'; + +// VersionedTransaction test data +export const JUPITER_VERSIONED_TX_BYTES = + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAFDTWoM5DBCGn7cn5aV56fomo6mhD2K1c5XYhQIan41E6dG9t06ox6DMdvVuBnSEATyN/8qIq246iO5aDm1jm82x4wM40FR8Q7xSZUpUtB0lbvelaZ46oRQ2GM9hWGMukHG1EUsA1R9ZO55ClUvNMyw8IQOrfRmu3ONG50oNeluT4LgaOacN+I4d0HStcu+oh0no4XXgVUwjzk5+egiUaQDkCCjXAlSDAb46ahLAfWeKOqzO/HC/z2WgRk8RS1yc6heMZzBbR3SPDwrNvWlCZGaETaixiH9ufdOJ823Gv+MnxH1SOu6TCsX1+TMskj94D3NElDpado8okFQbUuzaijU9oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCp+mGpF4I4ZrV12bapJ7p61Babt3ty7HYDUKtMso38OYgHCgAFAooyBAAKAAkDoQ4BAAAAAAAIAgAFDAIAAAAwKyIAAAAAAAsFBQAgDAgJk/F7ZPSErnb/CQYABwAdCAwBAQs/HgAFDg0HIB0MDBwLJg8mEBIODSAdESYeDAwkJgIDBgsmGyYWGQ4EICUaJh4MDCQmGBcBCyIjDCEeBA0VExQfMtGYU5N8/tjpC0ANAwAAAAAAWqwAAAAAAAAyAAAAAAADAAAAJiwaAAMm5AwAAgoQJwIDDAMFAAABCQQpv5UHKk+/BN9xdZPnOLzMIScoFcJHGoDZ+fVVsNSEJQI0IwUAKA4CFyo9SDjsPdfWXAdMF0mu2+xZbw9rbR6rzNAEeFkLmyKwBGBmYWUAMHCJ6Oi0A1Qjpjp1Yv3xP+sPLm2qJ//TKZd5Z9KzjvEDwb3CA77DwOtKd1MCu6h4XJjC42yc8TcL92nhzFUR6HLW3XrAIZOvBo6Ki5CRjQOMk5Q='; diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts index 2b09fa16d6..d7fdb78a31 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/customInstructionBuilder.ts @@ -1,8 +1,16 @@ import should from 'should'; -import { SystemProgram, PublicKey, TransactionInstruction, ComputeBudgetProgram } from '@solana/web3.js'; +import { + SystemProgram, + PublicKey, + TransactionInstruction, + ComputeBudgetProgram, + VersionedTransaction, +} from '@solana/web3.js'; import { getBuilderFactory } from '../getBuilderFactory'; import { KeyPair, Utils, Transaction } from '../../../src'; import * as testData from '../../resources/sol'; +import { SolVersionedInstruction } from '@bitgo/sdk-core'; +import base58 from 'bs58'; describe('Sol Custom Instruction Builder', () => { const factory = getBuilderFactory('tsol'); @@ -161,7 +169,7 @@ describe('Sol Custom Instruction Builder', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any should(() => txBuilder.addCustomInstruction(invalidInstruction as any)).throwError( - 'Instruction must have a valid programId string' + 'Versioned instruction must have a valid programIdIndex number' ); }); @@ -230,4 +238,261 @@ describe('Sol Custom Instruction Builder', () => { await txBuilder.build().should.be.rejectedWith('Invalid transaction: missing nonce blockhash'); }); }); + + describe('SolVersionedInstruction Support', () => { + it('should accept and validate SolVersionedInstruction format', () => { + const compiledInstruction: SolVersionedInstruction = { + programIdIndex: 1, + accountKeyIndexes: [0, 2, 3], + data: '3Bxs43ZMjSRQLs6o', // base58 encoded instruction data + }; + + const txBuilder = customInstructionBuilder(); + should(() => txBuilder.addCustomInstruction(compiledInstruction)).not.throwError(); + + txBuilder.getInstructions().should.have.length(1); + const addedInstruction = txBuilder.getInstructions()[0]; + addedInstruction.params.should.deepEqual(compiledInstruction); + }); + + it('should validate versioned instruction format', () => { + const txBuilder = customInstructionBuilder(); + + // Invalid programIdIndex + should(() => + txBuilder.addCustomInstruction({ + programIdIndex: -1, + accountKeyIndexes: [0], + data: '1', // base58 for 0x00 + } as SolVersionedInstruction) + ).throwError('Versioned instruction must have a valid programIdIndex number'); + + // Invalid accountKeyIndexes + should(() => + txBuilder.addCustomInstruction({ + programIdIndex: 0, + accountKeyIndexes: [-1], + data: '1', // base58 for 0x00 + } as SolVersionedInstruction) + ).throwError('Each accountKeyIndex must be a non-negative number'); + + // Missing data + should(() => + txBuilder.addCustomInstruction({ + programIdIndex: 0, + accountKeyIndexes: [0], + } as SolVersionedInstruction) + ).throwError('Versioned instruction must have valid data string'); + }); + + it('should handle mixed SolInstruction and SolVersionedInstruction', () => { + const traditionalInstruction = convertInstructionToParams( + SystemProgram.transfer({ + fromPubkey: new PublicKey(authAccount.pub), + toPubkey: new PublicKey(otherAccount.pub), + lamports: 1000000, + }) + ); + + const compiledInstruction: SolVersionedInstruction = { + programIdIndex: 1, + accountKeyIndexes: [0, 2], + data: '4HSo5YVBrgChTZX5', + }; + + const txBuilder = customInstructionBuilder(); + txBuilder.addCustomInstruction(traditionalInstruction); + txBuilder.addCustomInstruction(compiledInstruction); + + txBuilder.getInstructions().should.have.length(2); + }); + }); + + describe('fromVersionedTransactionData', () => { + function extractVersionedTransactionData(base64Bytes: string) { + const buffer = Buffer.from(base64Bytes, 'base64'); + const versionedTx = VersionedTransaction.deserialize(buffer); + + return { + versionedInstructions: versionedTx.message.compiledInstructions.map((ci) => ({ + programIdIndex: ci.programIdIndex, + accountKeyIndexes: ci.accountKeyIndexes, + data: base58.encode(ci.data), + })), + addressLookupTables: versionedTx.message.addressTableLookups.map((alt) => ({ + accountKey: alt.accountKey.toString(), + writableIndexes: alt.writableIndexes, + readonlyIndexes: alt.readonlyIndexes, + })), + staticAccountKeys: versionedTx.message.staticAccountKeys.map((key) => key.toString()), + messageHeader: { + numRequiredSignatures: versionedTx.message.header.numRequiredSignatures, + numReadonlySignedAccounts: versionedTx.message.header.numReadonlySignedAccounts, + numReadonlyUnsignedAccounts: versionedTx.message.header.numReadonlyUnsignedAccounts, + }, + }; + } + + it('should process VersionedTransactionData and extract instructions', () => { + const txBuilder = customInstructionBuilder(); + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + // Should parse without throwing errors + should(() => txBuilder.fromVersionedTransactionData(versionedTxData)).not.throwError(); + + // Should have extracted instructions + const instructions = txBuilder.getInstructions(); + instructions.length.should.be.greaterThan(0); + + for (const instruction of instructions) { + instruction.type.should.equal('VersionedCustomInstruction'); + instruction.params.should.have.property('programIdIndex'); + instruction.params.should.have.property('accountKeyIndexes'); + instruction.params.should.have.property('data'); + } + }); + + it('should store VersionedTransactionData in underlying transaction', async () => { + const txBuilder = customInstructionBuilder(); + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + txBuilder.fromVersionedTransactionData(versionedTxData); + + const tx = txBuilder['_transaction']; + const storedData = tx.getVersionedTransactionData(); + should.exist(storedData); + storedData!.versionedInstructions.length.should.equal(versionedTxData.versionedInstructions.length); + storedData!.addressLookupTables.length.should.equal(versionedTxData.addressLookupTables.length); + storedData!.staticAccountKeys.length.should.equal(versionedTxData.staticAccountKeys.length); + }); + + it('should validate input data', () => { + const txBuilder = customInstructionBuilder(); + + // Invalid: null/undefined + should(() => txBuilder.fromVersionedTransactionData(null as any)).throwError(/must be a valid object/); + should(() => txBuilder.fromVersionedTransactionData(undefined as any)).throwError(/must be a valid object/); + + // Invalid: empty instructions + should(() => + txBuilder.fromVersionedTransactionData({ + versionedInstructions: [], + addressLookupTables: [], + staticAccountKeys: ['test'], + messageHeader: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 }, + }) + ).throwError(/non-empty array/); + + // Invalid: missing addressLookupTables + should(() => + txBuilder.fromVersionedTransactionData({ + versionedInstructions: [{ programIdIndex: 0, accountKeyIndexes: [0], data: '1' }], // base58 for 0x00 + staticAccountKeys: ['test'], + } as any) + ).throwError(/must be an array/); + + // Invalid: empty staticAccountKeys + should(() => + txBuilder.fromVersionedTransactionData({ + versionedInstructions: [{ programIdIndex: 0, accountKeyIndexes: [0], data: '1' }], // base58 for 0x00 + addressLookupTables: [], + staticAccountKeys: [], + messageHeader: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 }, + }) + ).throwError(/non-empty array/); + }); + + it('should work with the complete transaction building flow', async () => { + const txBuilder = customInstructionBuilder(); + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + // Process the VersionedTransactionData + txBuilder.fromVersionedTransactionData(versionedTxData); + + txBuilder.sender(versionedTxData.staticAccountKeys[0]); // Fee payer + txBuilder.nonce(testData.blockHashes.validBlockHashes[0]); + + const tx = await txBuilder.build(); + + // Verify transaction properties + tx.should.be.ok(); + tx.type.should.equal(31); // TransactionType.CustomTx enum value + + // Verify signable payload + const payload = tx.signablePayload; + payload.should.be.instanceOf(Buffer); + payload.length.should.be.greaterThan(0); + + // Verify payload is deterministic - rebuilding with same params produces same payload + const txBuilder2 = customInstructionBuilder(); + txBuilder2.fromVersionedTransactionData(versionedTxData); + txBuilder2.sender(versionedTxData.staticAccountKeys[0]); + txBuilder2.nonce(testData.blockHashes.validBlockHashes[0]); + const tx2 = await txBuilder2.build(); + should.equal(tx2.signablePayload.toString('hex'), payload.toString('hex')); + }); + + it('should extract the correct number of instructions from Jupiter transaction', () => { + const txBuilder = customInstructionBuilder(); + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + txBuilder.fromVersionedTransactionData(versionedTxData); + + const instructions = txBuilder.getInstructions(); + + // Verify we extracted instructions + instructions.length.should.be.greaterThan(0); + instructions.length.should.equal(versionedTxData.versionedInstructions.length); + + // Verify each instruction has valid compiled format + for (const instruction of instructions) { + const params = instruction.params as SolVersionedInstruction; + (typeof params.programIdIndex).should.equal('number'); + params.programIdIndex.should.be.greaterThanOrEqual(0); + Array.isArray(params.accountKeyIndexes).should.equal(true); + (typeof params.data).should.equal('string'); + // Data should be valid base58 string (non-empty) + params.data.length.should.be.greaterThan(0); + // Verify it can be decoded + should.doesNotThrow(() => base58.decode(params.data)); + } + }); + + it('should clear instructions properly after using fromVersionedTransactionData', () => { + const txBuilder = customInstructionBuilder(); + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + txBuilder.fromVersionedTransactionData(versionedTxData); + txBuilder.getInstructions().length.should.be.greaterThan(0); + + // Clear should work + txBuilder.clearInstructions(); + txBuilder.getInstructions().should.have.length(0); + }); + + it('should extract fee payer from staticAccountKeys', () => { + const txBuilder = factory.getCustomInstructionBuilder(); + txBuilder.nonce(recentBlockHash); + + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + txBuilder.fromVersionedTransactionData(versionedTxData); + + const sender = txBuilder['_sender']; + should.exist(sender); + sender.should.equal(versionedTxData.staticAccountKeys[0]); + }); + + it('should not override existing sender', () => { + const txBuilder = customInstructionBuilder(); + const versionedTxData = extractVersionedTransactionData(testData.JUPITER_VERSIONED_TX_BYTES); + + const originalSender = txBuilder['_sender']; + txBuilder.fromVersionedTransactionData(versionedTxData); + + const sender = txBuilder['_sender']; + sender.should.equal(originalSender); + sender.should.equal(authAccount.pub); + }); + }); }); diff --git a/modules/sdk-coin-sol/test/unit/versionedTransaction.ts b/modules/sdk-coin-sol/test/unit/versionedTransaction.ts new file mode 100644 index 0000000000..7e43e38cc0 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/versionedTransaction.ts @@ -0,0 +1,131 @@ +import should from 'should'; +import { KeyPair, Transaction } from '../../src'; +import * as testData from '../resources/sol'; +import { getBuilderFactory } from './getBuilderFactory'; +import base58 from 'bs58'; +const { VersionedTransaction } = require('@solana/web3.js'); + +describe('Sol Jupiter Swap Transaction', () => { + const walletKeyPair = new KeyPair(testData.authAccount); + const wallet = walletKeyPair.getKeys(); + + it('should preserve instructions and ALTs when building and signing', async function () { + // Jupiter Swap Transaction (Versioned) + const versionedTransaction = + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAFDTWoM5DBCGn7cn5aV56fomo6mhD2K1c5XYhQIan41E6dG9t06ox6DMdvVuBnSEATyN/8qIq246iO5aDm1jm82x4wM40FR8Q7xSZUpUtB0lbvelaZ46oRQ2GM9hWGMukHG1EUsA1R9ZO55ClUvNMyw8IQOrfRmu3ONG50oNeluT4LgaOacN+I4d0HStcu+oh0no4XXgVUwjzk5+egiUaQDkCCjXAlSDAb46ahLAfWeKOqzO/HC/z2WgRk8RS1yc6heMZzBbR3SPDwrNvWlCZGaETaixiH9ufdOJ823Gv+MnxH1SOu6TCsX1+TMskj94D3NElDpado8okFQbUuzaijU9oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCp+mGpF4I4ZrV12bapJ7p61Babt3ty7HYDUKtMso38OYgHCgAFAooyBAAKAAkDoQ4BAAAAAAAIAgAFDAIAAAAwKyIAAAAAAAsFBQAgDAgJk/F7ZPSErnb/CQYABwAdCAwBAQs/HgAFDg0HIB0MDBwLJg8mEBIODSAdESYeDAwkJgIDBgsmGyYWGQ4EICUaJh4MDCQmGBcBCyIjDCEeBA0VExQfMtGYU5N8/tjpC0ANAwAAAAAAWqwAAAAAAAAyAAAAAAADAAAAJiwaAAMm5AwAAgoQJwIDDAMFAAABCQQpv5UHKk+/BN9xdZPnOLzMIScoFcJHGoDZ+fVVsNSEJQI0IwUAKA4CFyo9SDjsPdfWXAdMF0mu2+xZbw9rbR6rzNAEeFkLmyKwBGBmYWUAMHCJ6Oi0A1Qjpjp1Yv3xP+sPLm2qJ//TKZd5Z9KzjvEDwb3CA77DwOtKd1MCu6h4XJjC42yc8TcL92nhzFUR6HLW3XrAIZOvBo6Ki5CRjQOMk5Q='; + + const originalDeserialized = VersionedTransaction.deserialize(Buffer.from(versionedTransaction, 'base64')); + + const versionedInstructions = originalDeserialized.message.compiledInstructions.map((ix) => ({ + programIdIndex: ix.programIdIndex, + accountKeyIndexes: ix.accountKeyIndexes, + data: base58.encode(ix.data), + })); + + const addressLookupTables = + originalDeserialized.message.addressTableLookups?.map((lookup) => ({ + accountKey: lookup.accountKey.toBase58(), + writableIndexes: lookup.writableIndexes, + readonlyIndexes: lookup.readonlyIndexes, + })) || []; + + const staticAccountKeys = originalDeserialized.message.staticAccountKeys.map((key) => key.toBase58()); + staticAccountKeys[0] = testData.authAccount.pub; // Replace fee payer with our test account + + const versionedTransactionData = { + versionedInstructions, + addressLookupTables, + staticAccountKeys, + messageHeader: originalDeserialized.message.header, + }; + + const factory = getBuilderFactory('tsol'); + const txBuilder = factory.getCustomInstructionBuilder(); + + // Build transaction from versioned transaction data + txBuilder.fromVersionedTransactionData(versionedTransactionData); + txBuilder.nonce(testData.blockHashes.validBlockHashes[0]); + + // Build unsigned transaction + const txUnsigned = (await txBuilder.build()) as Transaction; + should.exist(txUnsigned); + txUnsigned.isVersionedTransaction().should.be.true(); + should.exist(txUnsigned.toBroadcastFormat()); + + // Sign the transaction + txBuilder.sign({ key: wallet.prv }); + const tx = (await txBuilder.build()) as Transaction; + const rawTx = tx.toBroadcastFormat(); + tx.isVersionedTransaction().should.be.true(); + should.exist(rawTx); + + const txBuilder2 = factory.getCustomInstructionBuilder(); + txBuilder2.fromVersionedTransactionData(versionedTransactionData); + txBuilder2.nonce(testData.blockHashes.validBlockHashes[0]); + const tx2 = await txBuilder2.build(); + + should.equal(tx2.signablePayload.toString('hex'), txUnsigned.signablePayload.toString('hex')); + should.equal(tx2.type, txUnsigned.type); + + // Verify we can add signature manually + const signed = tx.signature[0]; + const txBuilder3 = factory.getCustomInstructionBuilder(); + txBuilder3.fromVersionedTransactionData(versionedTransactionData); + txBuilder3.nonce(testData.blockHashes.validBlockHashes[0]); + await txBuilder3.addSignature({ pub: wallet.pub }, Buffer.from(base58.decode(signed))); + + const signedTx = await txBuilder3.build(); + should.equal(signedTx.type, tx.type); + + const rawSignedTx = signedTx.toBroadcastFormat(); + should.equal(rawSignedTx, rawTx); + + const signedDeserialized = VersionedTransaction.deserialize(Buffer.from(rawTx, 'base64')); + + // Verify all instructions are preserved + const origInstructions = originalDeserialized.message.compiledInstructions; + const signedInstructions = signedDeserialized.message.compiledInstructions; + should.equal(origInstructions.length, signedInstructions.length, 'Number of instructions should match'); + + for (let i = 0; i < origInstructions.length; i++) { + should.equal( + origInstructions[i].programIdIndex, + signedInstructions[i].programIdIndex, + `Instruction ${i}: programIdIndex should match` + ); + should.deepEqual( + origInstructions[i].accountKeyIndexes, + signedInstructions[i].accountKeyIndexes, + `Instruction ${i}: accountKeyIndexes should match` + ); + should.equal( + Buffer.from(origInstructions[i].data).toString('hex'), + Buffer.from(signedInstructions[i].data).toString('hex'), + `Instruction ${i}: data should match` + ); + } + + // Verify all ALTs are preserved + const origALTs = originalDeserialized.message.addressTableLookups || []; + const signedALTs = signedDeserialized.message.addressTableLookups || []; + should.equal(origALTs.length, signedALTs.length, 'Number of ALTs should match'); + + for (let i = 0; i < origALTs.length; i++) { + should.equal( + origALTs[i].accountKey.toBase58(), + signedALTs[i].accountKey.toBase58(), + `ALT ${i}: accountKey should match` + ); + should.deepEqual( + origALTs[i].writableIndexes, + signedALTs[i].writableIndexes, + `ALT ${i}: writableIndexes should match` + ); + should.deepEqual( + origALTs[i].readonlyIndexes, + signedALTs[i].readonlyIndexes, + `ALT ${i}: readonlyIndexes should match` + ); + } + }); +}); diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index 460412b1a3..a008e31a22 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -118,9 +118,15 @@ export abstract class MpcUtils { const chain = this.baseCoin.getChain(); if (params.intentType === 'customTx' && baseCoin.getFamily() === 'sol') { + const hasSolInstructions = params.solInstructions && params.solInstructions.length > 0; + const hasVersionedData = + params.solVersionedTransactionData && + params.solVersionedTransactionData.versionedInstructions && + params.solVersionedTransactionData.versionedInstructions.length > 0; + assert( - params.solInstructions && params.solInstructions.length > 0, - `'solInstructions' is a required parameter for customTx intent` + hasSolInstructions || hasVersionedData, + `'solInstructions' or 'solVersionedTransactionData' is required for customTx intent` ); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 31c80f5d76..5fc017912e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -47,6 +47,29 @@ export interface SolInstruction { data: string; } +export interface SolVersionedInstruction { + programIdIndex: number; + accountKeyIndexes: number[]; + data: string; +} + +export interface SolAddressLookupTable { + accountKey: string; + writableIndexes: number[]; + readonlyIndexes: number[]; +} + +export interface SolVersionedTransactionData { + versionedInstructions: SolVersionedInstruction[]; + addressLookupTables: SolAddressLookupTable[]; + staticAccountKeys: string[]; + messageHeader: { + numRequiredSignatures: number; + numReadonlySignedAccounts: number; + numReadonlyUnsignedAccounts: number; + }; +} + export interface aptosCustomTransactionParams { moduleName: string; functionName: string; @@ -228,6 +251,11 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase * Each instruction should contain programId, keys, and data fields. */ solInstructions?: SolInstruction[]; + /** + * Deconstructed Versioned Transaction data for Solana customTx intent type. + * Contains compiled instructions, Address Lookup Tables, and static account keys. + */ + solVersionedTransactionData?: SolVersionedTransactionData; /** * Custom transaction parameters for Aptos entry function calls. * Used with the customTx intent type for Aptos smart contract interactions. @@ -298,6 +326,11 @@ export interface PopulatedIntent extends PopulatedIntentBase { * Each instruction should contain programId, keys, and data fields. */ solInstructions?: SolInstruction[]; + /** + * Deconstructed Versioned Transaction data for Solana customTx intent type. + * Contains compiled instructions, Address Lookup Tables, and static account keys. + */ + solVersionedTransactionData?: SolVersionedTransactionData; /** * Custom Aptos transaction for use with the customTx intent type. */ diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index bd4fbfa533..12e02967fc 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -112,6 +112,8 @@ export const BuildParams = t.exact( emergency: t.unknown, // Solana custom instructions for transaction building solInstructions: t.unknown, + // Solana versioned transaction data for transaction building + solVersionedTransactionData: t.unknown, // Aptos custom transaction parameters for smart contract calls aptosCustomTransactionParams: t.unknown, }), diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 2f9938caef..39b3d18584 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -181,6 +181,28 @@ export interface PrebuildTransactionOptions { }[]; data: string; }[]; + /** + * Solana versioned transaction data for building transactions with Address Lookup Tables. + * Contains compiled instructions, address lookup tables, static account keys, and message header. + */ + solVersionedTransactionData?: { + versionedInstructions: { + programIdIndex: number; + accountKeyIndexes: number[]; + data: string; + }[]; + addressLookupTables: { + accountKey: string; + writableIndexes: number[]; + readonlyIndexes: number[]; + }[]; + staticAccountKeys: string[]; + messageHeader: { + numRequiredSignatures: number; + numReadonlySignedAccounts: number; + numReadonlyUnsignedAccounts: number; + }; + }; /** * Custom transaction parameters for Aptos entry function calls. * Used with the customTx intent type for Aptos smart contract interactions. diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index d587c670ce..39ab5cebcc 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3446,6 +3446,7 @@ export class Wallet implements IWallet { sequenceId: params.sequenceId, comment: params.comment, solInstructions: params.solInstructions, + solVersionedTransactionData: params.solVersionedTransactionData, aptosCustomTransactionParams: params.aptosCustomTransactionParams, recipients: params.recipients || [], nonce: params.nonce,