TON DocsTON Docs
OnboardingNodesApplicationsAPIsContractsSmart contractsTolkTolk languageTVMTON Virtual MachineFoundationsBlockchain foundations

How to interact with wallets

Creating a wallet involves several steps:

  1. Generating a mnemonic. At this point, an account is still in the nonexist state.
  2. Deriving the public key and wallet address.
  3. Funding the address with a non-bounceable transfer. The account becomes uninit.
  4. Deploying the wallet contract using its initial StateInit. The account becomes active.

This page covers address derivation and composition of plaintext or encrypted comments. For transfers, use wallet wrappers in an SDK or directly use the V5 interface.

For WalletTg, use its own interaction guide.

Derive wallet address

Address derivation is contract-specific. For V4R2 and V5R1, the address is derived from initial StateInit, whose data includes:

  • public_key, derived from the mnemonic;
  • wallet_id, a wallet identifier whose value depends on the contract and may vary by network.

Sometimes wallet_id is also called subwallet_id or subwalletId in some of the libraries and documentation. They mean the same thing.

The mnemonic alone does not determine contract addresses: the contract version and wallet_id must also match.

Furthermore, the WalletTg contract also derives its address from an initial StateInit, but its anchor public key can differ from the current signing key after rotation. See the integration guide for how to identify both keys.

Default wallet_id values

NetworkV4R2V5
Mainnet0x29a9a317 (698983191)0x7FFFFF11 (2147483409)
Testnet0x29a9a317 (698983191)0x7FFFFFFD (2147483645)

For Wallet V4R2, the default wallet_id comes from the first 4 bytes of TON mainnet's initial state hash. It is a client convention; the contract accepts other IDs.

For Wallet V5, wallet_id is different between mainnet and testnet for security reasons. Wallet addresses for the same mnemonic differ across networks. This prevents replaying transactions from testnet on mainnet and ensuing loss of funds. The wallet_id value is obtained from

  • network_id: -239 for mainnet, -3 for testnet;
  • wallet_version: currently always 0;
  • subwallet_number: 0 by default;
  • workchain: 0 for basechain;

using the following derivation scheme (client context):

import { beginCell, Builder } from '@ton/core';

type WalletIdV5ClientContext = {
  // -239 for mainnet, -3 for testnet
  readonly networkGlobalId: number;
  // 0 for basechain
  readonly workchain: number;
  // currently always 0 for V5R1
  readonly walletVersion: number;
  // 0 for the first wallet with this mnemonic
  // 0..2^15-1
  readonly subwalletNumber: number;
};

export function storeWalletIdV5R1(ctx: WalletIdV5ClientContext) {
  return (builder: Builder) => {
    // context_id_client$1 = wc:int8 wallet_version:uint8 counter:uint15
    const context = beginCell()
      .storeUint(1, 1)
      .storeInt(ctx.workchain, 8)
      .storeUint(ctx.walletVersion, 8)
      .storeUint(ctx.subwalletNumber, 15)
      .endCell()
      .beginParse()
      .loadInt(32);

    const walletId = ctx.networkGlobalId ^ context;

    builder.storeInt(walletId, 32);
  };
}

This code mirrors the official WalletV5R1WalletId implementation in the @ton/ton SDK and is shown for educational purposes. In production code, use the SDK implementation directly instead of reimplementing it.

Examples

The following offline examples use Node.js 22 and wrappers from the @ton/ton TypeScript SDK.

Install the dependencies:

npm install --save-exact @ton/[email protected] @ton/[email protected] @ton/[email protected]

Each example generates a temporary 24-word TON mnemonic and prints an address. For an existing wallet, validate its stored phrase with mnemonicValidate before deriving its key pair.

Wallet V4R2

import { mnemonicNew, mnemonicToPrivateKey } from '@ton/crypto';
import { WalletContractV4 } from '@ton/ton';

