TON DocsTON Docs
OnboardingNodesApplicationsAPIsContractsSmart contractsTolkTolk languageTVMTON Virtual MachineFoundationsBlockchain foundations

How to sign data with TON Connect

signData asks the wallet to sign opaque data the user reviews on the wallet's screen. The signature is bound to the user's wallet address, the dApp's domain, a timestamp, and the payload — so the same bytes signed by a different wallet, or for a different dApp, do not verify.

Three payload variants cover three use cases:

VariantUse it when
textThe data is human-readable text. The wallet shows it verbatim, monospace.
binaryThe data is opaque bytes. The wallet warns the user the content is unknown.
cellThe signature will be verified on-chain. The wallet may parse a TL-B schema and show a structured preview.

Calling signData

tonConnectUi.signData(payload) is the entry point. The wallet must be connected when you call it, or pass options.enableEmbeddedRequest: true to defer the request until a wallet is picked from the modal. A compliant wallet then handles connect and sign in one tap. Without either, the SDK throws.

Text

Use text for anything a person should read and approve — confirmation prompts, off-chain auth challenges, login messages.

// Not runnable: initialize tonConnectUi with a connected wallet.
const result = await tonConnectUi.signData({
    type: 'text',
    text: 'Confirm new 2fa number:\n+1 234 567 8901',
    network: '-239',
    from: tonConnectUi.wallet?.account.address,
});

Binary

Use binary for opaque bytes — encrypted blobs, hashes, anything the user cannot read. Pass the bytes as base64 (not URL-safe).

// Not runnable: initialize tonConnectUi with a connected wallet.
const result = await tonConnectUi.signData({
    type: 'binary',
    bytes: 'KGVsZWN0cmljIGJvb2dhbG9vKQ==',
    network: '-239',
    from: tonConnectUi.wallet?.account.address,
});

Cell

Use cell when a smart contract will verify the signature. The wallet hashes a TON cell, not a flat byte string; you also send the TL-B schema so the wallet can decode the cell on-screen.

// Not runnable: initialize tonConnectUi with a connected wallet.
const result = await tonConnectUi.signData({
    type: 'cell',
    schema: 'transfer#0f8a7ea5 query_id:uint64 amount:(VarUInteger 16) destination:MsgAddress response_destination:MsgAddress custom_payload:(Maybe ^Cell) forward_ton_amount:(VarUInteger 16) forward_payload:(Either Cell ^Cell) = InternalMsgBody;',
    cell: 'te6ccgEBAQEAVwAAqg+KfqVUbeTvKqB4h0AcnDgIAZucsOi6TLrfP6FcuPKEeTI6oB3fF/NBjyqtdov/KtutACCLqvfmyV9kH+Pyo5lcsrJzJDzjBJK6fd+ZnbFQe4+XggI=',
    network: '-239',
    from: tonConnectUi.wallet?.account.address,
});

If the schema declares several types, the last declared type is the root.

Response

All three variants return the same shape:

interface SignDataResult {
    // Base64 Ed25519 signature.
    signature: string;
    // Raw wallet address ("0:<hex>").
    address: string;
    // Unix seconds (UTC) at signing time.
    timestamp: number;
    // App domain (URL part, not encoded).
    domain: string;
    // The payload from the request.
    payload: object;
    // UUID for end-to-end analytics correlation.
    traceId?: string;
}

payload is the original request payload, echoed back verbatim. Reuse it when reconstructing the signed bytes for verification — never re-serialize.

// Not runnable: initialize tonConnectUi and define the payload.
const result = await tonConnectUi.signData(payload, {
    // Optional; the SDK generates a UUIDv7 if omitted.
    traceId: '019a2a92-a884-7cfc-b1bc-caab18644b6f',
});

console.log(result.signature);

The bridge specification defines trace_id at the protocol level.

Detecting wallet support

The SDK rejects requests whose type is not supported by the connected wallet before they reach the bridge — calling signData({ type: 'cell', ... }) against a wallet that lists only ['text'] throws. To restrict the wallet picker to wallets that support a given type, see Filter wallets by required features.

Verifying text and binary signatures

The signing scheme for text and binary payloads is described in the signData RPC specification.

