From 458a498e566a5e028e5aa53d69ba71101f31d117 Mon Sep 17 00:00:00 2001 From: Raj Date: Wed, 30 Jul 2025 13:27:26 +0530 Subject: [PATCH 1/2] feat: add spl token operations builder for sol - Add mint/burn transaction builder for sol - Could support more instructions in the future TICKET: TMS-1218 --- modules/sdk-coin-sol/src/lib/constants.ts | 1 + modules/sdk-coin-sol/src/lib/iface.ts | 28 +- modules/sdk-coin-sol/src/lib/index.ts | 1 + .../src/lib/splTokenOpsBuilder.ts | 300 ++++++++++++++++ .../src/lib/transactionBuilderFactory.ts | 8 + modules/sdk-coin-sol/src/sol.ts | 36 ++ .../transactionBuilder/splTokenOpsBuilder.ts | 332 ++++++++++++++++++ 7 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts create mode 100644 modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index 2c84113617..ad529191b2 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -49,6 +49,7 @@ export enum InstructionBuilderTypes { SetPriorityFee = 'SetPriorityFee', MintTo = 'MintTo', Burn = 'Burn', + SplTokenOps = 'SplTokenOps', } export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index c4126809de..02806c3949 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -40,7 +40,8 @@ export type InstructionParams = | StakingAuthorize | StakingDelegate | MintTo - | Burn; + | Burn + | SplTokenOps; export interface Memo { type: InstructionBuilderTypes.Memo; @@ -106,6 +107,28 @@ export interface Burn { }; } +export interface SplTokenOperation { + type: 'mint' | 'burn'; + mintAddress?: string; + amount: string; + tokenName?: string; + decimalPlaces?: number; + programId?: string; + // For mint operations + destinationAddress?: string; + // For burn operations + accountAddress?: string; + // Authority address for both mint and burn + authorityAddress: string; +} + +export interface SplTokenOps { + type: InstructionBuilderTypes.SplTokenOps; + params: { + operations: SplTokenOperation[]; + }; +} + export interface StakingActivate { type: InstructionBuilderTypes.StakingActivate; params: { @@ -184,7 +207,8 @@ export type ValidInstructionTypes = | 'TokenTransfer' | 'SetPriorityFee' | 'MintTo' - | 'Burn'; + | 'Burn' + | 'SplTokenOps'; export type StakingAuthorizeParams = { stakingAddress: string; diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 57d7ff8d05..69c5cc8f9a 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -4,6 +4,7 @@ import * as Utils from './utils'; export { AtaInitializationBuilder } from './ataInitializationBuilder'; export { CloseAtaBuilder } from './closeAtaBuilder'; export { KeyPair } from './keyPair'; +export { SplTokenOpsBuilder } from './splTokenOpsBuilder'; export { StakingActivateBuilder } from './stakingActivateBuilder'; export { StakingAuthorizeBuilder } from './stakingAuthorizeBuilder'; export { StakingDeactivateBuilder } from './stakingDeactivateBuilder'; diff --git a/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts b/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts new file mode 100644 index 0000000000..e9523025e6 --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts @@ -0,0 +1,300 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { Transaction } from './transaction'; +import { getSolTokenFromTokenName, isValidAmount, validateAddress, validateMintAddress } from './utils'; +import { InstructionBuilderTypes } from './constants'; +import { MintTo, Burn, SetPriorityFee, SplTokenOperation } from './iface'; +import assert from 'assert'; +import { TransactionBuilder } from './transactionBuilder'; + +/** + * Valid SPL token operation types + */ +const VALID_OPERATION_TYPES = ['mint', 'burn'] as const; + +/** + * Transaction builder for SPL token mint and burn operations. + * Supports mixed operations in a single transaction. + */ +export class SplTokenOpsBuilder extends TransactionBuilder { + private _operations: SplTokenOperation[] = []; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + /** + * Add a mint operation to the transaction + * + * @param operation - The mint operation parameters + * @returns This transaction builder + */ + mint(operation: Omit): this { + this.addOperation({ ...operation, type: 'mint' }); + return this; + } + + /** + * Add a burn operation to the transaction + * + * @param operation - The burn operation parameters + * @returns This transaction builder + */ + burn(operation: Omit): this { + this.addOperation({ ...operation, type: 'burn' }); + return this; + } + + /** + * Add a generic SPL token operation (mint or burn) + * + * @param operation - The operation parameters + * @returns This transaction builder + */ + addOperation(operation: SplTokenOperation): this { + this.validateOperation(operation); + this._operations.push(operation); + return this; + } + + /** + * Validates an SPL token operation + * @param operation - The operation to validate + */ + private validateOperation(operation: SplTokenOperation): void { + this.validateOperationType(operation.type); + this.validateCommonFields(operation); + this.validateOperationSpecificFields(operation); + this.validateTokenInformation(operation); + } + + /** + * Validates the operation type + */ + private validateOperationType(type: string): void { + if (!type || !(VALID_OPERATION_TYPES as readonly string[]).includes(type)) { + throw new BuildTransactionError(`Operation type must be one of: ${VALID_OPERATION_TYPES.join(', ')}`); + } + } + + /** + * Validates fields common to all operations + */ + private validateCommonFields(operation: SplTokenOperation): void { + if (!operation.amount || !isValidAmount(operation.amount)) { + throw new BuildTransactionError('Invalid amount: ' + operation.amount); + } + + if (!operation.authorityAddress) { + throw new BuildTransactionError('Operation requires authorityAddress'); + } + validateAddress(operation.authorityAddress, 'authorityAddress'); + } + + /** + * Validates operation-specific fields based on type + */ + private validateOperationSpecificFields(operation: SplTokenOperation): void { + switch (operation.type) { + case 'mint': + this.validateMintOperation(operation); + break; + case 'burn': + this.validateBurnOperation(operation); + break; + default: + throw new BuildTransactionError(`Unsupported operation type: ${operation.type}`); + } + } + + /** + * Validates mint-specific fields + */ + private validateMintOperation(operation: SplTokenOperation): void { + if (!operation.destinationAddress) { + throw new BuildTransactionError('Mint operation requires destinationAddress'); + } + validateAddress(operation.destinationAddress, 'destinationAddress'); + } + + /** + * Validates burn-specific fields + */ + private validateBurnOperation(operation: SplTokenOperation): void { + if (!operation.accountAddress) { + throw new BuildTransactionError('Burn operation requires accountAddress'); + } + validateAddress(operation.accountAddress, 'accountAddress'); + } + + /** + * Validates token information (name or mint address) + */ + private validateTokenInformation(operation: SplTokenOperation): void { + if (!operation.tokenName && !operation.mintAddress) { + throw new BuildTransactionError('Either tokenName or mintAddress must be provided'); + } + + if (operation.tokenName) { + const token = getSolTokenFromTokenName(operation.tokenName); + if (!token && !operation.mintAddress) { + throw new BuildTransactionError('Invalid token name or missing mintAddress: ' + operation.tokenName); + } + } + + if (operation.mintAddress) { + validateMintAddress(operation.mintAddress); + } + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + + for (const instruction of this._instructionsData) { + if (instruction.type === InstructionBuilderTypes.MintTo) { + const mintInstruction: MintTo = instruction; + this.addOperation({ + type: 'mint', + mintAddress: mintInstruction.params.mintAddress, + destinationAddress: mintInstruction.params.destinationAddress, + authorityAddress: mintInstruction.params.authorityAddress, + amount: mintInstruction.params.amount, + tokenName: mintInstruction.params.tokenName, + programId: mintInstruction.params.programId, + }); + } else if (instruction.type === InstructionBuilderTypes.Burn) { + const burnInstruction: Burn = instruction; + this.addOperation({ + type: 'burn', + mintAddress: burnInstruction.params.mintAddress, + accountAddress: burnInstruction.params.accountAddress, + authorityAddress: burnInstruction.params.authorityAddress, + amount: burnInstruction.params.amount, + tokenName: burnInstruction.params.tokenName, + programId: burnInstruction.params.programId, + }); + } + } + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + assert(this._operations.length > 0, 'At least one SPL token operation must be specified'); + + const instructions = this._operations.map((operation) => this.createInstructionFromOperation(operation)); + + // Add priority fee instruction if needed + if (this._priorityFee && this._priorityFee > 0) { + const priorityFeeInstruction: SetPriorityFee = { + type: InstructionBuilderTypes.SetPriorityFee, + params: { fee: this._priorityFee }, + }; + this._instructionsData = [priorityFeeInstruction, ...instructions]; + } else { + this._instructionsData = instructions; + } + + return await super.buildImplementation(); + } + + /** + * Creates an instruction from an operation + */ + private createInstructionFromOperation(operation: SplTokenOperation): MintTo | Burn { + const tokenInfo = this.resolveTokenInfo(operation); + + switch (operation.type) { + case 'mint': + return this.createMintInstruction(operation, tokenInfo); + case 'burn': + return this.createBurnInstruction(operation, tokenInfo); + default: + throw new BuildTransactionError(`Unsupported operation type: ${operation.type}`); + } + } + + /** + * Resolves token information from operation + */ + private resolveTokenInfo(operation: SplTokenOperation): { + mintAddress: string; + tokenName: string; + programId?: string; + } { + if (operation.mintAddress) { + return { + mintAddress: operation.mintAddress, + tokenName: operation.tokenName || operation.mintAddress, + programId: operation.programId, + }; + } else if (operation.tokenName) { + const token = getSolTokenFromTokenName(operation.tokenName); + if (token) { + return { + mintAddress: token.tokenAddress, + tokenName: token.name, + programId: token.programId, + }; + } else { + throw new BuildTransactionError('Invalid token name: ' + operation.tokenName); + } + } else { + throw new BuildTransactionError('Either tokenName or mintAddress must be provided'); + } + } + + /** + * Creates a mint instruction + */ + private createMintInstruction( + operation: SplTokenOperation, + tokenInfo: { mintAddress: string; tokenName: string; programId?: string } + ): MintTo { + if (!operation.destinationAddress) { + throw new BuildTransactionError('Mint operation requires destinationAddress'); + } + + const params = { + mintAddress: tokenInfo.mintAddress, + destinationAddress: operation.destinationAddress, + authorityAddress: operation.authorityAddress, + amount: operation.amount, + tokenName: tokenInfo.tokenName, + programId: tokenInfo.programId, + ...(operation.decimalPlaces !== undefined && { decimalPlaces: operation.decimalPlaces }), + }; + + return { + type: InstructionBuilderTypes.MintTo, + params, + }; + } + + /** + * Creates a burn instruction + */ + private createBurnInstruction( + operation: SplTokenOperation, + tokenInfo: { mintAddress: string; tokenName: string; programId?: string } + ): Burn { + if (!operation.accountAddress) { + throw new BuildTransactionError('Burn operation requires accountAddress'); + } + return { + type: InstructionBuilderTypes.Burn, + params: { + mintAddress: tokenInfo.mintAddress, + accountAddress: operation.accountAddress, + authorityAddress: operation.authorityAddress, + amount: operation.amount, + tokenName: tokenInfo.tokenName, + programId: tokenInfo.programId, + decimalPlaces: operation.decimalPlaces, + }, + }; + } +} diff --git a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts index 45e348d0bc..eafb58e212 100644 --- a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts @@ -2,6 +2,7 @@ import { BaseTransactionBuilderFactory, InvalidTransactionError, TransactionType import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { AtaInitializationBuilder } from './ataInitializationBuilder'; import { CloseAtaBuilder } from './closeAtaBuilder'; +import { SplTokenOpsBuilder } from './splTokenOpsBuilder'; import { StakingActivateBuilder } from './stakingActivateBuilder'; import { StakingAuthorizeBuilder } from './stakingAuthorizeBuilder'; import { StakingDeactivateBuilder } from './stakingDeactivateBuilder'; @@ -175,6 +176,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new CloseAtaBuilder(this._coinConfig)); } + /** + * Returns the builder to create SPL token mint and burn operations. + */ + getSplTokenOpsBuilder(tx?: Transaction): SplTokenOpsBuilder { + return this.initializeBuilder(tx, new SplTokenOpsBuilder(this._coinConfig)); + } + /** * Initialize the builder with the given transaction * diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 0955ec2167..6d4424c8e7 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -30,6 +30,7 @@ import { OvcInput, OvcOutput, ParsedTransaction, + PrebuildTransactionOptions, PresignTransactionOptions, PublicKey, RecoveryTxRequest, @@ -43,6 +44,8 @@ import { MultisigType, multisigTypes, AuditDecryptedKeyParams, + PopulatedIntent, + PrebuildTransactionWithIntentOptions, } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; @@ -74,6 +77,27 @@ export interface ExplainTransactionOptions { tokenAccountRentExemptAmount?: string; } +export interface SolPrebuildTransactionOptions extends PrebuildTransactionOptions { + splTokenOps?: { + type: 'mint' | 'burn'; + mintAddress: string; + amount: string; + tokenName: string; + decimalPlaces?: number; + programId?: string; + // For mint operations + destinationAddress?: string; + // For burn operations + accountAddress?: string; + // Authority address for both mint and burn + authorityAddress: string; + }[]; +} + +export interface SolPopulatedIntent extends PopulatedIntent { + splTokenOps?: SolPrebuildTransactionOptions['splTokenOps']; +} + export interface TxInfo { recipients: TransactionRecipient[]; from: string; @@ -1413,4 +1437,16 @@ export class Sol extends BaseCoin { } auditEddsaPrivateKey(prv, publicKey ?? ''); } + + /** inherited doc */ + setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void { + // Handle Solana-specific intent fields + if (params.intentType === 'splTokenOps') { + const solParams = params as unknown as SolPrebuildTransactionOptions; + if (solParams.splTokenOps) { + // Cast intent to our extended interface and add the splTokenOps operations + (intent as SolPopulatedIntent).splTokenOps = solParams.splTokenOps; + } + } + } } diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts new file mode 100644 index 0000000000..c30154688b --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts @@ -0,0 +1,332 @@ +import { getBuilderFactory } from '../getBuilderFactory'; +import { KeyPair, Utils } from '../../../src'; +import should from 'should'; +import * as testData from '../../resources/sol'; +import { TransactionType } from '@bitgo/sdk-core'; + +describe('Sol SPL Token Ops Builder', () => { + const factory = getBuilderFactory('tsol'); + const authAccount = new KeyPair(testData.authAccount).getKeys(); + const otherAccount = new KeyPair({ prv: testData.prvKeys.prvKey1.base58 }).getKeys(); + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + const amount = '1000000'; + const nameUSDC = testData.tokenTransfers.nameUSDC; + const mintUSDC = testData.tokenTransfers.mintUSDC; + + describe('Succeed', () => { + it('should build a mint operation transaction', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.mint({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }); + txBuilder.setPriorityFee({ amount: 5000 }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.Send); + + const txJson = tx.toJson(); + txJson.instructionsData.length.should.equal(2); + txJson.instructionsData[0].type.should.equal('SetPriorityFee'); + txJson.instructionsData[1].type.should.equal('MintTo'); + txJson.instructionsData[1].params.should.deepEqual({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + decimalPlaces: undefined, + }); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + + it('should build a burn operation transaction', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.burn({ + mintAddress: mintUSDC, + accountAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }); + txBuilder.setPriorityFee({ amount: 5000 }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.Send); + + const txJson = tx.toJson(); + txJson.instructionsData.length.should.equal(2); + txJson.instructionsData[0].type.should.equal('SetPriorityFee'); + txJson.instructionsData[1].type.should.equal('Burn'); + txJson.instructionsData[1].params.should.deepEqual({ + mintAddress: mintUSDC, + accountAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + decimalPlaces: undefined, + }); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + + it('should build a mixed mint and burn operations transaction', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + // Add mint operation + txBuilder.mint({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }); + + // Add burn operation + txBuilder.burn({ + mintAddress: mintUSDC, + accountAddress: authAccount.pub, + authorityAddress: authAccount.pub, + amount: '500000', + tokenName: nameUSDC, + }); + + txBuilder.setPriorityFee({ amount: 5000 }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.Send); + + const txJson = tx.toJson(); + txJson.instructionsData.length.should.equal(3); + txJson.instructionsData[0].type.should.equal('SetPriorityFee'); + txJson.instructionsData[1].type.should.equal('MintTo'); + txJson.instructionsData[1].params.should.deepEqual({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + decimalPlaces: undefined, + }); + txJson.instructionsData[2].type.should.equal('Burn'); + txJson.instructionsData[2].params.should.deepEqual({ + mintAddress: mintUSDC, + accountAddress: authAccount.pub, + authorityAddress: authAccount.pub, + amount: '500000', + tokenName: nameUSDC, + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + decimalPlaces: undefined, + }); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + + it('should build operations using generic addOperation method', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + txBuilder.addOperation({ + type: 'mint', + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }); + + txBuilder.addOperation({ + type: 'burn', + mintAddress: mintUSDC, + accountAddress: authAccount.pub, + authorityAddress: authAccount.pub, + amount: '250000', + tokenName: nameUSDC, + }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.Send); + + const txJson = tx.toJson(); + txJson.instructionsData.length.should.equal(2); + txJson.instructionsData[0].type.should.equal('MintTo'); + txJson.instructionsData[1].type.should.equal('Burn'); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + + it('should work with token name only (without explicit mint address)', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + txBuilder.mint({ + tokenName: nameUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.Send); + + const txJson = tx.toJson(); + txJson.instructionsData.length.should.equal(1); + txJson.instructionsData[0].type.should.equal('MintTo'); + txJson.instructionsData[0].params.mintAddress.should.equal(mintUSDC); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx), true); + }); + }); + + describe('Build and sign', () => { + it('should build and sign a mint operation transaction', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.mint({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }); + txBuilder.sign({ key: authAccount.prv }); + + const tx = await txBuilder.build(); + tx.type.should.equal(TransactionType.Send); + + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx, true, true), true); + }); + + it('should build and sign a mixed operations transaction', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + txBuilder.mint({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }); + + txBuilder.burn({ + mintAddress: mintUSDC, + accountAddress: authAccount.pub, + authorityAddress: authAccount.pub, + amount: '500000', + tokenName: nameUSDC, + }); + + txBuilder.sign({ key: authAccount.prv }); + + const tx = await txBuilder.build(); + + // Should be a valid signed transaction + const rawTx = tx.toBroadcastFormat(); + should.equal(Utils.isValidRawTransaction(rawTx, true, true), true); + + // Verify transaction structure + const txJson = tx.toJson(); + txJson.instructionsData.length.should.equal(2); + txJson.instructionsData[0].type.should.equal('MintTo'); + txJson.instructionsData[1].type.should.equal('Burn'); + }); + }); + + describe('Fail', () => { + it('should fail when no operations are provided', async () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + await txBuilder.build().should.be.rejectedWith('At least one SPL token operation must be specified'); + }); + + it('should fail with invalid operation type', () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + + should(() => + txBuilder.addOperation({ + type: 'invalid' as any, + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + }) + ).throwError('Operation type must be one of: mint, burn'); + }); + + it('should fail mint operation without destination address', () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + + should(() => + txBuilder.mint({ + mintAddress: mintUSDC, + authorityAddress: authAccount.pub, + amount: amount, + } as any) + ).throwError('Mint operation requires destinationAddress'); + }); + + it('should fail burn operation without account address', () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + + should(() => + txBuilder.burn({ + mintAddress: mintUSDC, + authorityAddress: authAccount.pub, + amount: amount, + } as any) + ).throwError('Burn operation requires accountAddress'); + }); + + it('should fail with invalid amount', () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + + should(() => + txBuilder.mint({ + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: 'invalid', + }) + ).throwError('Invalid amount: invalid'); + }); + + it('should fail with invalid token name and no mint address', () => { + const txBuilder = factory.getSplTokenOpsBuilder(); + + should(() => + txBuilder.mint({ + tokenName: 'invalid-token', + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + }) + ).throwError('Invalid token name or missing mintAddress: invalid-token'); + }); + }); +}); From 4456c0bb505b9080bd8b3b27ab6fe3fd985fb2b9 Mon Sep 17 00:00:00 2001 From: Raj Date: Thu, 31 Jul 2025 11:45:46 +0530 Subject: [PATCH 2/2] save TICKET: TMS-1218 --- modules/sdk-coin-sol/src/lib/constants.ts | 1 - modules/sdk-coin-sol/src/lib/iface.ts | 62 ++---- modules/sdk-coin-sol/src/lib/index.ts | 2 + .../src/lib/splTokenOpsBuilder.ts | 199 ++++++++---------- modules/sdk-coin-sol/src/sol.ts | 17 +- .../transactionBuilder/splTokenOpsBuilder.ts | 47 +++-- 6 files changed, 139 insertions(+), 189 deletions(-) diff --git a/modules/sdk-coin-sol/src/lib/constants.ts b/modules/sdk-coin-sol/src/lib/constants.ts index ad529191b2..2c84113617 100644 --- a/modules/sdk-coin-sol/src/lib/constants.ts +++ b/modules/sdk-coin-sol/src/lib/constants.ts @@ -49,7 +49,6 @@ export enum InstructionBuilderTypes { SetPriorityFee = 'SetPriorityFee', MintTo = 'MintTo', Burn = 'Burn', - SplTokenOps = 'SplTokenOps', } export const VALID_SYSTEM_INSTRUCTION_TYPES: ValidInstructionTypes[] = [ diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index 02806c3949..12ed2cb3d3 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -40,8 +40,7 @@ export type InstructionParams = | StakingAuthorize | StakingDelegate | MintTo - | Burn - | SplTokenOps; + | Burn; export interface Memo { type: InstructionBuilderTypes.Memo; @@ -81,52 +80,34 @@ export interface TokenTransfer { }; } -export interface MintTo { - type: InstructionBuilderTypes.MintTo; - params: { - mintAddress: string; - destinationAddress: string; - authorityAddress: string; - amount: string; - tokenName: string; - decimalPlaces?: number; - programId?: string; - }; -} - -export interface Burn { - type: InstructionBuilderTypes.Burn; - params: { - mintAddress: string; - accountAddress: string; - authorityAddress: string; - amount: string; - tokenName: string; - decimalPlaces?: number; - programId?: string; - }; +export interface MintToParams { + mintAddress?: string; + destinationAddress: string; + authorityAddress: string; + amount: string; + tokenName?: string; + decimalPlaces?: number; + programId?: string; } -export interface SplTokenOperation { - type: 'mint' | 'burn'; +export interface BurnParams { mintAddress?: string; + accountAddress: string; + authorityAddress: string; amount: string; tokenName?: string; decimalPlaces?: number; programId?: string; - // For mint operations - destinationAddress?: string; - // For burn operations - accountAddress?: string; - // Authority address for both mint and burn - authorityAddress: string; } -export interface SplTokenOps { - type: InstructionBuilderTypes.SplTokenOps; - params: { - operations: SplTokenOperation[]; - }; +export interface MintTo { + type: InstructionBuilderTypes.MintTo; + params: MintToParams; +} + +export interface Burn { + type: InstructionBuilderTypes.Burn; + params: BurnParams; } export interface StakingActivate { @@ -207,8 +188,7 @@ export type ValidInstructionTypes = | 'TokenTransfer' | 'SetPriorityFee' | 'MintTo' - | 'Burn' - | 'SplTokenOps'; + | 'Burn'; export type StakingAuthorizeParams = { stakingAddress: string; diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 69c5cc8f9a..6112ce26bd 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -18,5 +18,7 @@ export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { TransferBuilder } from './transferBuilder'; export { TransferBuilderV2 } from './transferBuilderV2'; export { WalletInitializationBuilder } from './walletInitializationBuilder'; +export { MintTo, Burn, MintToParams, BurnParams } from './iface'; +export { InstructionBuilderTypes } from './constants'; export { Interface, Utils }; export { MessageBuilderFactory } from './messages'; diff --git a/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts b/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts index e9523025e6..fec70f9d0a 100644 --- a/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/splTokenOpsBuilder.ts @@ -3,21 +3,16 @@ import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; import { Transaction } from './transaction'; import { getSolTokenFromTokenName, isValidAmount, validateAddress, validateMintAddress } from './utils'; import { InstructionBuilderTypes } from './constants'; -import { MintTo, Burn, SetPriorityFee, SplTokenOperation } from './iface'; +import { MintTo, Burn, SetPriorityFee, MintToParams, BurnParams } from './iface'; import assert from 'assert'; import { TransactionBuilder } from './transactionBuilder'; -/** - * Valid SPL token operation types - */ -const VALID_OPERATION_TYPES = ['mint', 'burn'] as const; - /** * Transaction builder for SPL token mint and burn operations. * Supports mixed operations in a single transaction. */ export class SplTokenOpsBuilder extends TransactionBuilder { - private _operations: SplTokenOperation[] = []; + private _operations: (MintTo | Burn)[] = []; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -30,22 +25,30 @@ export class SplTokenOpsBuilder extends TransactionBuilder { /** * Add a mint operation to the transaction * - * @param operation - The mint operation parameters + * @param params - The mint operation parameters * @returns This transaction builder */ - mint(operation: Omit): this { - this.addOperation({ ...operation, type: 'mint' }); + mint(params: MintToParams): this { + const operation: MintTo = { + type: InstructionBuilderTypes.MintTo, + params, + }; + this.addOperation(operation); return this; } /** * Add a burn operation to the transaction * - * @param operation - The burn operation parameters + * @param params - The burn operation parameters * @returns This transaction builder */ - burn(operation: Omit): this { - this.addOperation({ ...operation, type: 'burn' }); + burn(params: BurnParams): this { + const operation: Burn = { + type: InstructionBuilderTypes.Burn, + params, + }; + this.addOperation(operation); return this; } @@ -55,7 +58,7 @@ export class SplTokenOpsBuilder extends TransactionBuilder { * @param operation - The operation parameters * @returns This transaction builder */ - addOperation(operation: SplTokenOperation): this { + addOperation(operation: MintTo | Burn): this { this.validateOperation(operation); this._operations.push(operation); return this; @@ -65,7 +68,7 @@ export class SplTokenOpsBuilder extends TransactionBuilder { * Validates an SPL token operation * @param operation - The operation to validate */ - private validateOperation(operation: SplTokenOperation): void { + private validateOperation(operation: MintTo | Burn): void { this.validateOperationType(operation.type); this.validateCommonFields(operation); this.validateOperationSpecificFields(operation); @@ -75,79 +78,79 @@ export class SplTokenOpsBuilder extends TransactionBuilder { /** * Validates the operation type */ - private validateOperationType(type: string): void { - if (!type || !(VALID_OPERATION_TYPES as readonly string[]).includes(type)) { - throw new BuildTransactionError(`Operation type must be one of: ${VALID_OPERATION_TYPES.join(', ')}`); + private validateOperationType(type: InstructionBuilderTypes): void { + const validTypes = [InstructionBuilderTypes.MintTo, InstructionBuilderTypes.Burn]; + if (!type || !validTypes.includes(type)) { + throw new BuildTransactionError(`Operation type must be one of: ${validTypes.join(', ')}`); } } /** * Validates fields common to all operations */ - private validateCommonFields(operation: SplTokenOperation): void { - if (!operation.amount || !isValidAmount(operation.amount)) { - throw new BuildTransactionError('Invalid amount: ' + operation.amount); + private validateCommonFields(operation: MintTo | Burn): void { + const params = operation.params; + if (!params.amount || !isValidAmount(params.amount)) { + throw new BuildTransactionError('Invalid amount: ' + params.amount); } - if (!operation.authorityAddress) { + if (!params.authorityAddress) { throw new BuildTransactionError('Operation requires authorityAddress'); } - validateAddress(operation.authorityAddress, 'authorityAddress'); + validateAddress(params.authorityAddress, 'authorityAddress'); } /** * Validates operation-specific fields based on type */ - private validateOperationSpecificFields(operation: SplTokenOperation): void { - switch (operation.type) { - case 'mint': - this.validateMintOperation(operation); - break; - case 'burn': - this.validateBurnOperation(operation); - break; - default: - throw new BuildTransactionError(`Unsupported operation type: ${operation.type}`); + private validateOperationSpecificFields(operation: MintTo | Burn): void { + if (operation.type === InstructionBuilderTypes.MintTo) { + this.validateMintOperation(operation); + } else if (operation.type === InstructionBuilderTypes.Burn) { + this.validateBurnOperation(operation); + } else { + throw new BuildTransactionError(`Unsupported operation type: ${String((operation as { type: string }).type)}`); } } /** * Validates mint-specific fields */ - private validateMintOperation(operation: SplTokenOperation): void { - if (!operation.destinationAddress) { + private validateMintOperation(operation: MintTo): void { + if (!operation.params.destinationAddress) { throw new BuildTransactionError('Mint operation requires destinationAddress'); } - validateAddress(operation.destinationAddress, 'destinationAddress'); + validateAddress(operation.params.destinationAddress, 'destinationAddress'); } /** * Validates burn-specific fields */ - private validateBurnOperation(operation: SplTokenOperation): void { - if (!operation.accountAddress) { + private validateBurnOperation(operation: Burn): void { + if (!operation.params.accountAddress) { throw new BuildTransactionError('Burn operation requires accountAddress'); } - validateAddress(operation.accountAddress, 'accountAddress'); + validateAddress(operation.params.accountAddress, 'accountAddress'); } /** * Validates token information (name or mint address) */ - private validateTokenInformation(operation: SplTokenOperation): void { - if (!operation.tokenName && !operation.mintAddress) { + private validateTokenInformation(operation: MintTo | Burn): void { + const params = operation.params; + if (!params.tokenName && !params.mintAddress) { throw new BuildTransactionError('Either tokenName or mintAddress must be provided'); } - if (operation.tokenName) { - const token = getSolTokenFromTokenName(operation.tokenName); - if (!token && !operation.mintAddress) { - throw new BuildTransactionError('Invalid token name or missing mintAddress: ' + operation.tokenName); + if (params.tokenName) { + const token = getSolTokenFromTokenName(params.tokenName); + if (!token && !params.mintAddress) { + throw new BuildTransactionError('Invalid token name or missing mintAddress: ' + params.tokenName); } } - if (operation.mintAddress) { - validateMintAddress(operation.mintAddress); + if (params.mintAddress) { + validateMintAddress(params.mintAddress); } } @@ -156,27 +159,9 @@ export class SplTokenOpsBuilder extends TransactionBuilder { for (const instruction of this._instructionsData) { if (instruction.type === InstructionBuilderTypes.MintTo) { - const mintInstruction: MintTo = instruction; - this.addOperation({ - type: 'mint', - mintAddress: mintInstruction.params.mintAddress, - destinationAddress: mintInstruction.params.destinationAddress, - authorityAddress: mintInstruction.params.authorityAddress, - amount: mintInstruction.params.amount, - tokenName: mintInstruction.params.tokenName, - programId: mintInstruction.params.programId, - }); + this.addOperation(instruction as MintTo); } else if (instruction.type === InstructionBuilderTypes.Burn) { - const burnInstruction: Burn = instruction; - this.addOperation({ - type: 'burn', - mintAddress: burnInstruction.params.mintAddress, - accountAddress: burnInstruction.params.accountAddress, - authorityAddress: burnInstruction.params.authorityAddress, - amount: burnInstruction.params.amount, - tokenName: burnInstruction.params.tokenName, - programId: burnInstruction.params.programId, - }); + this.addOperation(instruction as Burn); } } } @@ -185,7 +170,7 @@ export class SplTokenOpsBuilder extends TransactionBuilder { protected async buildImplementation(): Promise { assert(this._operations.length > 0, 'At least one SPL token operation must be specified'); - const instructions = this._operations.map((operation) => this.createInstructionFromOperation(operation)); + const instructions = this._operations.map((operation) => this.processOperation(operation)); // Add priority fee instruction if needed if (this._priorityFee && this._priorityFee > 0) { @@ -202,37 +187,38 @@ export class SplTokenOpsBuilder extends TransactionBuilder { } /** - * Creates an instruction from an operation + * Processes an operation to ensure it has complete token information */ - private createInstructionFromOperation(operation: SplTokenOperation): MintTo | Burn { + private processOperation(operation: MintTo | Burn): MintTo | Burn { const tokenInfo = this.resolveTokenInfo(operation); - - switch (operation.type) { - case 'mint': - return this.createMintInstruction(operation, tokenInfo); - case 'burn': - return this.createBurnInstruction(operation, tokenInfo); + const operationType = operation.type; + switch (operationType) { + case InstructionBuilderTypes.MintTo: + return this.enrichMintInstruction(operation, tokenInfo); + case InstructionBuilderTypes.Burn: + return this.enrichBurnInstruction(operation, tokenInfo); default: - throw new BuildTransactionError(`Unsupported operation type: ${operation.type}`); + throw new BuildTransactionError(`Unsupported operation type: ${operationType}`); } } /** * Resolves token information from operation */ - private resolveTokenInfo(operation: SplTokenOperation): { + private resolveTokenInfo(operation: MintTo | Burn): { mintAddress: string; tokenName: string; programId?: string; } { - if (operation.mintAddress) { + const params = operation.params; + if (params.mintAddress) { return { - mintAddress: operation.mintAddress, - tokenName: operation.tokenName || operation.mintAddress, - programId: operation.programId, + mintAddress: params.mintAddress, + tokenName: params.tokenName || params.mintAddress, + programId: params.programId, }; - } else if (operation.tokenName) { - const token = getSolTokenFromTokenName(operation.tokenName); + } else if (params.tokenName) { + const token = getSolTokenFromTokenName(params.tokenName); if (token) { return { mintAddress: token.tokenAddress, @@ -240,7 +226,7 @@ export class SplTokenOpsBuilder extends TransactionBuilder { programId: token.programId, }; } else { - throw new BuildTransactionError('Invalid token name: ' + operation.tokenName); + throw new BuildTransactionError('Invalid token name: ' + params.tokenName); } } else { throw new BuildTransactionError('Either tokenName or mintAddress must be provided'); @@ -248,24 +234,17 @@ export class SplTokenOpsBuilder extends TransactionBuilder { } /** - * Creates a mint instruction + * Enriches a mint instruction with complete token information */ - private createMintInstruction( - operation: SplTokenOperation, + private enrichMintInstruction( + operation: MintTo, tokenInfo: { mintAddress: string; tokenName: string; programId?: string } ): MintTo { - if (!operation.destinationAddress) { - throw new BuildTransactionError('Mint operation requires destinationAddress'); - } - const params = { + ...operation.params, mintAddress: tokenInfo.mintAddress, - destinationAddress: operation.destinationAddress, - authorityAddress: operation.authorityAddress, - amount: operation.amount, tokenName: tokenInfo.tokenName, - programId: tokenInfo.programId, - ...(operation.decimalPlaces !== undefined && { decimalPlaces: operation.decimalPlaces }), + programId: tokenInfo.programId || operation.params.programId, }; return { @@ -275,26 +254,22 @@ export class SplTokenOpsBuilder extends TransactionBuilder { } /** - * Creates a burn instruction + * Enriches a burn instruction with complete token information */ - private createBurnInstruction( - operation: SplTokenOperation, + private enrichBurnInstruction( + operation: Burn, tokenInfo: { mintAddress: string; tokenName: string; programId?: string } ): Burn { - if (!operation.accountAddress) { - throw new BuildTransactionError('Burn operation requires accountAddress'); - } + const params = { + ...operation.params, + mintAddress: tokenInfo.mintAddress, + tokenName: tokenInfo.tokenName, + programId: tokenInfo.programId || operation.params.programId, + }; + return { type: InstructionBuilderTypes.Burn, - params: { - mintAddress: tokenInfo.mintAddress, - accountAddress: operation.accountAddress, - authorityAddress: operation.authorityAddress, - amount: operation.amount, - tokenName: tokenInfo.tokenName, - programId: tokenInfo.programId, - decimalPlaces: operation.decimalPlaces, - }, + params, }; } } diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 6d4424c8e7..6f114f3d53 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -51,7 +51,7 @@ import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import * as _ from 'lodash'; import * as request from 'superagent'; -import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib'; +import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory, MintTo, Burn } from './lib'; import { getAssociatedTokenAccountAddress, getSolTokenFromAddress, @@ -78,20 +78,7 @@ export interface ExplainTransactionOptions { } export interface SolPrebuildTransactionOptions extends PrebuildTransactionOptions { - splTokenOps?: { - type: 'mint' | 'burn'; - mintAddress: string; - amount: string; - tokenName: string; - decimalPlaces?: number; - programId?: string; - // For mint operations - destinationAddress?: string; - // For burn operations - accountAddress?: string; - // Authority address for both mint and burn - authorityAddress: string; - }[]; + splTokenOps?: (MintTo | Burn)[]; } export interface SolPopulatedIntent extends PopulatedIntent { diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts index c30154688b..60414943cc 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/splTokenOpsBuilder.ts @@ -1,5 +1,5 @@ import { getBuilderFactory } from '../getBuilderFactory'; -import { KeyPair, Utils } from '../../../src'; +import { KeyPair, Utils, InstructionBuilderTypes } from '../../../src'; import should from 'should'; import * as testData from '../../resources/sol'; import { TransactionType } from '@bitgo/sdk-core'; @@ -144,21 +144,25 @@ describe('Sol SPL Token Ops Builder', () => { txBuilder.sender(authAccount.pub); txBuilder.addOperation({ - type: 'mint', - mintAddress: mintUSDC, - destinationAddress: otherAccount.pub, - authorityAddress: authAccount.pub, - amount: amount, - tokenName: nameUSDC, + type: InstructionBuilderTypes.MintTo, + params: { + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }, }); txBuilder.addOperation({ - type: 'burn', - mintAddress: mintUSDC, - accountAddress: authAccount.pub, - authorityAddress: authAccount.pub, - amount: '250000', - tokenName: nameUSDC, + type: InstructionBuilderTypes.Burn, + params: { + mintAddress: mintUSDC, + accountAddress: authAccount.pub, + authorityAddress: authAccount.pub, + amount: '250000', + tokenName: nameUSDC, + }, }); const tx = await txBuilder.build(); @@ -270,13 +274,16 @@ describe('Sol SPL Token Ops Builder', () => { should(() => txBuilder.addOperation({ - type: 'invalid' as any, - mintAddress: mintUSDC, - destinationAddress: otherAccount.pub, - authorityAddress: authAccount.pub, - amount: amount, - }) - ).throwError('Operation type must be one of: mint, burn'); + type: 'invalid' as unknown as InstructionBuilderTypes, + params: { + mintAddress: mintUSDC, + destinationAddress: otherAccount.pub, + authorityAddress: authAccount.pub, + amount: amount, + tokenName: nameUSDC, + }, + } as unknown as any) + ).throwError('Operation type must be one of: MintTo, Burn'); }); it('should fail mint operation without destination address', () => {