TON DocsTON Docs
OnboardingNodesApplicationsAPIsContractsSmart contractsTolkTolk languageTVMTON Virtual MachineFoundationsBlockchain foundations

How to verify a Highload Wallet v3 batch

This guide verifies wallet-side processing of a batch across the external and internal transactions. It does not verify whether recipient contracts processed the outgoing messages.

Objective

By the end of this guide, you will:

  • Verify that both wallet transactions (external and internal) succeeded
  • Count outgoing messages created by the wallet
  • Detect partial failures in batch transfers

Prerequisites

  • Completed wallet creation with funded balance
  • Sent at least one batch using the batch guide
  • Know the query_id printed by the batch guide
  • Use the Node.js project and wrapper files created in the wallet creation guide. Create wallet-verify-batch.ts and append each TypeScript block below in order.
  • For an authenticated TON Center endpoint, inject TONCENTER_API_KEY from protected storage.

Verification overview

Highload Wallet v3 uses a two-transaction pattern for batches. Check both wallet transactions to verify dispatch:

  1. External transaction (Transaction 1): Validates the message and marks query_id as processed
  2. Internal transaction (Transaction 2): Processes the action list and creates outgoing messages

Even if processed? returns true, the internal transaction may have failed. Checking both transactions confirms wallet-side dispatch, not recipient execution. A direct single transfer uses sendExternalMessage and sends its message in the external transaction; it has no self-message transaction to trace with this guide.

The message sending flow reference describes both transactions.

Helper functions

Create helper functions for transaction search and parsing:

// Not runnable: append to wallet-verify-batch.ts after setup on the create page.
import { TonClient } from '@ton/ton';
import { Address, beginCell, Cell, Message, storeMessage, Transaction } from '@ton/core';

// Retry helper for network requests
async function retry<T>(fn: () => Promise<T>, options: { retries: number; delay: number }): Promise<T> {
    let lastError: unknown;
    for (let i = 0; i < options.retries; i++) {
        try {
            return await fn();
        } catch (e) {
            lastError = e;
            await new Promise((resolve) => setTimeout(resolve, options.delay));
        }
    }
    throw lastError instanceof Error ? lastError : new Error('Transaction request failed after retries.');
}

// Parse external message to extract query_id and created_at
function parseExternalMessageBody(body: Cell) {
    try {
        const inner = body.refs[0]!.beginParse();
        // Skip subwalletId and mode.
        inner.skip(32 + 8);
        const queryId = inner.loadUintBig(23);
        const createdAt = inner.loadUint(64);

        return { queryId, createdAt };
    } catch (e) {
        return null;
    }
}

// Generic transaction finder with pagination
async function findTransaction(
    client: TonClient,
    address: Address,
    predicate: (tx: Transaction) => boolean
): Promise<Transaction | null> {
    let lt: string | undefined = undefined;
    let hash: string | undefined = undefined;

    while (true) {
        const transactions = await retry(
            () => client.getTransactions(address, {
                hash,
                lt,
                limit: 20,
                archival: true,
                inclusive: false,
            }),
            { delay: 1000, retries: 3 }
        );

        if (transactions.length === 0) return null;

        const found = transactions.find(predicate);
        if (found) return found;

        const last = transactions.at(-1)!;
        const nextLt = last.lt.toString();
        const nextHash = last.hash().toString('base64');
        if (nextLt === lt && nextHash === hash) {
            throw new Error('Transaction pagination did not advance.');
        }
        lt = nextLt;
        hash = nextHash;
    }
}

Step 1: Set up and check if processed

Load wallet configuration and check if the query_id was marked as processed:

// Not runnable: append to wallet-verify-batch.ts after the helper functions.
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 client = new TonClient({
    endpoint: 'https://testnet.toncenter.com/api/v2/jsonRPC',
    ...(process.env.TONCENTER_API_KEY ? { apiKey: process.env.TONCENTER_API_KEY } : {}),
});