To verify on the backend:

  1. Reconstruct message from the response fields.
  2. Compute sha256(message).
  3. Resolve the user's Ed25519 publicKey from trusted wallet data: parse StateInit only for a recognized layout, or use the active account's get_public_key() getter after validating the account code. Never trust a key echoed by the wallet response.
  4. Verify the Ed25519 signature against the digest.
  5. Reject if Timestamp is outside your tolerance window — 15 minutes is a common bound.
  6. Reject if domain is not your domain.

Extracting the public key

The tryExtractPublicKey() function below walks the standard v1R1–v5R1 wallet contract codes and parses the public key out of the data cell when it finds a match. The data layout differs per wallet version, so each version has its own parser:

wallet-public-key.ts
import type { Slice, StateInit } from '@ton/core';
import {
    WalletContractV1R1, WalletContractV1R2, WalletContractV1R3,
    WalletContractV2R1, WalletContractV2R2,
    WalletContractV3R1, WalletContractV3R2,
    WalletContractV4 as WalletContractV4R2,
    WalletContractV5R1,
} from '@ton/ton';
import { Buffer } from 'buffer';

function loadV1(cs: Slice) { cs.loadUint(32); return cs.loadBuffer(32); }
function loadV2(cs: Slice) { cs.loadUint(32); return cs.loadBuffer(32); }
function loadV3(cs: Slice) { cs.loadUint(32); cs.loadUint(32); return cs.loadBuffer(32); }
function loadV4(cs: Slice) { cs.loadUint(32); cs.loadUint(32); const k = cs.loadBuffer(32); return k; }
function loadV5(cs: Slice) { cs.loadBoolean(); cs.loadUint(32); cs.loadUint(32); return cs.loadBuffer(32); }

const knownWallets = [
    { contract: WalletContractV1R1, load: loadV1 },
    { contract: WalletContractV1R2, load: loadV1 },
    { contract: WalletContractV1R3, load: loadV1 },
    { contract: WalletContractV2R1, load: loadV2 },
    { contract: WalletContractV2R2, load: loadV2 },
    { contract: WalletContractV3R1, load: loadV3 },
    { contract: WalletContractV3R2, load: loadV3 },
    { contract: WalletContractV4R2, load: loadV4 },
    { contract: WalletContractV5R1, load: loadV5 },
].map(({ contract, load }) => ({
    code: contract.create({ workchain: 0, publicKey: Buffer.alloc(32) }).init.code,
    load,
}));

export function tryExtractPublicKey(stateInit: StateInit): Buffer | null {
    if (!stateInit.code || !stateInit.data) return null;
    for (const { code, load } of knownWallets) {
        try {
            if (code.equals(stateInit.code)) {
                return load(stateInit.data.beginParse());
            }
        } catch {
            // Try the next known layout.
        }
    }
    return null;
}

WalletTg has a special layout that is not recognized by the tryExtractPublicKey() function above: its initial public key from the StateInit can become stale after rotation.

To extract the current public key, match the StateInit code hash of a given contract with the one of WalletTg, then read the current key from the active account's get-method. Configure TonClient for the connected TON network to call the getter off-chain.

If the contract type is known ahead of time, one only needs to call the get_public_key getter off-chain to obtain the target public key. Otherwise, proceed with the example below.

wallet-tg-public-key.ts
import { Cell } from '@ton/core';
import type { Address, StateInit } from '@ton/core';
import type { TonClient } from '@ton/ton';
import { Buffer } from 'node:buffer';

export const WALLET_TG_CODE_HASH = '9149ae51c1e4689710cebf7830297b16acfbadb363a920a537893e7ffeeca768';

export async function getWalletTgPublicKey(
    client: TonClient,
    address: Address,
    stateInit: StateInit,
): Promise<Buffer> {
    if (stateInit.code?.hash().toString('hex') !== WALLET_TG_CODE_HASH) {
        throw new Error('StateInit is not WalletTg');
    }

    const provider = client.provider(address, null);
    const account = await provider.getState();
    if (account.state.type !== 'active' || !account.state.code) {
        throw new Error('Address is not an active WalletTg account');
    }
    const code = Cell.fromBoc(account.state.code);
    if (
        code.length !== 1 ||
        code[0].hash().toString('hex') !== WALLET_TG_CODE_HASH
    ) {
        throw new Error('Address is not an active WalletTg account');
    }

    const { stack } = await provider.get('get_public_key', []);
    const value = stack.readBigNumber();
    if (value < 0n || value >= (1n << 256n)) {
        throw new Error('Invalid WalletTg public key');
    }

    // The uint256 getter value is converted to exactly 32 big-endian bytes.
    return Buffer.from(value.toString(16).padStart(64, '0'), 'hex');
}

