diff --git a/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts index 97c2a1c2f7..56c0b409cb 100644 --- a/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/tokenTransferBuilder.ts @@ -13,7 +13,7 @@ import { InstructionBuilderTypes } from './constants'; import { AtaInit, TokenAssociateRecipient, TokenTransfer, SetPriorityFee } from './iface'; import assert from 'assert'; import { TransactionBuilder } from './transactionBuilder'; -import _ from 'lodash'; +import * as _ from 'lodash'; export interface SendParams { address: string; @@ -117,6 +117,30 @@ export class TokenTransferBuilder extends TransactionBuilder { /** @inheritdoc */ protected async buildImplementation(): Promise { assert(this._sender, 'Sender must be set before building the transaction'); + + // Validate transaction size limits + // Solana legacy transactions are limited to 1232 bytes + // Empirically determined safe limits: 9 recipients with ATA, 18 without ATA + const uniqueAtaCount = _.uniqBy(this._createAtaParams, (recipient: TokenAssociateRecipient) => { + return recipient.ownerAddress + recipient.tokenName; + }).length; + + if (uniqueAtaCount > 0 && this._sendParams.length > 9) { + throw new BuildTransactionError( + `Transaction too large: ${this._sendParams.length} recipients with ${uniqueAtaCount} ATA creations. ` + + `Solana legacy transactions are limited to 1232 bytes (maximum 9 recipients with ATA creation). ` + + `Please split into multiple transactions with max 9 recipients each.` + ); + } + + if (uniqueAtaCount === 0 && this._sendParams.length > 18) { + throw new BuildTransactionError( + `Transaction too large: ${this._sendParams.length} recipients. ` + + `Solana legacy transactions are limited to 1232 bytes (maximum 18 recipients without ATA creation). ` + + `Please split into multiple transactions with max 18 recipients each.` + ); + } + const sendInstructions = await Promise.all( this._sendParams.map(async (sendParams: SendParams): Promise => { const coin = getSolTokenFromTokenName(sendParams.tokenName); diff --git a/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts b/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts index 40e1bdc0dc..6a77d002a2 100644 --- a/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts +++ b/modules/sdk-coin-sol/src/lib/transferBuilderV2.ts @@ -130,6 +130,30 @@ export class TransferBuilderV2 extends TransactionBuilder { /** @inheritdoc */ protected async buildImplementation(): Promise { assert(this._sender, 'Sender must be set before building the transaction'); + + // Validate transaction size limits + // Solana legacy transactions are limited to 1232 bytes + // Empirically determined safe limits: 9 recipients with ATA, 18 without ATA + const uniqueAtaCount = _.uniqBy(this._createAtaParams, (recipient: TokenAssociateRecipient) => { + return recipient.ownerAddress + recipient.tokenName; + }).length; + + if (uniqueAtaCount > 0 && this._sendParams.length > 9) { + throw new BuildTransactionError( + `Transaction too large: ${this._sendParams.length} recipients with ${uniqueAtaCount} ATA creations. ` + + `Solana legacy transactions are limited to 1232 bytes (maximum 9 recipients with ATA creation). ` + + `Please split into multiple transactions with max 9 recipients each.` + ); + } + + if (uniqueAtaCount === 0 && this._sendParams.length > 18) { + throw new BuildTransactionError( + `Transaction too large: ${this._sendParams.length} recipients. ` + + `Solana legacy transactions are limited to 1232 bytes (maximum 18 recipients without ATA creation). ` + + `Please split into multiple transactions with max 18 recipients each.` + ); + } + const sendInstructions = await Promise.all( this._sendParams.map(async (sendParams: SendParams): Promise => { if (sendParams.tokenName) { diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts index b555b330b0..3915fad04d 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts @@ -796,5 +796,89 @@ describe('Sol Token Transfer Builder', () => { }) ).throwError('Invalid token name, got: ' + invalidTokenName); }); + + it('should fail with more than 9 recipients with ATA creation', async () => { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + // Generate 10 unique recipients for ATA creation + const recipients: string[] = []; + for (let i = 0; i < 10; i++) { + const keypair = new KeyPair(); + recipients.push(keypair.getKeys().pub); + } + + for (const address of recipients) { + txBuilder.send({ address, amount, tokenName: nameUSDC }); + txBuilder.createAssociatedTokenAccount({ + ownerAddress: address, + tokenName: nameUSDC, + }); + } + + await txBuilder + .build() + .should.be.rejectedWith( + /Transaction too large: 10 recipients with 10 ATA creations.*maximum 9 recipients with ATA creation/ + ); + }); + + it('should fail with more than 18 recipients without ATA creation', async () => { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + // Add 19 recipients without ATA creation (reusing addresses is fine without ATA) + for (let i = 0; i < 19; i++) { + // Only use first 3 addresses to avoid invalid address at index 3 + const address = testData.addresses.validAddresses[i % 3]; + txBuilder.send({ address, amount, tokenName: nameUSDC }); + } + + await txBuilder + .build() + .should.be.rejectedWith(/Transaction too large: 19 recipients.*maximum 18 recipients without ATA creation/); + }); + + it('should succeed with 9 recipients with ATA creation', async () => { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + // Generate exactly 9 unique recipients for ATA creation + const recipients: string[] = []; + for (let i = 0; i < 9; i++) { + const keypair = new KeyPair(); + recipients.push(keypair.getKeys().pub); + } + + for (const address of recipients) { + txBuilder.send({ address, amount, tokenName: nameUSDC }); + txBuilder.createAssociatedTokenAccount({ + ownerAddress: address, + tokenName: nameUSDC, + }); + } + + const tx = await txBuilder.build(); + tx.should.be.ok(); + }); + + it('should succeed with 18 recipients without ATA creation', async () => { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + + // Add exactly 18 recipients without ATA creation (reusing addresses is fine without ATA) + for (let i = 0; i < 18; i++) { + // Only use first 3 addresses to avoid invalid address at index 3 + const address = testData.addresses.validAddresses[i % 3]; + txBuilder.send({ address, amount, tokenName: nameUSDC }); + } + + const tx = await txBuilder.build(); + tx.should.be.ok(); + }); }); }); diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/transferBuilderV2.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/transferBuilderV2.ts index 60cd1ebbf8..8fac2a912e 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/transferBuilderV2.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/transferBuilderV2.ts @@ -872,5 +872,93 @@ describe('Sol Transfer Builder V2', () => { `input amount ${excessiveAmount} exceeds max safe int 9007199254740991` ); }); + + it('should fail with more than 9 recipients with ATA creation', async () => { + const txBuilder = factory.getTransferBuilderV2(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.feePayer(feePayerAccount.pub); + + // Generate 10 unique recipients for ATA creation + const recipients: string[] = []; + for (let i = 0; i < 10; i++) { + const keypair = new KeyPair(); + recipients.push(keypair.getKeys().pub); + } + + for (const address of recipients) { + txBuilder.send({ address, amount, tokenName: nameUSDC }); + txBuilder.createAssociatedTokenAccount({ + ownerAddress: address, + tokenName: nameUSDC, + }); + } + + await txBuilder + .build() + .should.be.rejectedWith( + /Transaction too large: 10 recipients with 10 ATA creations.*maximum 9 recipients with ATA creation/ + ); + }); + + it('should fail with more than 18 token recipients without ATA creation', async () => { + const txBuilder = factory.getTransferBuilderV2(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.feePayer(feePayerAccount.pub); + + // Add 19 token recipients without ATA creation (reusing addresses is fine without ATA) + for (let i = 0; i < 19; i++) { + // Only use first 3 addresses to avoid invalid address at index 3 + const address = testData.addresses.validAddresses[i % 3]; + txBuilder.send({ address, amount, tokenName: nameUSDC }); + } + + await txBuilder + .build() + .should.be.rejectedWith(/Transaction too large: 19 recipients.*maximum 18 recipients without ATA creation/); + }); + + it('should succeed with 9 recipients with ATA creation', async () => { + const txBuilder = factory.getTransferBuilderV2(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.feePayer(feePayerAccount.pub); + + // Generate exactly 9 unique recipients for ATA creation + const recipients: string[] = []; + for (let i = 0; i < 9; i++) { + const keypair = new KeyPair(); + recipients.push(keypair.getKeys().pub); + } + + for (const address of recipients) { + txBuilder.send({ address, amount, tokenName: nameUSDC }); + txBuilder.createAssociatedTokenAccount({ + ownerAddress: address, + tokenName: nameUSDC, + }); + } + + const tx = await txBuilder.build(); + tx.should.be.ok(); + }); + + it('should succeed with 18 token recipients without ATA creation', async () => { + const txBuilder = factory.getTransferBuilderV2(); + txBuilder.nonce(recentBlockHash); + txBuilder.sender(authAccount.pub); + txBuilder.feePayer(feePayerAccount.pub); + + // Add exactly 18 token recipients without ATA creation (reusing addresses is fine without ATA) + for (let i = 0; i < 18; i++) { + // Only use first 3 addresses to avoid invalid address at index 3 + const address = testData.addresses.validAddresses[i % 3]; + txBuilder.send({ address, amount, tokenName: nameUSDC }); + } + + const tx = await txBuilder.build(); + tx.should.be.ok(); + }); }); });