How to send a single transfer
Funds at risk: This guide sends real GRAM. Test on testnet first. Double-check recipient addresses — blockchain transactions cannot be reversed.
This guide shows how to send a single transfer from your Highload Wallet v3.
Objective
By the end of this guide, you will:
- Send a single GRAM transfer from your Highload Wallet v3
- Understand how
query_id,created_at, and send modes work
Prerequisites
- Completed wallet creation with funded balance and saved configuration in
.wallet.json - Use the Node.js project and wrapper files created in that guide. Create
wallet-send-single.tsand append each TypeScript block below in order. - For an authenticated TON Center endpoint, inject
TONCENTER_API_KEYfrom protected storage.
Step 1: Load wallet configuration
Load the wallet data and create the wallet instance:
// Not runnable: append to wallet-send-single.ts after setup on the create page.
import { TonClient, internal, toNano } 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 transfer message
Set TON_TESTNET_RECIPIENT to a valid testnet address and define the transfer inputs:
// Not runnable: append to wallet-send-single.ts after the configuration block.
const recipient = process.env.TON_TESTNET_RECIPIENT;
if (!recipient) throw new Error('Set TON_TESTNET_RECIPIENT to a testnet address.');
const transferValue = toNano('0.01');No state-init support: Highload Wallet v3 cannot send messages containing StateInit using this method.
If you need to deploy a contract (send a message with StateInit), use batch transfers with an action list instead. The limitations and constraints reference describes the supported messages.
Step 3: Send the transfer
Send the transfer using the sendExternalMessage method:
// Not runnable: append to wallet-send-single.ts after the transfer-message 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 transfer.');
const request = {
kind: 'single',
recipient: Address.parse(recipient).toRawString(),
valueNano: transferValue.toString(),
bounce: false,
body: 'empty',
mode: SendMode.PAY_GAS_SEPARATELY,
subwalletId: walletData.subwalletId,
timeout: walletData.timeout,
};
const saved = getOrReserveRequest(requestId, request);
const savedRequest = saved.request;
const internalMessage = internal({
to: Address.parse(String(savedRequest.recipient)),
value: BigInt(String(savedRequest.valueNano)),
bounce: Boolean(savedRequest.bounce),
});
await wallet.sendExternalMessage(keyPair.secretKey, {
message: internalMessage,
mode: Number(savedRequest.mode),
query_id: saved.queryId,
createdAt: saved.createdAt,
subwalletId: Number(savedRequest.subwalletId),
timeout: Number(savedRequest.timeout),
});
console.log('Transfer sent');
console.log(`Query ID: ${saved.queryId.getQueryId()}`);
console.log(`Created at: ${saved.createdAt}`);TON_REQUEST_ID identifies one intended transfer. 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. Check the wallet and recipient histories and resolve pending outgoing messages. Use a new TON_REQUEST_ID for a replacement only after confirming the transfer did not execute.
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 stable request ID, then 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 'Payment ID (record for retries): ' TON_REQUEST_ID
export TON_REQUEST_ID
npx tsx wallet-send-single.tsAutomatic deployment: If your wallet is in uninit status (has a balance but no code), this external message deploys it and sends the transfer directly. Deployment and transfer happen in the same transaction.
Parameter explanation
query_id — Unique identifier for replay protection:
- Each
query_idcan only be processed once within the protection window HighloadQueryIdis a wrapper class that represents the compositequery_idas a sequential countergetNext()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
Next steps
Your wallet is ready to send transfers. Use an atomic shared allocator before sending requests in parallel.
To verify a direct transfer, find the wallet's external transaction by its saved queryId and createdAt. Confirm that its VM compute and action phases succeeded, then compare the outgoing message with the saved recipient, value, and bounce flag. This example uses an empty body. Check the recipient's transaction history to confirm downstream execution. This method sends directly from the external transaction; it has no self-message transaction. The batch verification guide covers only the two-transaction batch flow.