If account-state lookup or the getter fails, reject verification. The verifier below supports the fixed-key wallets listed above and WalletTg. Add other wallet contracts only after defining how to validate their code and resolve their signing key.

Node.js verifier

verify-sign-data.ts
import { Address, Cell, contractAddress, loadStateInit } from '@ton/core';
import type { TonClient } from '@ton/ton';
import { sha256 } from '@ton/crypto';
import { Buffer } from 'node:buffer';
import nacl from 'tweetnacl';

// Save the two helpers above alongside this file.
import { tryExtractPublicKey } from './wallet-public-key.js';
import { getWalletTgPublicKey, WALLET_TG_CODE_HASH } from './wallet-tg-public-key.js';

interface SignDataResult {
    signature: string;
    address: string;
    timestamp: number;
    domain: string;
    payload: { type: 'text'; text: string } | { type: 'binary'; bytes: string };
}

export async function verifyTextOrBinary(
    res: SignDataResult,
    // Base64-encoded BoC string from the connect event.
    walletStateInit: string,
    // Configure for the connected account's network.
    client: TonClient,
    expected: { domain: string; maxAgeSeconds: number },
): Promise<boolean> {
    if (res.domain !== expected.domain) return false;
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - res.timestamp) > expected.maxAgeSeconds) return false;

    const addr = Address.parse(res.address);
    const stateInit = loadStateInit(Cell.fromBase64(walletStateInit).beginParse());
    const derivedAddress = contractAddress(addr.workChain, stateInit);
    if (!derivedAddress.equals(addr)) return false;

    const isWalletTg = stateInit.code?.hash().toString('hex') === WALLET_TG_CODE_HASH;
    let publicKey: Buffer | null;
    try {
        publicKey = isWalletTg
            ? await getWalletTgPublicKey(client, addr, stateInit)
            : tryExtractPublicKey(stateInit);
    } catch {
        return false;
    }
    if (!publicKey) return false;

    const wc = Buffer.alloc(4);
    wc.writeInt32BE(addr.workChain);

    const dom = Buffer.from(res.domain, 'utf8');
    const domLen = Buffer.alloc(4);
    domLen.writeUInt32BE(dom.length);

    const ts = Buffer.alloc(8);
    ts.writeBigUInt64BE(BigInt(res.timestamp));

    const data = res.payload.type === 'text'
        ? Buffer.from(res.payload.text, 'utf8')
        : Buffer.from(res.payload.bytes, 'base64');

    const dataLen = Buffer.alloc(4);
    dataLen.writeUInt32BE(data.length);

    const prefix = Buffer.from(res.payload.type === 'text' ? 'txt' : 'bin');

    const message = Buffer.concat([
        Buffer.from([0xff, 0xff]),
        Buffer.from('ton-connect/sign-data/'),
        wc, addr.hash, domLen, dom, ts, prefix, dataLen, data,
    ]);
    const digest = await sha256(message);

    return nacl.sign.detached.verify(
        new Uint8Array(digest),
        new Uint8Array(Buffer.from(res.signature, 'base64')),
        new Uint8Array(publicKey),
    );
}

Verifying cell signatures

For the cell variant the wallet hashes a TON cell, not a flat byte string. The cell carries the same five fields as the off-chain construction, plus a magic prefix and a CRC-32 of the schema:

// Not runnable: define the schema, domain, payload, wallet address, and private key.
import { beginCell } from '@ton/core';
import crc32 from 'crc-32';

const message = beginCell()
    // Magic prefix.
    .storeUint(0x75569022, 32)
    // Schema hash.
    .storeUint(crc32.buf(Buffer.from(schema, 'utf8')) >>> 0, 32)
    // Unix seconds.
    .storeUint(timestamp, 64)
    // MsgAddressInt.
    .storeAddress(userWalletAddress)
    // DNS wire format per TEP-81.
    .storeStringRefTail(encodedDomain)
    // The cell the dApp sent.
    .storeRef(payloadCell);

const signature = Ed25519Sign(message.hash(), privkey);

encodedDomain is the app domain in TEP-81 DNS wire format — labels reversed, each terminated by \0. For example, ton-connect.github.io becomes io\0github\0ton-connect\0.

