Skip to content

Commit e2c71f5

Browse files
committed
fix(sdk-coin-sol): add early validation for transaction size limits
TICKET: WIN-8401
1 parent b5f002e commit e2c71f5

File tree

5 files changed

+247
-16
lines changed

5 files changed

+247
-16
lines changed

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ export const UNAVAILABLE_TEXT = 'UNAVAILABLE';
2828
*/
2929
export const SOLANA_TRANSACTION_MAX_SIZE = 1232;
3030

31+
/**
32+
* Maximum safe recipient limits for Solana token transfers
33+
*
34+
* These limits are based on empirical testing to stay within SOLANA_TRANSACTION_MAX_SIZE (1232 bytes).
35+
* Source: modules/sdk-coin-sol/scripts/transaction-size-benchmark-results.json
36+
*
37+
* With ATA Creation: Includes Associated Token Account initialization instructions
38+
* Without ATA Creation: Recipients already have token accounts
39+
*/
40+
export const MAX_RECIPIENTS_WITH_ATA_CREATION = 9;
41+
export const MAX_RECIPIENTS_WITHOUT_ATA_CREATION = 19;
42+
3143
export const JITO_STAKE_POOL_ADDRESS = 'Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb';
3244
export const JITOSOL_MINT_ADDRESS = 'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn';
3345
export const JITO_STAKE_POOL_RESERVE_ACCOUNT = 'BgKUXdS29YcHCFrPm5M8oLHiTzZaMDjsebggjoaQ6KFL';

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

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import {
99
validateMintAddress,
1010
validateOwnerAddress,
1111
} from './utils';
12-
import { InstructionBuilderTypes } from './constants';
12+
import * as Constants from './constants';
1313
import { AtaInit, TokenAssociateRecipient, TokenTransfer, SetPriorityFee } from './iface';
1414
import assert from 'assert';
1515
import { TransactionBuilder } from './transactionBuilder';
16-
import _ from 'lodash';
16+
import * as _ from 'lodash';
1717

