Highload wallet v2: Specification
Deprecated: Highload Wallet v2 is a legacy contract. Use Highload Wallet v3 for new deployments.
This specification is maintained for reference to support existing legacy systems.
This page provides a complete technical specification for Highload Wallet v2, covering storage structure, message formats, replay protection, and limitations.
What is Highload Wallet v2?
Highload Wallet v2 is a specialized wallet contract designed for services that need to send many transactions in a short time. It uses dictionary-based replay protection to enable parallel transaction submission.
Key difference from standard wallets:
Unlike seqno-based wallets that require sequential transaction processing, Highload v2 stores processed request identifiers in a dictionary, enabling parallel submissions.
Replaced by: Highload Wallet v3
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 and external messages.
Storage structure
storage$_ subwallet_id:uint32 last_cleaned:uint64 public_key:bits256
queries:(HashmapE 64 Cell) = Storage;Query ID structure
_ query_id:uint64 = QueryId;External message structure
_ mode:uint8 message:^Cell = OutListNode;
msg_body$_ signature:bits512 subwallet_id:uint32 query_id:uint64
messages:(HashmapE 16 Cell) = ExternalInMsgBody;Storage structure
The Highload Wallet v2 contract stores four persistent fields (in this order):
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. Each external message must include the correct subwallet_id; mismatches result in transaction failure.
last_cleaned (64 bits)
Purpose:
The greatest query_id removed by cleanup. The getter uses this watermark to report IDs that may have been forgotten.
How it works:
When an external message runs, the contract removes stored IDs whose timestamp portion is strictly more than 64 seconds old. Cleanup runs only when the wallet processes a later external message, so an idle wallet can retain older entries longer. last_cleaned records the last key removed during that cleanup.
Cleanup logic:
bound -= (64 << 32); // Clean up records expired more than 64 seconds agoQueries with query_id < (now() - 64) << 32 are removed from storage.
Gas costs: Cleanup operations consume gas proportional to the number of expired queries. With many expired queries, cleanup can exceed the 1,000,000 gas limit, causing the transaction to fail.
public_key (256 bits)
Purpose:
The 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.
queries (HashmapE 64 Cell)
Purpose:
Stores processed query_id values for replay protection.
Structure:
- Key: 64-bit
query_id - Value: An empty cell marker. The key's presence means the ID was processed.
How it works:
Before processing a message, the contract checks if query_id exists in queries. If found, the message is rejected (replay attack). If not found, the query_id is added to queries, and the message is processed.
Storage limit: The queries dictionary cannot exceed 65,535 cells. If this limit is reached, the contract will fail during the action phase.
External message structure
Message layout
signature:bits512
subwallet_id:uint32
query_id:uint64
messages:(HashmapE 16 Cell)Key point:
Unlike v3, in v2 the signature is in the same cell as the message body, not in a separate reference cell.
signature (512 bits)
Type:
Ed25519 signature (512 bits).
What is signed:
The hash of the remaining slice after the signature, containing subwallet_id, query_id, and messages.
From source code:
throw_unless(35, check_signature(slice_hash(in_msg), signature, public_key));The contract uses slice_hash() on the message body after loading the signature.
subwallet_id (32 bits)
Purpose:
Identifies which subwallet this message targets.
Validation:
Must match the subwallet_id stored in contract storage.
query_id (64 bits)
Purpose:
Unique identifier for replay protection and timestamp validation.
Structure:
The 64-bit value is internally interpreted as a timestamped identifier:
- High 32 bits: Unix timestamp (seconds)
- Low 32 bits: application-chosen counter or differentiator
Validation:
The contract checks query_id >= now() << 32, ensuring the query ID is not from the past (based on the current time shifted left by 32 bits).
Capacity:
The contract does not define a fixed limit of 32,000 query IDs. The practical limit depends on storage size and the gas needed to remove expired entries. The source recommends no more than 1,000 queries in an expiration interval and fewer than 100 removals in one transaction.
messages (HashmapE 16)
Purpose:
Dictionary of messages to send in this transaction.
Structure:
- Key:
uint16(message index, 0 to 65,535) - Value:
mode:uint8+^Cell(reference to internal message)
How it works:
The contract iterates through the dictionary and sends each message with its corresponding send mode:
int i = -1;
do {
(i, var cs, var f) = dict.idict_get_next?(16, i);
if (f) {
var mode = cs~load_uint(8);
send_raw_message(cs~load_ref(), mode);
}
} until (~ f);Max batch size:
Up to 254 messages. The limit comes from TVM action list, not the 16-bit dictionary key space.
Replay protection mechanism
Validation sequence
- Check query_id timestamp:
query_id >= now() << 32(exit code35if too old) - Check replay:
query_idmust not be inqueries(exit code32if already processed) - Check subwallet:
subwallet_id == stored_subwallet(exit code34if mismatch) - Verify signature: Ed25519 signature verification (exit code
35if invalid) - Mark as processed: Add
query_idtoqueries - Send messages: Iterate through the message dictionary and send each message
- Cleanup: Remove queries older than 64 seconds
Rollback issue: Highload Wallet v2 does not use commit() to persist storage changes. If the compute phase fails after accept_message() (e.g., gas limit exceeded during cleanup) or if the action phase fails, all changes roll back, including replay protection. The query_id is not marked as processed, and lite-servers will retry the same message, burning gas repeatedly.
Highload Wallet v3 calls commit() before validating the outgoing message and processes batch actions in a separate internal transaction. See Why internal messages to self? for the batch flow.
Exit codes
| Exit code | Name | Description | How to fix |
|---|---|---|---|
0 | Success | Message processed successfully | — |
32 | Query already executed | The query_id was already processed (found in queries) | Use a new, unique query_id |
34 | Subwallet ID mismatch | The subwallet_id in the message does not match storage | Verify you are using the correct subwallet_id for this wallet |
35 | Invalid signature or query_id | Ed25519 signature verification failed, or query_id is too old | Check the private key and ensure query_id >= now() << 32 |
Limitations and constraints
Storage size limit
Limit:
The queries dictionary cannot exceed 65,535 cells.
What happens if exceeded:
An exception is thrown during the action phase, and the transaction fails. The failed transaction may be replayed, potentially locking funds.
Gas limit for cleanup
Limit:
Transaction gas limit is 1,000,000 gas.
What happens if exceeded:
Cleanup operations that exceed this limit will fail, preventing the contract from processing new transactions.
Recommended limits:
- Queries within expiration window: ≤ 1,000
- Queries cleaned per transaction: ≤ 100
Query ID expiration
Expiration time:
Cleanup removes query IDs whose timestamp portion is strictly older than now() - 64 seconds. It runs when a later external message is processed, not on a timer.
Effective limit:
The source recommends no more than 1,000 queries in an expiration interval and fewer than 100 expired entries removed per transaction. These are operating limits for storage and cleanup gas, not a fixed query ID count.
Get methods
| Method | Returns | Description |
|---|---|---|
processed?(query_id) | int | Returns -1 if processed, 0 if not processed, 1 if unknown (forgotten after cleanup) |
get_public_key() | int (256 bits) | Returns the Ed25519 public key |
processed? method details
Returns:
-1(true) — thequery_idwas processed and is still stored inqueries0(false) — thequery_idhas not been processed yet1(unknown) —query_id <= last_cleaned; cleanup removed that ID or an earlier range of IDs
Implementation
Source code:
ton-blockchain/ton (highload-wallet-v2-code.fc)
SDK wrappers:
- Go:
tonutils-go— includes Highload v2 wrapper - Python:
pytoniq— includes Highload v2 wrapper
For new projects, consider using Highload Wallet v3 instead.
See also
- Highload Wallet v3 specification — recommended version
- Version comparison — v1 vs v2 vs v3