const main = async () => {
    // Alternatively, pass an existing mnemonic as an env var
    // and use process.env to retrieve it here
    const mnemonic = await mnemonicNew(24);
    const keyPair = await mnemonicToPrivateKey(mnemonic);
    const walletContractV4 = WalletContractV4.create({
        workchain: 0,
        publicKey: keyPair.publicKey,
        // Default wallet_id for V4R2.
        walletId: 0x29a9a317,
    });
    console.log(walletContractV4.address);
}

void main();

Wallet V5

import { mnemonicNew, mnemonicToPrivateKey } from '@ton/crypto';
import { WalletContractV5R1 } from '@ton/ton';

const main = async () => {
    // Alternatively, pass an existing mnemonic as an env var
    // and use process.env to retrieve it here
    const mnemonic = await mnemonicNew(24);
    const keyPair = await mnemonicToPrivateKey(mnemonic);

    // testnet
    const testnetV5Wallet = WalletContractV5R1.create({
        walletId: {
            networkGlobalId: -3,
        },
        publicKey: keyPair.publicKey,
        workchain: 0,
    });

    console.log(testnetV5Wallet.address);

    // mainnet
    const mainnetV5Wallet = WalletContractV5R1.create({
        walletId: {
            networkGlobalId: -239,
        },
        publicKey: keyPair.publicKey,
        workchain: 0,
    });

    console.log(mainnetV5Wallet.address);
};

void main();

Use the resulting wallet wrapper's sendTransfer method to send messages. For custom V5 requests, see the message interface.

Comments

Wallet apps can attach short human-readable notes, called comments, to outgoing internal messages. A comment has a specific message-body layout that wallet apps interpret as text.

Comment format

  • The first 32 bits of the incoming message body must be the opcode 0x00000000. This value signals that the rest of the payload should be treated as a comment.
  • The remaining bytes are UTF-8 encoded text. Wallet apps should tolerate invalid or empty strings — many senders emit messages without a comment or with zero-length payloads.

When parsing, always inspect the opcode before assuming a text or an encrypted comment. If the opcode differs, fall back to handling other contract-specific payloads.

The comment encoding is the same across these transfers, but its location differs:

  • Native Gram transfers between wallets use the comment as the internal message body.
  • Jetton transfers put the comment in forward_payload of the jetton transfer request.
  • NFT transfers put the comment in forward_payload of the NFT transfer request.

Attaching a comment when sending

To include a comment in an outgoing transfer, construct an internal message body that starts with 0x00000000 and append the UTF-8 bytes of the note to share. Most wallet libraries expose helpers for this, but the underlying steps are:

  1. Allocate a cell.
  2. Store the 32-bit zero opcode.
  3. Store the text as a byte string (UTF-8 encoded).
  4. For a native Gram transfer, use the cell as the internal message body. For a jetton or NFT transfer, put it in the normal transfer request's forward_payload and set a positive forwarding amount to deliver the notification.

Wallet apps that support the convention display the decoded text. Contract behavior depends on its message interface; an unsupported body can be rejected or bounced.

Example: Composing a comment with @ton/core

import { Cell, beginCell } from '@ton/core';

function createCommentCell(comment: string): Cell {
    return beginCell()
        // opcode for comment
        .storeUint(0, 32)
        // UTF-8 encoded text in snake encoding
        .storeStringTail(comment)
        .endCell();
}

Encrypted comments

An encrypted comment hides the contents of a UTF-8 or binary comment from everyone except the sender and recipient. Its internal message body starts with opcode 0x2167da4b instead of the plaintext comment opcode 0x00000000.

Use encrypted comments when the recipient supports the format and its signing public key is known:

  • For WalletTg, use the active key from get_public_key().
  • For jetton and NFT transfers, place the encrypted comment in forward_payload as described for plaintext comments.

Encrypted comment format

Message body layout:

  • The first 32 bits of the incoming message body contain the opcode 0x2167da4b.
  • The rest of the payload contains the encrypted comment in snake encoding. Parse it as binary data, not UTF-8 text.

When parsing, always inspect the opcode before assuming a text or encrypted comment. If the opcode differs, fall back to handling other contract-specific payloads.