// Address-only access is sufficient for GET methods and transaction history.
const highloadWallet = HighloadWalletV3.createFromAddress(Address.parse(walletData.address));
const wallet = client.open(highloadWallet);

const queryIdValue = process.env.HIGHLOAD_QUERY_ID;
if (!queryIdValue) throw new Error('Set HIGHLOAD_QUERY_ID to the value printed by the send guide.');
if (!Array.isArray(walletData.requests)) throw new Error('No request history exists in .wallet.json.');
const requestRecord = walletData.requests.find(
    (record: { queryId: string; request?: { kind?: string } }) => record.queryId === queryIdValue
);
if (!requestRecord || requestRecord.request?.kind !== 'batch') {
    throw new Error('HIGHLOAD_QUERY_ID must identify a saved batch request.');
}

const queryId = HighloadQueryId.fromQueryId(BigInt(requestRecord.queryId));
const createdAt = requestRecord.createdAt;

// Check if processed
// Read the stored maps without simulating pending cleanup.
const isProcessed = await wallet.getProcessed(queryId, false);
if (isProcessed) {
    console.log('Query ID has an active replay marker');
} else {
    console.log('No active replay marker; continue with transaction history lookup');
}

What processed? tells you: This GET method checks the replay-protection maps and can evaluate a pending cleanup when need_clean is true. A false result can mean the request was never processed or its marker would be removed by cleanup. The getter does not persist cleanup, and a false result does not prove an older request failed; continue with transaction history lookup.

The processed? method reference defines the getter's behavior.

Step 2: Find and verify external transaction

Search transaction history to find the external transaction by query_id and created_at:

// Not runnable: append to wallet-verify-batch.ts after the setup block.
// Find external transaction by query_id + created_at
async function findHighloadExternalTransaction(
    client: TonClient,
    walletAddress: Address,
    queryId: HighloadQueryId,
    createdAt: number
): Promise<Transaction | null> {
    const targetQueryId = queryId.getQueryId();

    return findTransaction(client, walletAddress, (tx) => {
        if (tx.inMessage?.info.type !== 'external-in') return false;
        if (!tx.inMessage.body) return false;

        const parsed = parseExternalMessageBody(tx.inMessage.body);
        if (!parsed) return false;

        return parsed.queryId === targetQueryId && parsed.createdAt === createdAt;
    });
}

const externalTx = await findHighloadExternalTransaction(
    client,
    highloadWallet.address,
    queryId,
    createdAt
);

if (!externalTx) {
    throw new Error('External transaction not found. It may be pending or rejected before acceptance.');
}

// Verify external transaction compute and action phases.
if (externalTx.description.type !== 'generic' || externalTx.description.computePhase.type !== 'vm') {
    throw new Error('External transaction has no successful VM compute phase.');
}

const externalCompute = externalTx.description.computePhase;
if (!externalCompute.success || externalCompute.exitCode !== 0) {
    throw new Error(`External transaction failed: exit code ${externalCompute.exitCode}`);
}
if (!externalTx.description.actionPhase?.success) {
    throw new Error(`External transaction action phase failed: ${externalTx.description.actionPhase?.resultCode}`);
}

console.log('✓ External transaction succeeded');

If the external transaction failed, see Exit codes for troubleshooting. An external message rejected before transaction acceptance has no history entry. Check the provider or emulator result for its exit code; for example, timestamp exit code 35 can be returned without a transaction appearing in history.

Step 3: Find and verify internal transaction

Follow the transaction chain to find and verify Transaction 2:

// Not runnable: append to wallet-verify-batch.ts after the external-transaction block.
function messageHash(message: Message): string {
    return beginCell().store(storeMessage(message)).endCell().hash().toString('hex');
}

