TON DocsTON Docs
OnboardingNodesApplicationsAPIsContractsSmart contractsTolkTolk languageTVMTON Virtual MachineFoundationsBlockchain foundations

How to send a batch of transfers

Funds at risk: This guide sends real GRAM. Test on testnet first. Double-check recipient addresses — blockchain transactions cannot be reversed.

This guide sends up to 254 transfers in one batch. The batch uses an external transaction to queue a self-message, then an internal transaction to process its action list.

Objective

By the end of this guide, you will:

  • Send multiple transfers (up to 254) in one batch
  • Understand the two-transaction flow for batch transfers
  • Set the GRAM value attached to the internal self-message

Prerequisites

  • Completed wallet creation with enough balance for the 1 GRAM self-message, outgoing values, and transaction fees; saved configuration in .wallet.json
  • Use the Node.js project and wrapper files created in that guide. Create wallet-send-batch.ts and append each TypeScript block below in order.
  • For an authenticated TON Center endpoint, inject TONCENTER_API_KEY from protected storage.

Step 1: Load wallet configuration

Load the public wallet data and inject its mnemonic from protected storage. Do not store the mnemonic in .wallet.json, source control, or logs.

// Not runnable: append to wallet-send-batch.ts after setup on the create page.
import { TonClient, internal, toNano, comment } from '@ton/ton';
import { mnemonicToPrivateKey } from '@ton/crypto';
import { Address, Cell, SendMode } from '@ton/core';
import { HighloadWalletV3 } from './wrappers/HighloadWalletV3';
import { HighloadQueryId } from './wrappers/HighloadQueryId';
import * as fs from 'fs';

// Load public wallet configuration
const walletData = JSON.parse(fs.readFileSync('.wallet.json', 'utf-8'));
const mnemonicPhrase = process.env.TON_WALLET_MNEMONIC;
if (!mnemonicPhrase) throw new Error('Inject TON_WALLET_MNEMONIC from protected storage.');
const mnemonic = mnemonicPhrase.trim().split(/\s+/);
const keyPair = await mnemonicToPrivateKey(mnemonic);

const CODE = Cell.fromBoc(Buffer.from('b5ee9c7241021001000228000114ff00f4a413f4bcf2c80b01020120020d02014803040078d020d74bc00101c060b0915be101d0d3030171b0915be0fa4030f828c705b39130e0d31f018210ae42e5a4ba9d8040d721d74cf82a01ed55fb04e030020120050a02027306070011adce76a2686b85ffc00201200809001aabb6ed44d0810122d721d70b3f0018aa3bed44d08307d721d70b1f0201200b0c001bb9a6eed44d0810162d721d70b15800e5b8bf2eda2edfb21ab09028409b0ed44d0810120d721f404f404d33fd315d1058e1bf82325a15210b99f326df82305aa0015a112b992306dde923033e2923033e25230800df40f6fa19ed021d721d70a00955f037fdb31e09130e259800df40f6fa19cd001d721d70a00937fdb31e0915be270801f6f2d48308d718d121f900ed44d0d3ffd31ff404f404d33fd315d1f82321a15220b98e12336df82324aa00a112b9926d32de58f82301de541675f910f2a106d0d31fd4d307d30cd309d33fd315d15168baf2a2515abaf2a6f8232aa15250bcf2a304f823bbf2a35304800df40f6fa199d024d721d70a00f2649130e20e01fe5309800df40f6fa18e13d05004d718d20001f264c858cf16cf8301cf168e1030c824cf40cf8384095005a1a514cf40e2f800c94039800df41704c8cbff13cb1ff40012f40012cb3f12cb15c9ed54f80f21d0d30001f265d3020171b0925f03e0fa4001d70b01c000f2a5fa4031fa0031f401fa0031fa00318060d721d300010f0020f265d2000193d431d19130e272b1fb00b585bf03', 'hex'))[0];

const client = new TonClient({
    endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC',
    ...(process.env.TONCENTER_API_KEY ? { apiKey: process.env.TONCENTER_API_KEY } : {}),
});