1818
export interface SendParams {
1919
address: string;
@@ -43,7 +43,7 @@ export class TokenTransferBuilder extends TransactionBuilder {
4343
super.initBuilder(tx);
4444

4545
for (const instruction of this._instructionsData) {
46-
if (instruction.type === InstructionBuilderTypes.TokenTransfer) {
46+
if (instruction.type === Constants.InstructionBuilderTypes.TokenTransfer) {
4747
const transferInstruction: TokenTransfer = instruction;
4848
this.sender(transferInstruction.params.fromAddress);
4949
this.send({
@@ -55,7 +55,7 @@ export class TokenTransferBuilder extends TransactionBuilder {
5555
decimalPlaces: transferInstruction.params.decimalPlaces,
5656
});
5757
}
58-
if (instruction.type === InstructionBuilderTypes.CreateAssociatedTokenAccount) {
58+
if (instruction.type === Constants.InstructionBuilderTypes.CreateAssociatedTokenAccount) {
5959
const ataInitInstruction: AtaInit = instruction;
6060
this._createAtaParams.push({
6161
ownerAddress: ataInitInstruction.params.ownerAddress,
@@ -117,6 +117,29 @@ export class TokenTransferBuilder extends TransactionBuilder {
117117
/** @inheritdoc */
118118
protected async buildImplementation(): Promise<Transaction> {
119119
assert(this._sender, 'Sender must be set before building the transaction');
120+
121+
const uniqueAtaCount = _.uniqBy(this._createAtaParams, (recipient: TokenAssociateRecipient) => {
122+
return recipient.ownerAddress + recipient.tokenName;
123+
}).length;
124+
125+
if (uniqueAtaCount > 0 && this._sendParams.length > Constants.MAX_RECIPIENTS_WITH_ATA_CREATION) {
126+
throw new BuildTransactionError(
127+
`Transaction too large: ${this._sendParams.length} recipients with ${uniqueAtaCount} ATA creations. ` +
128+
`Solana legacy transactions are limited to ${Constants.SOLANA_TRANSACTION_MAX_SIZE} bytes ` +
129+
`(maximum ${Constants.MAX_RECIPIENTS_WITH_ATA_CREATION} recipients with ATA creation). ` +
130+
`Please split into multiple transactions with max ${Constants.MAX_RECIPIENTS_WITH_ATA_CREATION} recipients each.`
131+
);
132+
}
133+
134+
if (uniqueAtaCount === 0 && this._sendParams.length > Constants.MAX_RECIPIENTS_WITHOUT_ATA_CREATION) {
135+
throw new BuildTransactionError(
136+
`Transaction too large: ${this._sendParams.length} recipients. ` +
137+
`Solana legacy transactions are limited to ${Constants.SOLANA_TRANSACTION_MAX_SIZE} bytes ` +
138+
`(maximum ${Constants.MAX_RECIPIENTS_WITHOUT_ATA_CREATION} recipients without ATA creation). ` +
139+
`Please split into multiple transactions with max ${Constants.MAX_RECIPIENTS_WITHOUT_ATA_CREATION} recipients each.`
140+
);
141+
}
142+
120143
const sendInstructions = await Promise.all(
121144
this._sendParams.map(async (sendParams: SendParams): Promise<TokenTransfer> => {
122145
const coin = getSolTokenFromTokenName(sendParams.tokenName);
@@ -139,7 +162,7 @@ export class TokenTransferBuilder extends TransactionBuilder {
139162
}
140163
const sourceAddress = await getAssociatedTokenAccountAddress(tokenAddress, this._sender, false, programId);
141164
return {
142-
type: InstructionBuilderTypes.TokenTransfer,
165+
type: Constants.InstructionBuilderTypes.TokenTransfer,
143166
params: {
144167
fromAddress: this._sender,
145168
toAddress: sendParams.address,
@@ -180,7 +203,7 @@ export class TokenTransferBuilder extends TransactionBuilder {
180203
ataAddress = await getAssociatedTokenAccountAddress(tokenAddress, recipient.ownerAddress, false, programId);
181204
}
182205
return {
183-
type: InstructionBuilderTypes.CreateAssociatedTokenAccount,
206+
type: Constants.InstructionBuilderTypes.CreateAssociatedTokenAccount,
184207
params: {
185208
ownerAddress: recipient.ownerAddress,
186209
mintAddress: tokenAddress,
@@ -193,7 +216,7 @@ export class TokenTransferBuilder extends TransactionBuilder {
193216
})
194217
);
195218
const addPriorityFeeInstruction: SetPriorityFee = {
196-
type: InstructionBuilderTypes.SetPriorityFee,
219+
type: Constants.InstructionBuilderTypes.SetPriorityFee,
197220
params: {
198221
fee: this._priorityFee,
199222
},

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1313
import assert from 'assert';
1414
import { AtaInit, TokenAssociateRecipient, TokenTransfer, Transfer, SetPriorityFee } from './iface';
15-
import { InstructionBuilderTypes } from './constants';
15+
import * as Constants from './constants';
1616
import _ from 'lodash';
1717

1818
export interface SendParams {
@@ -42,22 +42,22 @@ export class TransferBuilderV2 extends TransactionBuilder {
4242
super.initBuilder(tx);
4343

4444
for (const instruction of this._instructionsData) {
45-
if (instruction.type === InstructionBuilderTypes.Transfer) {
45+
if (instruction.type === Constants.InstructionBuilderTypes.Transfer) {
4646
const transferInstruction: Transfer = instruction;
4747
this.sender(transferInstruction.params.fromAddress);
4848
this.send({
4949
address: transferInstruction.params.toAddress,
5050
amount: transferInstruction.params.amount,
5151
});
52-
} else if (instruction.type === InstructionBuilderTypes.TokenTransfer) {
52+
} else if (instruction.type === Constants.InstructionBuilderTypes.TokenTransfer) {
5353
const transferInstruction: TokenTransfer = instruction;
5454
this.sender(transferInstruction.params.fromAddress);
5555
this.send({
5656
address: transferInstruction.params.toAddress,
5757
amount: transferInstruction.params.amount,
5858
tokenName: transferInstruction.params.tokenName,
5959
});
60-
} else if (instruction.type === InstructionBuilderTypes.CreateAssociatedTokenAccount) {
60+
} else if (instruction.type === Constants.InstructionBuilderTypes.CreateAssociatedTokenAccount) {
6161
const ataInitInstruction: AtaInit = instruction;
6262
this._createAtaParams.push({
6363
ownerAddress: ataInitInstruction.params.ownerAddress,
@@ -130,6 +130,30 @@ export class TransferBuilderV2 extends TransactionBuilder {
130130
/** @inheritdoc */
131131
protected async buildImplementation(): Promise<Transaction> {
132132
assert(this._sender, 'Sender must be set before building the transaction');
133+
134+
// Validate transaction size limits
135+
const uniqueAtaCount = _.uniqBy(this._createAtaParams, (recipient: TokenAssociateRecipient) => {
136+
return recipient.ownerAddress + recipient.tokenName;
137+
}).length;
138+
139+
if (uniqueAtaCount > 0 && this._sendParams.length > Constants.MAX_RECIPIENTS_WITH_ATA_CREATION) {
140+
throw new BuildTransactionError(
141+
`Transaction too large: ${this._sendParams.length} recipients with ${uniqueAtaCount} ATA creations. ` +
142+
`Solana legacy transactions are limited to ${Constants.SOLANA_TRANSACTION_MAX_SIZE} bytes ` +
143+
`(maximum ${Constants.MAX_RECIPIENTS_WITH_ATA_CREATION} recipients with ATA creation). ` +
144+
`Please split into multiple transactions with max ${Constants.MAX_RECIPIENTS_WITH_ATA_CREATION} recipients each.`
145+
);
146+
}
147+
148+
if (uniqueAtaCount === 0 && this._sendParams.length > Constants.MAX_RECIPIENTS_WITHOUT_ATA_CREATION) {
149+
throw new BuildTransactionError(
150+
`Transaction too large: ${this._sendParams.length} recipients. ` +
151+
`Solana legacy transactions are limited to ${Constants.SOLANA_TRANSACTION_MAX_SIZE} bytes ` +
152+
`(maximum ${Constants.MAX_RECIPIENTS_WITHOUT_ATA_CREATION} recipients without ATA creation). ` +
153+
`Please split into multiple transactions with max ${Constants.MAX_RECIPIENTS_WITHOUT_ATA_CREATION} recipients each.`
154+
);
155+
}
156+
133157
const sendInstructions = await Promise.all(
134158
this._sendParams.map(async (sendParams: SendParams): Promise<Transfer | TokenTransfer> => {
135159
if (sendParams.tokenName) {
@@ -154,7 +178,7 @@ export class TransferBuilderV2 extends TransactionBuilder {
154178

155179
const sourceAddress = await getAssociatedTokenAccountAddress(tokenAddress, this._sender, false, programId);
156180
return {
157-
type: InstructionBuilderTypes.TokenTransfer,
181+
type: Constants.InstructionBuilderTypes.TokenTransfer,
158182
params: {
159183
fromAddress: this._sender,
160184
toAddress: sendParams.address,
@@ -168,7 +192,7 @@ export class TransferBuilderV2 extends TransactionBuilder {
168192
};
169193
} else {
170194
return {
171-
type: InstructionBuilderTypes.Transfer,
195+
type: Constants.InstructionBuilderTypes.Transfer,
172196
params: {
173197
fromAddress: this._sender,
174198
toAddress: sendParams.address,
@@ -205,7 +229,7 @@ export class TransferBuilderV2 extends TransactionBuilder {
205229
programId
206230
);
207231
return {
208-
type: InstructionBuilderTypes.CreateAssociatedTokenAccount,
232+
type: Constants.InstructionBuilderTypes.CreateAssociatedTokenAccount,
209233
params: {
210234
ownerAddress: recipient.ownerAddress,
211235
tokenName: tokenName,
@@ -224,10 +248,10 @@ export class TransferBuilderV2 extends TransactionBuilder {
224248
this._instructionsData = [...createAtaInstructions, ...sendInstructions];
225249
} else if (
226250
createAtaInstructions.length !== 0 ||
227-
sendInstructions.some((instruction) => instruction.type === InstructionBuilderTypes.TokenTransfer)
251+
sendInstructions.some((instruction) => instruction.type === Constants.InstructionBuilderTypes.TokenTransfer)
228252
) {
229253
addPriorityFeeInstruction = {
230-
type: InstructionBuilderTypes.SetPriorityFee,
254+
type: Constants.InstructionBuilderTypes.SetPriorityFee,
231255
params: {
232256
fee: this._priorityFee,
233257
},

modules/sdk-coin-sol/test/unit/transactionBuilder/tokenTransferBuilder.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,5 +796,89 @@ describe('Sol Token Transfer Builder', () => {
796796
})
797797
).throwError('Invalid token name, got: ' + invalidTokenName);
798798
});
799+
800+
it('should fail with more than 9 recipients with ATA creation', async () => {
801+
const txBuilder = factory.getTokenTransferBuilder();
802+
txBuilder.nonce(recentBlockHash);
803+
txBuilder.sender(authAccount.pub);
804+
805+
// Generate 10 unique recipients for ATA creation
806+
const recipients: string[] = [];
807+
for (let i = 0; i < 10; i++) {
808+
const keypair = new KeyPair();
809+
recipients.push(keypair.getKeys().pub);
810+
}
811+
812+
for (const address of recipients) {
813+
txBuilder.send({ address, amount, tokenName: nameUSDC });
814+
txBuilder.createAssociatedTokenAccount({
815+
ownerAddress: address,
816+
tokenName: nameUSDC,
817+
});
818+
}
819+
820+
await txBuilder
821+
.build()
822+
.should.be.rejectedWith(
823+
/Transaction too large: 10 recipients with 10 ATA creations.*maximum 9 recipients with ATA creation/
824+
);
825+
});
826+
827+
it('should fail with more than 19 recipients without ATA creation', async () => {
828+
const txBuilder = factory.getTokenTransferBuilder();
829+
txBuilder.nonce(recentBlockHash);
830+
txBuilder.sender(authAccount.pub);
831+
832+
// Add 20 recipients without ATA creation (reusing addresses is fine without ATA)
833+
for (let i = 0; i < 20; i++) {
834+
// Only use first 3 addresses to avoid invalid address at index 3
835+
const address = testData.addresses.validAddresses[i % 3];
836+
txBuilder.send({ address, amount, tokenName: nameUSDC });
837+
}
838+
839+
await txBuilder
840+
.build()
841+
.should.be.rejectedWith(/Transaction too large: 20 recipients.*maximum 19 recipients without ATA creation/);
842+
});
843+
844+
it('should succeed with 9 recipients with ATA creation', async () => {
845+
const txBuilder = factory.getTokenTransferBuilder();
846+
txBuilder.nonce(recentBlockHash);
847+
txBuilder.sender(authAccount.pub);
848+
849+
// Generate exactly 9 unique recipients for ATA creation
850+
const recipients: string[] = [];
851+
for (let i = 0; i < 9; i++) {
852+
const keypair = new KeyPair();
853+
recipients.push(keypair.getKeys().pub);
854+
}
855+
856+
for (const address of recipients) {
857+
txBuilder.send({ address, amount, tokenName: nameUSDC });
858+
txBuilder.createAssociatedTokenAccount({
859+
ownerAddress: address,
860+
tokenName: nameUSDC,
861+
});
862+
}
863+
864+
const tx = await txBuilder.build();
865+
tx.should.be.ok();
866+
});
867+
868+
it('should succeed with 19 recipients without ATA creation', async () => {
869+
const txBuilder = factory.getTokenTransferBuilder();
870+
txBuilder.nonce(recentBlockHash);
871+
txBuilder.sender(authAccount.pub);
872+
873+
// Add exactly 19 recipients without ATA creation (reusing addresses is fine without ATA)
874+
for (let i = 0; i < 19; i++) {
875+
// Only use first 3 addresses to avoid invalid address at index 3
876+
const address = testData.addresses.validAddresses[i % 3];
877+
txBuilder.send({ address, amount, tokenName: nameUSDC });
878+
}
879+
880+
const tx = await txBuilder.build();
881+
tx.should.be.ok();
882+
});
799883
});
800884
});

modules/sdk-coin-sol/test/unit/transactionBuilder/transferBuilderV2.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,5 +872,93 @@ describe('Sol Transfer Builder V2', () => {
872872
`input amount ${excessiveAmount} exceeds max safe int 9007199254740991`
873873
);
874874
});
875+
876+
it('should fail with more than 9 recipients with ATA creation', async () => {
877+
const txBuilder = factory.getTransferBuilderV2();
878+
txBuilder.nonce(recentBlockHash);
879+
txBuilder.sender(authAccount.pub);
880+
txBuilder.feePayer(feePayerAccount.pub);
881+
882+
// Generate 10 unique recipients for ATA creation
883+
const recipients: string[] = [];
884+
for (let i = 0; i < 10; i++) {
885+
const keypair = new KeyPair();
886+
recipients.push(keypair.getKeys().pub);
887+
}
888+
889+
for (const address of recipients) {
890+
txBuilder.send({ address, amount, tokenName: nameUSDC });
891+
txBuilder.createAssociatedTokenAccount({
892+
ownerAddress: address,
893+
tokenName: nameUSDC,
894+
});
895+
}
896+
897+
await txBuilder
898+
.build()
899+
.should.be.rejectedWith(
900+
/Transaction too large: 10 recipients with 10 ATA creations.*maximum 9 recipients with ATA creation/
901+
);
902+
});
903+
904+
it('should fail with more than 19 token recipients without ATA creation', async () => {
905+
const txBuilder = factory.getTransferBuilderV2();
906+
txBuilder.nonce(recentBlockHash);
907+
txBuilder.sender(authAccount.pub);
908+
txBuilder.feePayer(feePayerAccount.pub);
909+
910+
// Add 20 token recipients without ATA creation (reusing addresses is fine without ATA)
911+
for (let i = 0; i < 20; i++) {
912+
// Only use first 3 addresses to avoid invalid address at index 3
913+
const address = testData.addresses.validAddresses[i % 3];
914+
txBuilder.send({ address, amount, tokenName: nameUSDC });
915+
}
916+
917+
await txBuilder
918+
.build()
919+
.should.be.rejectedWith(/Transaction too large: 20 recipients.*maximum 19 recipients without ATA creation/);
920+
});
921+
922+
it('should succeed with 9 recipients with ATA creation', async () => {
923+
const txBuilder = factory.getTransferBuilderV2();
924+
txBuilder.nonce(recentBlockHash);
925+
txBuilder.sender(authAccount.pub);
926+
txBuilder.feePayer(feePayerAccount.pub);
927+
928+
// Generate exactly 9 unique recipients for ATA creation
929+
const recipients: string[] = [];
930+
for (let i = 0; i < 9; i++) {
931+
const keypair = new KeyPair();
932+
recipients.push(keypair.getKeys().pub);
933+
}
934+
935+
for (const address of recipients) {
936+
txBuilder.send({ address, amount, tokenName: nameUSDC });
937+
txBuilder.createAssociatedTokenAccount({
938+
ownerAddress: address,
939+
tokenName: nameUSDC,
940+
});
941+
}
942+
943+
const tx = await txBuilder.build();
944+
tx.should.be.ok();
945+
});
946+
947+
it('should succeed with 19 token recipients without ATA creation', async () => {
948+
const txBuilder = factory.getTransferBuilderV2();
949+
txBuilder.nonce(recentBlockHash);
950+
txBuilder.sender(authAccount.pub);
951+
txBuilder.feePayer(feePayerAccount.pub);
952+
953+
// Add exactly 19 token recipients without ATA creation (reusing addresses is fine without ATA)
954+
for (let i = 0; i < 19; i++) {
955+
// Only use first 3 addresses to avoid invalid address at index 3
956+
const address = testData.addresses.validAddresses[i % 3];
957+
txBuilder.send({ address, amount, tokenName: nameUSDC });
958+
}
959+
960+
const tx = await txBuilder.build();
961+
tx.should.be.ok();
962+
});
875963
});
876964
});

0 commit comments

Comments
 (0)