Bytes stored:

  1. pub_xor: 32 bytes of sender_public_key XOR recipient_public_key.
  2. msg_key: first 16 bytes of HMAC-SHA512 applied to padded_data with salt as the key. The salt is the sender's user-friendly and URL-safe address string:
    // Not runnable: address is the sender's @ton/core Address.
    address.toString({
      bounceable: true,
      testOnly: false,
      urlSafe: true,
    })
  3. ciphertext: up to 976 bytes of AES-256-CBC ciphertext from padded_data.

There, padded_data consists of:

  1. prefix: random 16-31 bytes, with prefix[0] containing prefix length.
  2. comment: up to 960 bytes of the encrypted comment contents, see the algorithm below.

Finally,

  • HMAC-SHA512 is a HMAC applied with a 512-bit SHA-2 hash.
  • AES-256-CBC stands for AES encryption with a 256-bit key using Cipher Block Chaining (CBC) mode.
  • sender_public_key and sender_private_key — the sender's 32-byte Ed25519 public key and private seed, respectively.
  • recipient_public_key — 32-byte Ed25519 public key of the receiver.

When decrypting, reject malformed lengths and invalid prefixes. Recompute msg_key from the salt and decrypted padded_data, and require it to match before displaying the comment.

Attaching an encrypted comment when sending

To include an encrypted comment in an outgoing transfer, construct an internal message body that starts with 0x2167da4b, followed by the encrypted comment composed according to the following algorithm — given comment, sender_private_key, sender_public_key, recipient_public_key, and sender_address:

  1. Convert the plaintext comment to bytes. Maximum length: 960 bytes.
  2. Generate a random prefix so prefix.length >= 16 and (prefix.length + comment.length) % 16 == 0.
  3. Set prefix[0] = prefix.length.
  4. Set padded_data as byte-string concatenation of prefix and comment bytes.
  5. Derive shared_secret using sender_private_key and recipient_public_key. Validate the recipient's Ed25519 point, convert both keys to X25519, and reject an all-zero shared secret.
  6. Set salt as a bounceable, URL-safe, user-friendly sender_address string.
  7. Compute msg_key as the first 16 bytes of HMAC-SHA512 of the salt and padded_data.
  8. Compute x as HMAC-SHA512 of the shared_secret and msg_key.
  9. Use x[0..32] as the AES-256-CBC key and x[32..48] as the IV.
  10. Obtain ciphertext by encrypting padded_data with AES-256-CBC and disabled automatic padding.
  11. Store the byte-string concatenation of sender_public_key XOR recipient_public_key, msg_key, and ciphertext after the opcode:
    • Divide the resulting byte string into segments and store the snake cell chain of cells: c_1, c_2, …, c_k, where c_1 is the cell root of the body and k is the number of the cell.
    • Each cell, except the last, has a reference to the next cell. The maximum allowed reference depth k is 16, and the maximum total string length is 1024 bytes.
    • The root cell c_1 contains up to 39 bytes, including the 4-byte tag; all following contain up to 127 bytes.

The recipient contract must accept the body or forwarding payload. Contracts that do not recognize the opcode will ignore it or treat the message body as opaque data, so encrypted comments are backward-compatible with regular Gram transfers.

Example: Composing an encrypted comment with @ton/core

This implementation uses @ton/core for cells and addresses, @ton/crypto for Ed25519 public-key derivation and hashes, and Node.js crypto for AES-256-CBC. It includes Ed25519-to-X25519 conversion and X25519 helpers and accepts the sender's already-derived 32-byte Ed25519 signing seed.

import { createCipheriv } from 'node:crypto';
import { Address, beginCell, Cell } from '@ton/core';
import {
  getSecureRandomBytes,
  keyPairFromSeed,
  hmac_sha512,
  sha512,
} from '@ton/crypto';

const ENCRYPTED_COMMENT_OP = 0x2167da4b;