const walletContract = HighloadWalletV3.createFromConfig(
    {
        publicKey: keyPair.publicKey,
        subwalletId: walletData.subwalletId,
        timeout: walletData.timeout,
    },
    CODE
);
if (!walletContract.address.equals(Address.parse(walletData.address))) {
    throw new Error('Mnemonic does not match the wallet address in .wallet.json.');
}
const wallet = client.open(walletContract);

Step 2: Prepare the message batch

Set TON_TESTNET_RECIPIENT to a valid testnet address, then define the messages in the batch:

// Not runnable: append to wallet-send-batch.ts after the configuration block.
const requestMessages = [];
const recipient = process.env.TON_TESTNET_RECIPIENT;
if (!recipient) throw new Error('Set TON_TESTNET_RECIPIENT to a testnet address.');

for (let i = 1; i <= 10; i++) {
    const messageValue = toNano('0.001');
    const text = `#${i}`;
    requestMessages.push({
        recipient,
        valueNano: messageValue.toString(),
        comment: text,
        bounce: false,
        mode: SendMode.PAY_GAS_SEPARATELY,
    });
}

Each entry records the recipient, value, comment, bounce flag, and send mode. Step 3 rebuilds the internal messages from these saved fields before signing.

Batch limit: You can send up to 254 messages per transaction. This limit exists because one action slot is reserved for set_code protection.

Step 3: Send the batch

Send the batch using the sendBatch method:

// Not runnable: append to wallet-send-batch.ts after the message batch block.
// Reuse a saved request only when its stable ID and complete payload match.
function getOrReserveRequest(requestId: string, request: Record<string, unknown>) {
    const lockFd = fs.openSync('.wallet.json.lock', 'wx', 0o600);
    try {
        const current = JSON.parse(fs.readFileSync('.wallet.json', 'utf-8'));
        current.requests ??= [];
        const saved = current.requests.find((entry: any) => entry.requestId === requestId);
        if (saved) {
            if (JSON.stringify(saved.request) !== JSON.stringify(request)) {
                throw new Error('TON_REQUEST_ID already belongs to a different payload.');
            }
            const timeout = Number(request.timeout);
            if (!Number.isSafeInteger(saved.createdAt) || saved.createdAt + timeout <= Math.floor(Date.now() / 1000)) {
                throw new Error('Saved request is too old to retry by the local clock. Check its on-chain outcome before assigning a new ID.');
            }
            const queryId = HighloadQueryId.fromSeqno(BigInt(saved.seqno));
            if (queryId.getQueryId().toString() !== saved.queryId) {
                throw new Error('Saved query ID does not match its sequence number.');
            }
            return { request: saved.request, queryId, createdAt: saved.createdAt };
        }

        if (!Number.isSafeInteger(current.nextQuerySeqno) || current.nextQuerySeqno < 0) {
            throw new Error('Invalid nextQuerySeqno in .wallet.json.');
        }

        const queryId = HighloadQueryId.fromSeqno(BigInt(current.nextQuerySeqno));
        if (!queryId.hasNext()) throw new Error('Highload query ID space is exhausted.');

        const createdAt = Math.floor(Date.now() / 1000) - 30;
        const nextQueryId = queryId.getNext();
        current.nextQuerySeqno = Number(nextQueryId.toSeqno());
        current.requests.push({
            requestId,
            queryId: queryId.getQueryId().toString(),
            seqno: queryId.toSeqno().toString(),
            createdAt,
            request,
        });
        fs.writeFileSync('.wallet.json.tmp', JSON.stringify(current, null, 2), { mode: 0o600 });
        fs.renameSync('.wallet.json.tmp', '.wallet.json');
        return { request, queryId, createdAt };
    } finally {
        fs.closeSync(lockFd);
        fs.unlinkSync('.wallet.json.lock');
    }
}

const requestId = process.env.TON_REQUEST_ID?.trim();
if (!requestId) throw new Error('Set TON_REQUEST_ID to a stable ID for this intended batch.');
const request = {
    kind: 'batch',
    subwalletId: walletData.subwalletId,
    timeout: walletData.timeout,
    selfValueNano: toNano('1').toString(),
    messages: requestMessages,
};
const saved = getOrReserveRequest(requestId, request);
const savedRequest = saved.request;
const messages = savedRequest.messages.map((message: any) => ({
    type: 'sendMsg' as const,
    mode: Number(message.mode),
    outMsg: internal({
        to: Address.parse(String(message.recipient)),
        value: BigInt(String(message.valueNano)),
        body: comment(String(message.comment)),
        bounce: Boolean(message.bounce),
    }),
}));
const queryId = saved.queryId;
const createdAt = saved.createdAt;
const value = BigInt(String(savedRequest.selfValueNano));

