TON DocsTON Docs
OnboardingNodesApplicationsAPIsContractsSmart contractsTolkTolk languageTVMTON Virtual MachineFoundationsBlockchain foundations

Highload Wallet v3 — specification

This page provides a complete technical specification for Highload Wallet v3, covering storage structure, message formats, replay protection, limitations, and security mechanisms.

Objective

Understand the internal architecture, data structures, and operational mechanics of Highload Wallet v3. This reference page explains how the wallet processes high-throughput transaction flows securely.

For practical usage, see How-to guides.

What is Highload Wallet v3?

Highload Wallet v3 is a specialized wallet smart contract designed for services that need to send many transactions in a short time (e.g., cryptocurrency exchanges, payment processors).

Key difference from standard wallets:
Unlike seqno-based wallets (v3, v4, v5), which require clients to coordinate the next seqno, Highload v3 stores processed request identifiers in a dictionary. Clients can submit requests concurrently with distinct query_id values; the account still processes transactions sequentially.

Compared to Highload v2:
v3 solves architectural issues that could lock funds in v2 and consumes less gas during operations.

TL-B schema

TL-B (Type Language - Binary) is a domain-specific language designed to describe data structures in TON. The schemas below define the binary layout of the contract's storage, external messages, and internal messages.

Storage structure

storage$_ public_key:bits256 subwallet_id:uint32 old_queries:(HashmapE 13 ^Cell)
          queries:(HashmapE 13 ^Cell) last_clean_time:uint64 timeout:uint22
          = Storage;

Query ID structure