export async function createEncryptedCommentBody(args: {
  /** The sender's current signing key as an already-derived, 32-byte Ed25519 seed. */
  senderPrivateSeed: Buffer;

  /** From whom the message will be sent */
  senderAddress: Address;

  /** Recipient's active Ed25519 public key; for WalletTg, read it with get_public_key(). */
  recipientPublicKey: Buffer;

  /** Plaintext comment or data to encrypt */
  comment: string | Buffer;
}): Promise<Cell> {
  const senderSeed = Buffer.from(args.senderPrivateSeed);
  if (senderSeed.length !== 32) {
    throw new Error('senderPrivateSeed must be a 32-byte Ed25519 seed');
  }
  if (args.recipientPublicKey.length !== 32) {
    throw new Error('recipientPublicKey must be 32 bytes');
  }
  // sender_public_key
  const senderPublicKey = Buffer.from(keyPairFromSeed(senderSeed).publicKey);

  // 1st step: comment
  const comment = typeof args.comment === 'string'
    ? Buffer.from(args.comment, 'utf8')
    : Buffer.from(args.comment);

  if (comment.length > 960) {
    throw new Error('Encrypted comment plaintext must be <= 960 bytes');
  }

  // 2nd-3rd steps: prefix
  const prefixLength = ((16 + 15 + comment.length) & ~15) - comment.length;
  const prefix = Buffer.from(await getSecureRandomBytes(prefixLength));
  prefix[0] = prefixLength;

  // 4th step: padded_data
  const data = Buffer.concat([prefix, comment]);
  const salt = Buffer.from(args.senderAddress.toString({
    bounceable: true,
    testOnly: false,
    urlSafe: true,
  }));

  // 5th step: shared_secret
  const sharedSecret = await tonEd25519SharedSecret(
    Buffer.from(args.recipientPublicKey),
    senderSeed,
  );

  // 6th-7th steps: msg_key
  const msgKey = Buffer.from(await hmac_sha512(salt, data)).subarray(0, 16);

  // 8th step: x
  const x = Buffer.from(await hmac_sha512(sharedSecret, msgKey));
  // 9th step: key = x[0:32] and iv = x[32:48]
  const key = x.subarray(0, 32);
  const iv = x.subarray(32, 48);

  // 10th step: ciphertext
  const cipher = createCipheriv('aes-256-cbc', key, iv);
  cipher.setAutoPadding(false);
  const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);

  // 11th and final step: byte-string concatenation, then snake cell encoding
  const encryptedComment = Buffer.concat([
    xor32(senderPublicKey, Buffer.from(args.recipientPublicKey)),
    msgKey,
    ciphertext,
  ]);
  return encryptedCommentToCell(encryptedComment);
}

// --- utils ---

function mod(a: bigint): bigint {
  const P = (1n << 255n) - 19n;
  const r = a % P;
  return r >= 0n ? r : r + P;
}

function modPow(a: bigint, e: bigint): bigint {
  let x = mod(a);
  let r = 1n;

  while (e > 0n) {
    if (e & 1n) {
      r = mod(r * x);
    }
    x = mod(x * x);
    e >>= 1n;
  }

  return r;
}

function inv(a: bigint): bigint {
  const P = (1n << 255n) - 19n;
  return modPow(a, P - 2n);
}

function leToBigInt(bytes: Buffer): bigint {
  let n = 0n;

  for (let i = bytes.length - 1; i >= 0; i--) {
    n = (n << 8n) + BigInt(bytes[i]);
  }

  return n;
}

function bigIntToLe(n: bigint, size: number): Buffer {
  const out = Buffer.alloc(size);

  for (let i = 0; i < size; i++) {
    out[i] = Number(n & 255n);
    n >>= 8n;
  }

  return out;
}