await wallet.sendBatch(
    keyPair.secretKey,
    messages,
    Number(savedRequest.subwalletId),
    queryId,
    Number(savedRequest.timeout),
    createdAt,
    value
);

console.log('Batch sent: 10 transfers');
console.log(`Query ID: ${queryId.getQueryId()}`);
console.log(`Created At: ${createdAt}`);

Each rebuilt action uses type: 'sendMsg', its mode, and an outMsg created by internal().

TON_REQUEST_ID identifies one intended batch. If submission is uncertain, rerun the command with the same ID and inputs. A retry reuses the saved query ID, timestamp, and payload; changed inputs fail.

Once the saved timestamp is at least timeout seconds old by the local clock, the script stops retries. Before replacing the request, wait for a confirmed block in the wallet's shard with a timestamp at or beyond createdAt + timeout. Resolve its self-message and outgoing messages using the batch verification guide. Use a new TON_REQUEST_ID for a replacement batch containing only transfers confirmed not to have executed.

The exclusive lock serializes reservations across processes. Remove a stale lock only after confirming that no sender process is running. For a service with many requests, use a transactional database for the allocator and outbox.

In a private shell, inject the mnemonic without putting it in shell history, set a testnet recipient, and run the script:

read -r -s -p 'Wallet mnemonic: ' TON_WALLET_MNEMONIC
printf '\n'
export TON_WALLET_MNEMONIC
read -r -p 'Testnet recipient: ' TON_TESTNET_RECIPIENT
export TON_TESTNET_RECIPIENT
read -r -p 'Batch ID (record for retries): ' TON_REQUEST_ID
export TON_REQUEST_ID
npx tsx wallet-send-batch.ts

Automatic deployment: If your wallet is in uninit status (has a balance but no code), the external transaction deploys the wallet, records the query ID, and queues a self-message. A second, internal transaction processes the batch.

Parameter explanation

queryId — Unique identifier for replay protection:

  • Each query_id can only be processed once within the protection window
  • HighloadQueryId is a wrapper class that represents the composite query_id as a sequential counter
  • Use HighloadQueryId.fromSeqno(n) to construct the next persisted sequence value
  • getNext() returns a new wrapper object; it does not mutate the current one
  • Total range: 8,380,416 unique IDs
  • The query ID structure reference defines the counter layout

createdAt — Message timestamp for expiration:

  • Set to 30 seconds before current time: Math.floor(Date.now() / 1000) - 30
  • Compensates for blockchain time lag (lite-servers use last block time, not current time)
  • The timestamp validation rules explain this offset

value — Value attached to the internal self-message:

  • The self-message carries the action list and its GRAM value funds the internal transaction
  • This example attaches 1 GRAM; choose enough to cover internal execution and forwarding costs at submission time
  • The wallet must have this value, all outgoing message values, and applicable fees available when it processes the external message
  • The internal transaction credits the self-message value back to the same wallet before processing actions; fees and outgoing transfers reduce the wallet balance
  • In an offline sandbox run with one simple transfer, the internal receiver used 1,756 gas. At 400 nanograms per gas, that is 0.0007024 GRAM in compute fees; this excludes self-message forwarding and storage costs
  • If the self-message cannot fund processing, the query ID remains consumed even if the internal transaction fails

Compute fee requirement: The value parameter covers gas for the internal transaction that processes the action list. This is in addition to the gas costs of the external message itself.

How it works

Batch transfers use a two-transaction pattern: the external message marks the query_id as processed and sends an internal message to itself with the action list, then the internal transaction processes the actions and sends all outgoing messages.

The message sending flow reference gives the complete validation sequence.

Next steps

For parallel batches, use an atomic shared allocator so each request gets a unique query_id.

The batch verification guide shows how to verify wallet-side processing.

On this page