_ shift:uint13 bit_number:(## 10) { bit_number >= 0 } { bit_number < 1023 } = QueryId;

External message structure

_ {n:#} subwallet_id:uint32 message_to_send:^Cell send_mode:uint8 query_id:QueryId
  created_at:uint64 timeout:uint22 = MsgInner;

msg_body$_ {n:#} signature:bits512 ^(MsgInner) = ExternalInMsgBody;

Internal message structure (for batch transfers)

internal_transfer#ae42e5a4 {n:#} query_id:uint64 actions:^(OutList n) = InternalMsgBody n;

internal_transfer is used when the wallet sends a message to itself with an action list for batch transfers. The opcode 0xae42e5a4 is derived from crc32('internal_transfer n:# query_id:uint64 actions:^OutList n = InternalMsgBody n').

The canonical TL-B schemas are maintained in the highload-wallet-contract-v3 repository.

Below, each field is explained in detail.

Storage structure

The Highload Wallet v3 contract stores six persistent fields:

public_key (256 bits)

Purpose:
An Ed25519 public key is used to verify signatures on incoming external messages.

How it works:
When the wallet receives an external message, it verifies that the 512-bit signature was created by the holder of the private key corresponding to this public key.

The public key is not the wallet address. The address is derived from the contract's StateInit.

subwallet_id (32 bits)

Purpose:
Allows a single keypair to control multiple wallets with different addresses.

How it works:
The subwallet_id is part of the contract's initial state. Changing it produces a different contract address (because the address is a hash of code + data). Each external message must include the correct subwallet_id; mismatches result in exit code 34.

Common use case:
One private key manages multiple "virtual" wallets for organizational or accounting purposes.

Recommendation:
The official Highload Wallet v3 repository uses subwallet_id: 0x10ad (4269) as a convention. Different wallet code produces different addresses even when the key and subwallet_id match. Use distinct subwalletId values to distinguish wallets with the same code and remaining configuration.

old_queries (HashmapE 13 ^Cell)

Purpose:
Stores previously processed query_id values during rotation cycles.

Structure:
Hashmap with 13-bit keys, where each key holds a bitmap of processed query IDs.

Usage:
Provides a second layer of replay protection. During cleanup, queries → old_queries, creating a double timeout window.

Details: See Replay protection mechanism.

queries (HashmapE 13 ^Cell)

Purpose:
Stores recently processed query_id values to prevent replay attacks.

Structure:
Hashmap with 13-bit keys, where each key holds a bitmap of processed query IDs.

Usage:
When a message arrives, the contract checks if its query_id is already marked in queries or old_queries. If found, the message is rejected as a replay attempt.

Details: See Replay protection mechanism.

last_clean_time (64 bits)

Purpose:
Unix timestamp (in seconds) of the last cleanup operation.

Usage:
Tracks when the contract last rotated queries → old_queries. Cleanup triggers when current_time > last_clean_time + timeout.

Details: See Replay protection mechanism.

timeout (22 bits)

Purpose:
Defines the validity window (in seconds) for external messages.

Usage:
Messages are valid if created_at > now() - timeout and created_at <= now(). If expired or from the future, the contract rejects them with exit code 35.

22 bits encodes values from 0 to 4,194,303 seconds (about 48.6 days). A zero timeout has no valid timestamp because the lower bound is strict; choose a positive timeout. If your client subtracts 30 seconds from the current time, use a timeout greater than 30 seconds.

Details:

Replay protection mechanism

Highload v3 uses a dual-hashmap system (queries and old_queries) combined with timestamps to prevent replay attacks.

Storage structure for replay protection

The contract stores processed query_id values in two hashmaps:

old_queries (HashmapE 13 ^Cell):

  • Hashmap with 13-bit keys
  • Each key corresponds to shift (13 bits from query_id)
  • Each value is a cell containing a bitmap of processed bit_number values
  • Stores previously processed query IDs from the last rotation cycle
  • Provides extended protection during rotation

queries (HashmapE 13 ^Cell):

  • Same structure as old_queries — hashmap with 13-bit keys
  • Each key corresponds to shift = query_id >> 10
  • Each value is a cell containing a bitmap of processed bit_number values
  • Stores recently processed query IDs

How query_id is checked

When an external message arrives, the contract:

  1. Extracts query_id from the message
  2. Splits it into components:
    • shift = query_id >> 10 (13 bits, range 0–8191)
    • bit_number = query_id & 1023 (10 bits, range 0–1022)
  3. Checks if bit bit_number is set in queries[shift]:
    • If found → reject with exit code 36
  4. Checks if bit bit_number is set in old_queries[shift]:
    • If found → reject with exit code 36
  5. If not found in either → mark the bit in queries[shift] and proceed

Why a hashmap structure?
Enables parallel transaction submission — multiple messages can be sent simultaneously without waiting for sequential confirmation.

Rotation mechanism

When the contract processes an external request after current_time > last_clean_time + timeout, it performs cleanup:

  1. old_queries := queries — move current queries to old
  2. queries := {} — clear current queries hashmap
  3. last_clean_time := current_time — update timestamp

Additional cleanup:
If current_time > last_clean_time + (2 × timeout) when an external request is processed, the contract also clears old_queries completely to prevent unbounded storage growth.

Why two hashmaps?
This provides a double timeout window for replay protection:

  • A query_id remains protected while its marker is in either hashmap
  • Cleanup is request-driven, so an idle wallet can retain markers longer than 2 × timeout
  • Message timestamp validation independently rejects a replay after its signed validity window

Benefit:
Prevents replay attacks even if messages arrive near the rotation boundary.

Timestamp validation

The created_at timestamp combined with timeout ensures that even very old messages (beyond the rotation window) are rejected. This creates a time-based boundary for message validity:

The message is valid if:
  created_at > now() - timeout
  created_at <= now()
Otherwise: reject with exit code 35

Time lag consideration: When a lite-server receives an external message, the contract executes now() which returns the timestamp of the last processed block, not the current system time. Due to network latency and block processing time, this timestamp is typically ~1 seconds behind your system clock.

Best practice: Set created_at to 30-60 seconds before the current time to ensure the message passes validation:

// Not runnable: use when constructing the signed request.
const createdAt = Math.floor(Date.now() / 1000) - 30;

If created_at equals your current system time, it may appear to be "from the future" when validated on-chain, causing the transaction to fail with exit code 35.

Uniqueness guarantee

Highload v3 will never execute multiple external messages containing the same query_id and created_at — by the time it forgets any given query_id, the created_at condition will prevent execution of such a message. This effectively makes query_id and created_at together the "primary key" of a transfer request for Highload v3.

Why internal messages to self?

For batch transfers, Highload v3 queues the action list in an internal message to self. This separates replay protection from the action phase that sends the batch.

The transaction rollback risk:

In TON, transaction processing includes several phases. Two phases are critical for understanding this problem:

  1. Compute phase — executes smart contract code, updates storage
  2. Action phase — performs actions (sends messages)

If the action phase fails (e.g., insufficient funds for outgoing messages), the entire transaction is rolled back, including all storage changes made in the compute phase.

If a contract records a replay marker in the same transaction that sends outgoing messages, an action-phase failure can roll back both the messages and the marker. Whether this applies depends on the contract's commit behavior and message flow.

Highload v3's batch flow:

For a batch, Highload v3 uses two transactions:

  1. Transaction 1 (external message): Marks query_id as processed and sends an internal message to itself with the action list
  2. Transaction 2 (internal message): Processes the action list and sends outgoing transfers

If Transaction 2 fails in its action phase, Transaction 1 has already succeeded. That failure cannot roll back Transaction 1's replay marker, so the query_id remains consumed.

The direct single-transfer path sends its one message in Transaction 1 and does not use this internal self-message pattern.

External message structure

External messages sent to Highload v3 have a specific layout.

Message layout

signature:bits512
^[ subwallet_id:uint32
   message_to_send:^Cell
   send_mode:uint8
   query_id:QueryId
   created_at:uint64
   timeout:uint22 ]

Key point:
The signature is in the root cell (512 bits); all other parameters are in a reference cell (MsgInner).

Gas optimization: This structure saves ~500 gas units during signature verification. If the signature were in the same cell as the message body, the contract would need to use slice_hash() (which rebuilds the cell internally, costing extra gas) instead of simply taking cell_hash() of the reference.

signature (512 bits)

Type:
Ed25519 signature (512 bits).

What is signed:
The hash of the reference cell (MsgInner) containing subwallet_id, message_to_send, send_mode, query_id, created_at, and timeout.

Validation:
The contract verifies the signature using:

check_signature(hash(ref_cell), signature, public_key)

On failure:
Exit code 33.

Link: Ed25519 signature scheme

subwallet_id (32 bits)

Purpose:
Identifies which subwallet this message targets.

Validation:
Must match the subwallet_id stored in contract storage.

On mismatch:
Exit code 34.

query_id (composite structure)

The query_id follows the QueryId TL-B structure and is split into two parts:

  • shift (uint13, 13 bits): high-order bits (range 0 to 8191)
  • bit_number (## 10): low-order bits with constraints { bit_number >= 0 } { bit_number < 1023 } (range 0 to 1022)

Total range:
2^13 × 1023 = 8,380,416 possible unique query IDs.

How it maps to the hashmap:

hashmap_key = shift (13 bits)
bit_index = bit_number (10 bits)

The contract checks if bit bit_number is set in the cell stored at hashmap[shift].

Recommendation:
Increment query_id sequentially using a counter-based strategy.

created_at (64 bits)

Purpose:
Unix timestamp (seconds) when the external message was created.

Validation:
The contract performs two checks:

created_at > now() - timeout  // Message not too old
created_at <= now()           // Message not from future

On failure:
Exit code 35.

Why it matters:
Prevents replay of expired messages. Even if a query_id is eventually forgotten, stale messages are rejected based on created_at. See Timestamp validation for important time lag considerations.

timeout (22 bits)

Purpose:
Defines the message validity window (in seconds).

Validation:
Must match the timeout value stored in contract storage.

On mismatch:
Exit code 38.

A 22-bit unsigned integer holds values from 0 through 4,194,303 seconds (about 48.6 days).

send_mode (8 bits)

Purpose:
Specifies the send mode for the internal message.

Link: send_raw_message modes

message_to_send (reference cell)

Structure:
A serialized internal message stored in a reference cell.

Validation (exit code 37):

The contract validates message_to_send after committing storage to prevent action phase errors. The following checks are performed:

  1. Must be internal message:
    First bit must be 0 (int_msg_info$0, not ext_msg_info$10)

  2. Source address must be none:
    The src field must be addr_none (empty address)

  3. State-init must not be present:
    State-init validation is too expensive in gas and is rarely needed. For contract deployment, use the batch transfer pattern with an action list

  4. Bounced messages are ignored:
    If the bounced flag is set, the message is silently ignored (no error)

Why validate after commit:
Validation occurs after commit() to ensure replay protection is saved even if the message structure is invalid. This prevents the same external message from being retried infinitely by lite-servers.

Critical limitation:
Highload v3 can send only ONE internal message per external message. For batch transfers, use the internal_transfer pattern with an action list (up to 254 messages).

Message sending flow

Highload v3 uses a two-transaction pattern for batch transfers:

Message sending flow

Why two transactions?

For a batch, Transaction 1 commits the query_id before Transaction 2 attempts its outgoing messages. A failure in Transaction 2 cannot roll back Transaction 1. Direct single transfers send one message in Transaction 1 and do not create a self-message transaction.

Critical detail: Message validation (step 9) happens after commit (step 8) to prevent infinite retries of invalid messages by lite-servers.

See Single message per external for details on this limitation and the batch transfer workaround.

Exit codes

Exit codeNameDescriptionHow to fix
0SuccessMessage processed successfully—
33Invalid signatureEd25519 signature verification failedCheck that the private key is correct and the message hash is computed properly
34Subwallet ID mismatchThe subwallet_id in the message does not match storageVerify you are using the correct subwallet_id for this wallet
35Invalid created_atMessage timestamp is invalid (too old or from the future)Ensure created_at > now() - timeout and created_at <= now()
36Query already executedThe query_id was already processed (found in queries or old_queries)Use a new, unique query_id
37Invalid messageThe message_to_send structure is invalid or cannot be processedVerify the message cell structure and contents
38Invalid timeoutThe timeout in the message does not match storage timeoutVerify you are using the correct timeout value for this wallet

Limitations and constraints

Single message per external

Limitation:
Each external message can trigger only one outgoing internal message directly.

Why this limitation?
Manually validating message structure is expensive in gas costs. The contract validates only the single message_to_send reference to keep gas consumption predictable and low.

Why no state-init support?
State-init validation is complex and gas-intensive, while deploying contracts from a highload wallet is rarely needed. The feature was intentionally excluded to reduce gas costs.

Workaround for batch transfers:
Send an internal message to the wallet itself with opcode 0xae42e5a4 (internal_transfer) and an action list containing up to 254 outgoing messages (not 255, because one action slot is reserved for set_code protection).

How to implement: Send batch transfers

Query ID space limitations

Highload v3 supports up to 8,380,416 unique query IDs (see query_id structure for details).

Safe query ID allocation

Allocate a new query_id for each new request and persist the allocator state before sending. Reserve IDs atomically so concurrent processes cannot allocate the same value. A crash after reservation may leave a gap; skipping an ID is safe.

The TypeScript HighloadQueryId.getNext() method returns a new object and does not mutate the current one. Assign the returned value and persist it. The repository wrapper reserves the final value for emergency withdrawal; check hasNext() before reserving.

For an uncertain submission, look up the saved request and retry it with the same query_id, created_at, and action payload. Do not allocate a new ID for a transport retry. Allocate a new ID for a new request. Keep each request's ID, timestamp, and payload in a durable outbox so the request can be reconstructed after a restart.

Do not reset the counter or reuse an ID based only on elapsed time. A processed ID remains protected while its marker is present in either hashmap. Reuse an ID only after cleanup has removed its marker from both maps and no request using it can still be valid.

Timeout constraints

The timeout value affects message validity, storage costs, and operational behavior:

Message validity:
Messages are valid for timeout seconds after created_at. Expired messages are rejected with exit code 35.

Storage costs:
Processed query_id markers remain in storage until a later external request runs cleanup. With regular activity, cleanup rotates the maps at timeout intervals; an idle wallet can retain markers longer than 2 × timeout. Longer timeouts can increase storage size and costs.

Operational impact:

  • Short timeout (seconds/minutes): Fast expiration certainty, but messages may expire during network congestion
  • Long timeout (hours): Messages survive congestion, but slow failure detection and higher storage costs

Gas consumption

Gas costs vary depending on the number of outgoing messages sent:

OperationTransaction 1 (external)Transaction 2 (internal)Total
1 messageTBDTBDTBD
10 messagesTBDTBDTBD
254 messagesTBDTBDTBD

Replay bookkeeping:

Gas for replay bookkeeping depends on entries in the queries and old_queries hashmaps. Cleanup and rotation add work as those maps grow. This does not describe the full transaction cost, which also includes message import, self-message forwarding, and outgoing actions.

Forward fees:

The external message incurs its import and processing costs. The wallet then pays a forwarding fee for the internal message to itself and forwarding fees for outgoing messages. The external inbound message is not a second on-chain forwarding hop, so do not estimate forward fees by doubling a single-transaction wallet's total.

Forward fees scale with:

  • Number of outgoing messages
  • Size and complexity of message content

Get methods

Highload Wallet v3 provides several read-only methods for monitoring and verification.

MethodReturnsDescription
get_public_key()int (256 bits)Returns the stored public key
get_subwallet_id()int (32 bits)Returns the subwallet ID
get_timeout()intReturns the current timeout value (stored as uint22)
get_last_clean_time()int (64 bits)Returns the Unix timestamp of the last cleanup
processed?(query_id, need_clean)int (boolean)Returns whether query_id is currently processed, optionally evaluating cleanup

processed? method details

Parameters:

  • query_id (int): The query ID to check.
  • need_clean (int): If non-zero, evaluate the query after applying the time-based cleanup logic for this getter call.

Returns:

  • -1 if processed, 0 if not.

The getter returns one stack value. A getter call does not persist cleanup changes to contract storage. The repository wrapper reads the value as a boolean and defaults need_clean to true.

Use case:
Before sending a message, check if a query_id was already used to avoid replay errors.

Link: How to verify if a message is processed

Protection against set_code

Why this protection is needed:

The contract uses set_actions(actions) to execute arbitrary actions from the internal message (this allows sending batch transfers). However, leaving the ability to execute set_code actions would be unsafe — it creates a risk of accidentally changing the contract code.

How the protection works:

In recv_internal (Transaction 2), after extracting the action list from the internal message, the contract executes:

cell old_code = my_code();
set_actions(actions);      // Apply action list from message
set_code(old_code);        // Immediately restore original code

This pattern prevents any set_code action in the action list from taking effect. Even if an action list accidentally contains a set_code instruction, the final set_code(old_code) call overwrites it, ensuring the contract code remains unchanged.

Action list limitation:
Because the contract calls set_code(old_code) as a protection mechanism, one action slot is consumed. This is why the maximum number of outgoing messages in a batch is 254 (not 255) — one slot is reserved for the set_code protection.

Implementation

Source code: ton-blockchain/highload-wallet-contract-v3

SDK wrappers:

Tests and examples: See the tests/ directory in the repository for reference implementation and usage patterns.

Link: How-to guides

See also

On this page