function x25519(scalar: Buffer, uBytes: Buffer): Buffer {
  const k = Buffer.from(scalar);
  k[0] &= 248;
  k[31] &= 127;
  k[31] |= 64;

  const x1 = leToBigInt(uBytes);
  let x2 = 1n;
  let z2 = 0n;
  let x3 = x1;
  let z3 = 1n;
  let swap = 0n;
  const kNum = leToBigInt(k);

  for (let t = 254; t >= 0; t--) {
    const kt = (kNum >> BigInt(t)) & 1n;
    swap ^= kt;

    if (swap) {
      [x2, x3] = [x3, x2];
      [z2, z3] = [z3, z2];
    }

    swap = kt;

    const a = mod(x2 + z2);
    const aa = mod(a * a);
    const b = mod(x2 - z2);
    const bb = mod(b * b);
    const e = mod(aa - bb);
    const c = mod(x3 + z3);
    const d = mod(x3 - z3);
    const da = mod(d * a);
    const cb = mod(c * b);

    x3 = mod((da + cb) ** 2n);
    z3 = mod(x1 * mod((da - cb) ** 2n));
    x2 = mod(aa * bb);
    z2 = mod(e * mod(aa + 121665n * e));
  }

  if (swap) {
    [x2, x3] = [x3, x2];
    [z2, z3] = [z3, z2];
  }

  return bigIntToLe(mod(x2 * inv(z2)), 32);
}

async function tonEd25519SharedSecret(
  peerPublicKey: Buffer,
  privateSeed: Buffer,
): Promise<Buffer> {
  if (peerPublicKey.length !== 32) {
    throw new Error('peerPublicKey must be 32 bytes');
  }
  if (privateSeed.length !== 32) {
    throw new Error('privateSeed must be 32 bytes');
  }

  const publicY = Buffer.from(peerPublicKey);
  publicY[31] &= 127;

  const P = (1n << 255n) - 19n;
  const y = leToBigInt(publicY);
  if (y >= P) {
    throw new Error('Invalid Ed25519 public key');
  }

  // Check that the encoded y coordinate corresponds to an Ed25519 point.
  const ySquared = mod(y * y);
  const d = mod(-121665n * inv(121666n));
  const xSquared = mod((ySquared - 1n) * inv(d * ySquared + 1n));
  if (
    (xSquared === 0n && (peerPublicKey[31] & 128) !== 0) ||
    (xSquared !== 0n && modPow(xSquared, (P - 1n) / 2n) !== 1n)
  ) {
    throw new Error('Invalid Ed25519 public key');
  }

  const u = mod((y + 1n) * inv(1n - y));

  const h = await sha512(privateSeed);
  const scalar = Buffer.from(h.subarray(0, 32));

  const sharedSecret = x25519(scalar, bigIntToLe(u, 32));
  if (sharedSecret.every((byte) => byte === 0)) {
    throw new Error('Invalid public key: all-zero shared secret');
  }
  return sharedSecret;
}

function xor32(a: Buffer, b: Buffer): Buffer {
  if (a.length !== 32 || b.length !== 32) {
    throw new Error('Expected 32-byte public keys');
  }

  const out = Buffer.alloc(32);
  for (let i = 0; i < 32; i++) {
    out[i] = a[i] ^ b[i];
  }
  return out;
}

function encryptedCommentToCell(encryptedComment: Buffer): Cell {
  if (encryptedComment.length > 1024) {
    throw new Error('Encrypted comment is too long');
  }

  const chunks: Buffer[] = [encryptedComment.subarray(0, 35)];
  for (let i = 35; i < encryptedComment.length; i += 127) {
    chunks.push(encryptedComment.subarray(i, i + 127));
  }

  let ref: Cell | null = null;
  for (let i = chunks.length - 1; i >= 1; i--) {
    const builder = beginCell().storeBuffer(chunks[i]);
    if (ref) {
      builder.storeRef(ref);
    }
    ref = builder.endCell();
  }

  const root = beginCell()
    .storeUint(ENCRYPTED_COMMENT_OP, 32)
    .storeBuffer(chunks[0]);

  if (ref) {
    root.storeRef(ref);
  }

  return root.endCell();
}

WalletTg

When composing the encrypted messages for WalletTg, use the sender's current signing seed. The WalletTg integration guide covers account derivation and decrypting historical comments.

References:

On this page