async function findHighloadInternalTransaction(
    client: TonClient,
    walletAddress: Address,
    selfMessage: Message
): Promise<Transaction | null> {
    const expectedHash = messageHash(selfMessage);
    return findTransaction(client, walletAddress, (tx) => {
        const inbound = tx.inMessage;
        return inbound?.info.type === 'internal' && messageHash(inbound) === expectedHash;
    });
}

const selfMessage = Array.from(externalTx.outMessages.values()).find(
    (message) => message.info.type === 'internal' && message.info.dest.equals(highloadWallet.address)
);
if (!selfMessage) {
    throw new Error('No internal self-message from the external batch transaction.');
}

const internalTx = await findHighloadInternalTransaction(
    client,
    highloadWallet.address,
    selfMessage
);

if (!internalTx || internalTx.description.type !== 'generic') {
    throw new Error('Internal self-message transaction not found.');
}

// Verify compute phase
if (internalTx.description.computePhase.type !== 'vm') {
    throw new Error('Internal transaction compute phase was skipped.');
}

const internalCompute = internalTx.description.computePhase;
if (!internalCompute.success || internalCompute.exitCode !== 0) {
    throw new Error(`Internal transaction failed: exit code ${internalCompute.exitCode}`);
}

// Verify action phase
if (!internalTx.description.actionPhase) {
    throw new Error('No action phase in internal transaction.');
}

const action = internalTx.description.actionPhase;
if (!action.success) {
    throw new Error(`Action phase failed: result code ${action.resultCode}`);
}

console.log('✓ Internal transaction succeeded');

Transaction linking: The helper matches the exact self-message hash in the external transaction's outgoing messages to the internal transaction's inbound message. prevTransactionLt identifies the previous transaction for the wallet account; an unrelated inbound message can be next in that sequence.

Step 4: Count outgoing messages created

Count the outgoing messages created by the wallet action phase:

// Not runnable: append to wallet-verify-batch.ts after the internal-transaction block.
// Count send actions (total actions - 1 for set_code)
const messageActions = action.totalActions - 1;
const messagesSent = action.messagesCreated;
const messagesFailed = action.skippedActions;

if (messagesFailed > 0) {
    console.log(`Created ${messagesSent}/${messageActions} outgoing messages (${messagesFailed} skipped)`);
} else {
    console.log(`Created ${messagesSent}/${messageActions} outgoing messages`);
}

Partial failures: Even if the action phase succeeds overall, some send actions may be skipped. Check skippedActions to detect them. This count does not include failures in recipient transactions.

The totalActions - 1 calculation accounts for the set_code protection action.

Expected outputs

Create the script by appending the five TypeScript blocks above in order. In a shell, set the batch query ID and run it:

read -r -p 'Batch query ID: ' HIGHLOAD_QUERY_ID
export HIGHLOAD_QUERY_ID
npx tsx wallet-verify-batch.ts

The failure examples show the first error line; runtime stack frames are omitted.

Full success

Query ID has an active replay marker
✓ External transaction succeeded
✓ Internal transaction succeeded
Created 10/10 outgoing messages

Partial success

Query ID has an active replay marker
✓ External transaction succeeded
✓ Internal transaction succeeded
Created 8/10 outgoing messages (2 skipped)

External transaction not found

Error: External transaction not found. It may be pending or rejected before acceptance.

An external message rejected for timestamp exit code 35 is not accepted as a transaction, so this history lookup cannot find it. Inspect the provider or emulator result for the rejection code; the query ID is not marked as processed. The exit codes reference explains the code.

Internal transaction failure

Query ID has an active replay marker
✓ External transaction succeeded
Error: Internal transaction failed: exit code 9

Transaction 1 succeeded (replay protection applied), but Transaction 2 failed. The query_id cannot be reused.

Action phase failure

Query ID has an active replay marker
✓ External transaction succeeded
Error: Action phase failed: result code 37

Both transactions were executed, but the action phase failed to send messages.

Next steps

This verifies that the wallet created the outgoing messages. Check recipient transaction history separately to confirm downstream execution.

On this page