# How to integrate WalletTg support into a wallet app (https://docs.ton.org/llms/contracts/standard/wallets/telegram/integrate/content.md)



To support existing [WalletTg](https://docs.ton.org/llms/contracts/standard/wallets/telegram/overview/content.md) accounts, derive the account address, check the current signing key, and compose WalletTg-compatible messages.

<Callout type="note">
  WalletTg is the wallet contract used by Telegram. It is not the only kind of wallets on TON.

  When creating new wallets for TON, prefer to use the [wallet V5](https://docs.ton.org/llms/contracts/standard/wallets/v5/content.md) contract, unless the user explicitly provides the [rotation mnemonic](https://docs.ton.org/llms/contracts/standard/wallets/mnemonics/content.md) that is only compatible with WalletTg.
</Callout>

## Prerequisites [#prerequisites]

* [Node.js](https://nodejs.org/en/download) 22 or later LTS version **for the examples** below.
* Access to account state and get-methods on the TON mainnet.
* Encrypted mnemonic storage and Ed25519 signing in the wallet app.

The example runs offline with temporary keys and targets the Telegram Money-created wallets that use subwallet ID `0x7FFF7F11`.

<Callout type="caution">
  A recovery phrase gives control of the wallet. Store production phrases encrypted — never log them and never share them.
</Callout>

## Detect the mnemonic scheme [#detect-the-mnemonic-scheme]

1. Validate the phrase using the [mnemonic import rules](https://docs.ton.org/llms/contracts/standard/wallets/mnemonics/content.md). For rotation input, each 12-word half must pass its own BIP-39 checksum.
2. If several schemes match, derive the supported accounts and offer a choice.
3. Derive the keys for the selected WalletTg phrase:
   * For 12 BIP-39 words, derive one key pair. Use it as both the address anchor and signing key.
   * For a 24-word rotation mnemonic, derive each 12-word half separately. The first half derives the address anchor; the second half derives the signing key.

For each 12-word phrase, use BIP-39 seed derivation with an empty passphrase, then SLIP-0010 Ed25519 at `m/44'/607'/0'`.

## Derive the original account [#derive-the-original-account]

Build `StateInit` with the anchor public key and the [trampoline and initial data](https://docs.ton.org/llms/contracts/standard/wallets/telegram/contract/content.md). For Telegram's mainnet account, use workchain `0` and subwallet ID `0x7FFF7F11`.

Store the original `StateInit` separately from the current signing public key.

### Offline derivation example [#offline-derivation-example]

In a disposable directory, set up the example:

```bash
npm install --save-exact @ton/core@0.63.1 @ton/crypto@3.3.0 bip39@3.1.0 tsx@4.20.5
npm pkg set type=module
```

Create the following script:

```ts title="wallet-tg.ts"
import { createHmac } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { beginCell, Cell, contractAddress, type ContractProvider } from '@ton/core';
import { keyPairFromSeed } from '@ton/crypto';
import bip39 from 'bip39';

// WalletTg's trampoline hash and BoC
const CODE_HASH = '9149ae51c1e4689710cebf7830297b16acfbadb363a920a537893e7ffeeca768';
const CODE_BOC = 'b5ee9c7241010101001a000030ff00209821d7498308b9f240df8085f833d0ed1e20ed53d969427e39';
const code = Cell.fromBoc(Buffer.from(CODE_BOC, 'hex'))[0];
if (code.hash().toString('hex') !== CODE_HASH) throw new Error('Invalid trampoline');

function halfKey(words: string[]) {
  if (words.length !== 12 || !bip39.validateMnemonic(words.join(' '))) {
    throw new Error('Expected 12 valid BIP39 words');
  }
  const hmac = (key: string | Buffer, data: Buffer) => createHmac('sha512', key).update(data).digest();
  let node = hmac('ed25519 seed', bip39.mnemonicToSeedSync(words.join(' '), ''));
  for (const index of [44, 607, 0]) {
    const childIndex = Buffer.alloc(4);
    childIndex.writeUInt32BE(index + 0x80000000);
    node = hmac(node.subarray(32), Buffer.concat([
      Buffer.alloc(1), node.subarray(0, 32), childIndex,
    ]));
  }
  return keyPairFromSeed(node.subarray(0, 32));
}

/** NOTE: `words` are expected to be a rotation mnemonic */
export function deriveWalletTg(words: string[]) {
  if (words.length !== 12 && words.length !== 24) {
    throw new Error('Expected 12 words or a selected 24-word rotation mnemonic');
  }
  const anchor = halfKey(words.slice(0, 12));
  const signer = words.length === 12 ? anchor : halfKey(words.slice(12));
  const subwalletId = 0x7FFF7F11;
  const data = beginCell()
    // Initial storage revision.
    .storeUint(0, 8)
    .storeUint(0, 32)
    .storeUint(subwalletId, 32)
    .storeBuffer(anchor.publicKey)
    .endCell();
  const init = { code, data };
  return { address: contractAddress(0, init), init, subwalletId, signer };
}

export type WalletIdentity = ReturnType<typeof deriveWalletTg>;

export function assertCurrentWallet(identity: WalletIdentity, snapshot: {
  network: string;
  active: boolean;
  code: Cell;
  subwalletId: number;
  publicKey: Buffer;
}) {
  if (snapshot.network !== 'mainnet') {
    throw new Error('Expected mainnet');
  }
  if (!snapshot.active || snapshot.code.hash().toString('hex') !== CODE_HASH) {
    throw new Error('Account is not an active WalletTg');
  }
  if (snapshot.subwalletId !== identity.subwalletId) {
    throw new Error('Subwallet ID mismatch');
  }
  if (!Buffer.isBuffer(snapshot.publicKey) || snapshot.publicKey.length !== 32
      || !snapshot.publicKey.equals(identity.signer.publicKey)) {
    throw new Error('Phrase has a stale or incorrect signing half');
  }
}

export async function readWallet(provider: ContractProvider, identity: WalletIdentity, network: string) {
  const account = await provider.getState();
  if (account.state.type !== 'active' || !account.state.code) {
    throw new Error('Account is not active');
  }
  const roots = Cell.fromBoc(account.state.code);
  if (roots.length !== 1 || roots[0].hash().toString('hex') !== CODE_HASH) {
    throw new Error('Account is not WalletTg');
  }
  const readUint = async (name: string, bits: number) => {
    const { stack } = await provider.get(name, []);
    const value = stack.readBigNumber();
    if (value < 0n || value >= (1n << BigInt(bits))) {
      throw new Error(`Invalid ${name} result`);
    }
    return value;
  };
  const [subwalletId, publicKey, seqno] = await Promise.all([
    readUint('get_subwallet_id', 32),
    readUint('get_public_key', 256),
    readUint('seqno', 32),
  ]);
  const snapshot = {
    network, active: true, code: roots[0],
    subwalletId: Number(subwalletId),
    publicKey: Buffer.from(publicKey.toString(16).padStart(64, '0'), 'hex'),
    seqno: Number(seqno),
  };
  assertCurrentWallet(identity, snapshot);
  return snapshot;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const identity = deriveWalletTg(bip39.generateMnemonic(128).split(' '));
  console.log('Derived mainnet address:', identity.address.toRawString());
}
```

Run the offline check:

```bash
npx tsx wallet-tg.ts
```

Each run prints `Derived mainnet address: 0:` followed by a new 64-digit hexadecimal account ID.

## Validate the deployed account [#validate-the-deployed-account]

Bind `provider` to `identity.address` using trusted mainnet configuration. The helper cannot inspect the provider's target address or actual network. Then call `readWallet`:

```ts
// Not runnable: derive identity above and bind provider to its address on mainnet.
const snapshot = await readWallet(provider, identity, 'mainnet');
```

`readWallet` checks the active state, trampoline hash, subwallet ID, and current signing key using the account code and [wallet getters](https://docs.ton.org/llms/contracts/standard/wallets/telegram/contract/content.md). It returns the validated state, including `seqno`.

Pin the reads to one block when the provider supports it. A failed read or validation rejects the import; do not substitute defaults. If the signing key does not match, request the current recovery phrase.

<Callout type="note">
  Some popular wallets implement similar [import steps](https://github.com/mytonwallet-org/mytonwallet/blob/bb3f357690633d274e390e799babaf1d642b31de/src/api/chains/ton/auth.ts) and [account checks](https://github.com/mytonwallet-org/mytonwallet/blob/bb3f357690633d274e390e799babaf1d642b31de/src/api/chains/ton/wallet.ts).
</Callout>

## Build and track requests [#build-and-track-requests]

Use the [request guide](https://docs.ton.org/llms/contracts/standard/wallets/telegram/interact/content.md) to build and submit single transfers, batches, and gasless relayed requests. Its helpers use `readWallet` above to validate the active account and read its current key and `seqno`.

## Rotate the signing key [#rotate-the-signing-key]

Use Telegram's [key-history encryption format](https://docs.ton.org/llms/contracts/standard/wallets/telegram/key-management/content.md) to preserve access to comments encrypted with now-replaced signing keys.

1. Keep words 1-12 unchanged. Generate a fresh 12-word BIP-39 signing half from a cryptographically secure random source, independently of the anchor and every previous signing half. Never rotate back to the anchor key.
2. Save both the current and candidate phrases in encrypted storage before submission.
3. Build the [rotation request](https://docs.ton.org/llms/contracts/standard/wallets/telegram/messages/content.md). Sign the outer request with the current key and the rotation proof with the new key. Encode the replaced signing seed as `encryptedOldPrivateKey` using the key-history format.
4. Emulate and submit the request.
5. Read `get_public_key()` and `seqno()` again. Make the candidate phrase current only when the on-chain key matches its signing half. If submission fails or its result is unknown, retain both phrases and read the state before retrying.

## Decrypt historical comments [#decrypt-historical-comments]

When decrypting [encrypted comments](https://docs.ton.org/llms/contracts/standard/wallets/interact/content.md), try the current signing key and the anchor key first. Recover intermediate signing keys from public rotation history only when neither authenticates the comment.

The decryption itself can be performed locally, but the retrieval of the earlier signing keys requires querying the blockchain.

### Recover earlier signing keys [#recover-earlier-signing-keys]

1. Derive the current and anchor key pairs from the validated recovery phrase. For an `@ton/crypto` key pair, copy its 32-byte seed with `Buffer.from(secretKey.subarray(0, 32))`.
2. Query [TON Center's `/api/v3/actions`](https://docs.ton.org/llms/api/v3/actions-and-traces/get-actions/content.md) endpoint with `account` set to the wallet address and `action_type=change_wallet_key`. Use `limit=100`, `offset=0`, and `sort=desc`. Increase `offset` by the returned action count until a page contains fewer than 100 actions.
3. Keep only actions with `success: true` whose `details.destination` matches the wallet address. Compare parsed addresses: the account filter also returns rotations relayed for other wallets. Decode `details.new_public_key` and `details.encrypted_old_private_key` from 64 hexadecimal digits into 32 bytes, preserving leading zeros.
4. Find the rotation that installed the current signing public key. Recover its previous seed with the [key-history formula](https://docs.ton.org/llms/contracts/standard/wallets/telegram/key-management/content.md), then derive the previous public key with `keyPairFromSeed`.
5. Continue backward from that public key through earlier rotations. Stop at the anchor, a missing link, or a repeated public key. Keep the recovered keys as candidates for decryption.

When parsing transactions directly, pair `newPublicKey` from the accepted rotation request with `encryptedOldPrivateKey` from its emitted [`OnKeyHasBeenChangedEvent`](https://docs.ton.org/llms/contracts/standard/wallets/telegram/messages/content.md).

<Callout type="note">
  The event itself does not identify the installed key, and it does not prove that rotation succeeded.
</Callout>

The following helper recovers one previous seed:

```ts
// Not runnable: call with the rotation's ciphertext and its new signing seed.
import { createHash } from 'node:crypto';

function recoverPreviousSeed(encryptedOldPrivateKey: Buffer, newSeed: Buffer): Buffer {
  if (encryptedOldPrivateKey.length !== 32 || newSeed.length !== 32) {
    throw new Error('Expected 32-byte ciphertext and seed');
  }
  const mask = createHash('sha256')
    .update(newSeed)
    .update('keyChangeSaltV1', 'ascii')
    .digest();
  const previousSeed = Buffer.alloc(32);
  for (let i = 0; i < 32; i++) {
    previousSeed[i] = encryptedOldPrivateKey[i] ^ mask[i];
  }
  mask.fill(0);
  return previousSeed;
}
```

### Decrypt with each candidate key [#decrypt-with-each-candidate-key]

1. Read the comment body or the token transfer's `forward_payload`. Require the [encrypted-comment opcode and binary snake format](https://docs.ton.org/llms/contracts/standard/wallets/interact/content.md). Split the payload into `pub_xor`, `msg_key`, and `ciphertext`. Reject empty ciphertext or a ciphertext length that is not a multiple of 16 bytes.
2. Reconstruct the peer public key as `pub_xor XOR candidatePublicKey`. Use the candidate seed and that peer key for the [Ed25519-to-X25519 shared-secret derivation](https://docs.ton.org/llms/contracts/standard/wallets/interact/content.md). Skip the candidate if its peer key is invalid or its shared secret is all zeros. The peer's current getter can return a different key after rotation.
3. Compute `x = HMAC-SHA512(key=shared_secret, data=msg_key)`. Decrypt `ciphertext` with AES-256-CBC using `x[0..32]` as the key and `x[32..48]` as the initialization vector. Disable automatic padding.
4. Set `salt` to the original sender wallet's bounceable, URL-safe, non-test-only address string. For outgoing comments, use this wallet's address; for incoming comments, use the originating wallet's address, including inside token notifications.
5. Recompute `HMAC-SHA512(key=salt, data=padded_data)[0..16]` and compare it with `msg_key` in constant time. On a key mismatch, try the next candidate. Display plaintext only after this check passes.
6. Validate that `padded_data[0]` gives a prefix length of 16–31 bytes within the decrypted buffer. Remove the prefix and decode the remaining comment bytes. Reject comments longer than 960 bytes and, for text comments, invalid UTF-8.

Report missing rotation history separately from a comment that fails authentication. Retry a lagging indexer before declaring older keys unavailable. Keep comments readable with the anchor or already recovered keys even when another history link is missing.

<Callout type="note">
  Exemplary [decryption workflow](https://github.com/i582/wallet-engine/blob/e59e0d89d7ee90c388bf36e3334c5b276f396697/src/engine/encrypted_comment.rs#L162-L274) implements the candidate-key search and history lookup.
</Callout>

## Support TON Connect and TON Proof [#support-ton-connect-and-ton-proof]

Return the account address, original `walletStateInit`, and current signing public key in the TON Connect account reply. `walletStateInit` stays unchanged after rotation.

Sign [`ton_proof`](https://docs.ton.org/llms/applications/ton-connect/how-to/ton-proof/content.md) with the current signing key. The backend must verify that [`StateInit`](https://docs.ton.org/llms/foundations/messages/deploy/content.md) derives to the account address and that the active code matches the trampoline hash.

Read `get_public_key()` and verify the proof signature with that key. If the account reply includes `publicKey`, require it to match the getter. An absent field does not invalidate an otherwise valid proof. The anchor key from `StateInit` can be stale after rotation.

If the app supports [`signData`](https://docs.ton.org/llms/applications/ton-connect/how-to/sign-data/content.md), use the current key for its protocol payloads too. On-chain `cell` verifiers need a separate [authenticated receiver key update](https://docs.ton.org/llms/applications/ton-connect/how-to/sign-data/content.md) after rotation.

After rotation, disconnect the existing TON Connect session and require a new connection. TON Connect has no event that updates an account's public key. The new connection returns the same address and original `walletStateInit`, with the current signing public key. Require a fresh `ton_proof` when authenticating that connection.

Advertise only the transaction limits and TON Connect methods the app supports.

## Verify the integration [#verify-the-integration]

Test in a local emulator with the [WalletTg implementation](https://docs.ton.org/llms/contracts/standard/wallets/telegram/contract/content.md):

* Import 12 words before rotation and the current 24-word phrase after rotation; both must restore the same address.
* Reject a stale signing half, wrong subwallet ID or code, and failed getter reads.
* Offer a choice when a 24-word phrase matches several mnemonic schemes.
* Rotate to an independent signing key. Check that the address and trampoline hash stay the same and the old key can no longer sign valid requests.
* Encrypt incoming and outgoing comments before rotation, between 2 rotations, and after rotation. Restore only the current phrase and public history; all comments must decrypt.
* Reject tampered comments and incorrect sender salts. Check missing history links and delayed indexer updates without blocking comments readable with known keys.
* Accept `ton_proof` signed by the current key and reject proofs signed by the old key.
* Check single and bulk requests, internal/external opcode separation, expiration, requests with the same `seqno`, and [failure behavior](https://docs.ton.org/llms/contracts/standard/wallets/telegram/messages/content.md).

<Callout type="note">
  The [WalletTg Acton tests](https://github.com/ton-blockchain/tg-wallet-contract/tree/1a29ae5d90e03c3d585d3e42e6ecef691d727a3c/tests) include fixtures with config param `-123`. Without that entry, an emulator would not be able to execute WalletTg's signed requests or getters.
</Callout>