TL-B schema

The TL-B schema for the signed message is:

message#75569022
    schema_hash:uint32
    timestamp:uint64
    user_address:MsgAddress
    {n:#} app_domain:^(SnakeData ~n)
    payload:^Cell
    = SignedMessage;

FunC verifier

A receiver stores the expected user_address, user_pubkey, and app_domain in contract data. Compile the schema hash into the code. Load the stored values and run six checks:

;; Not runnable: add an entrypoint and initialize the receiver's stored values.
#include "imports/stdlib.fc";

;; CRC-32 of your TL-B schema string.
const int SCHEMA_HASH = 0x047cf718;
;; 6 minutes.
const int SIGNATURE_TTL = 360;

global slice state::owner_address;
global int   state::user_pubkey;
global slice state::domain;

() load_data() impure {
    slice cs = get_data().begin_parse();
    state::owner_address = cs~load_msg_addr();
    state::user_pubkey   = cs~load_uint(256);
    state::domain        = cs~load_ref().begin_parse();
}

int verify_signature(cell signed, slice signature) method_id {
    load_data();
    var cs = signed.begin_parse();

    throw_unless(460, check_signature(cs.slice_hash(), signature, state::user_pubkey));

    int   prefix      = cs~load_uint(32);
    int   schema_hash = cs~load_uint(32);
    int   timestamp   = cs~load_uint(64);
    slice addr        = cs~load_msg_addr();
    slice domain      = cs~load_ref().begin_parse();

    throw_unless(461, prefix == 0x75569022);
    throw_unless(462, schema_hash == SCHEMA_HASH);
    throw_unless(463, (timestamp <= now()) & (now() < timestamp + SIGNATURE_TTL));
    throw_unless(464, equal_slice_bits(addr, state::owner_address));
    ;; Include SnakeData continuation cells in the domain comparison.
    throw_unless(465, domain.slice_hash() == state::domain.slice_hash());

    cell payload = cs~load_ref();
    return 1;
}

The contract must run all six checks: signature, magic prefix, schema hash, freshness, sender, domain. Skipping any one of them lets a different signature pass — the magic prefix alone does not bind the signature to a specific schema, sender, or dApp.

The SCHEMA_HASH constant is the CRC-32 of the exact UTF-8 schema string the dApp passes in signData. Compute it once at deploy time and hard-code it; recomputing it on-chain costs gas.

WalletTg key rotation

This verifier uses the public key stored in the receiver's own state. WalletTg rotation updates the wallet's stored publicKey; it does not update this receiver. Without a receiver-side update, the verifier stays fixed to its initialized key. Once the receiver stores a new key, signatures from the old key fail this verifier.

To support rotation, add an application-defined internal SetSigningKey operation. Store the WalletTg account address as the authorized sender. In recv_internal, reject bounced messages, read src from the internal-message envelope, and require it to match the stored address before parsing the operation.

Require an exact body such as op:uint32, keyVersion:uint32, newPublicKey:uint256. Accept only a keyVersion greater than the stored version, then save the version and key. Do not authorize an update from an address in the body or a public-key value returned by an off-chain getter. The receiver trusts an internal message emitted by its stored WalletTg account; it does not call a get-method.

Refresh the receiver in this order:

  1. Complete WalletTg key rotation and retain the new signing key.
  2. Send a separate WalletTg request, signed by the current key, that emits SetSigningKey to the receiver. WalletTg's key-change request updates WalletTg storage and emits an external-out event; it cannot include this receiver update.
  3. Wait for the receiver transaction to succeed and confirm its stored key changed before verifying signatures from the new key. Until the receiver processes the update, it still accepts signatures from the old key. WalletTg rotation does not update keys cached by receivers or backend verifiers that verify against a fixed key.

A strictly increasing keyVersion rejects duplicate and lower-version updates; a delayed message with a higher version can still be accepted. The timestamp check above still permits replay during its validity window; require a single-use nonce or consumed-state check when a signed payload must execute once.

Errors

CodeNameDescription
0UNKNOWN_ERRORUnknown error.
1BAD_REQUEST_ERRORBad request.
100UNKNOWN_APP_ERRORUnknown app.
300USER_REJECTS_ERRORUser declined the request.
400METHOD_NOT_SUPPORTEDMethod not supported.

See also

On this page