Signing transactions offline

How to build a transaction with an SDK, sign it locally, and broadcast the signed transaction to the TRON network.

Offline signing means using the private key outside the node. The machine running the SDK may remain online to query chain state and build transactions; the signature is still local as long as the private key is not sent to the node. The node receives only the signed transaction.

Cold wallets, multi-party signing ceremonies, and isolated key custody can add another boundary by moving signing to a fully disconnected air-gapped device. This page first covers ordinary local SDK signing, followed by the optional two-machine air-gapped workflow.

📘

Prerequisites


Local signing with the SDK

TronWeb's transactionBuilder asks a Fullnode to build the unsigned transaction. trx.sign() signs locally with the supplied private key, and sendRawTransaction() sends only the signed transaction to the node.

This example reads the private key from an environment variable and validates the transaction returned by the node before signing:

const { TronWeb } = require('tronweb');

async function main() {
  const tronWeb = new TronWeb({
    fullHost: 'https://api.shasta.trongrid.io'
  });
  const privateKey = process.env.TRON_PRIVATE_KEY;
  const recipient = process.env.TRON_RECIPIENT_ADDRESS;
  const amount = 10_000_000; // 10 TRX in sun

  if (!/^[0-9a-fA-F]{64}$/.test(privateKey || '')) {
    throw new Error('TRON_PRIVATE_KEY must be a 64-character hex string');
  }
  if (!TronWeb.isAddress(recipient)) {
    throw new Error('Invalid recipient address');
  }

  const sender = TronWeb.address.fromPrivateKey(privateKey);
  const unsignedTxn = await tronWeb.transactionBuilder.sendTrx(
    recipient,
    amount,
    sender
  );

  const contract = unsignedTxn.raw_data?.contract?.[0];
  const value = contract?.parameter?.value;
  if (
    unsignedTxn.raw_data?.contract?.length !== 1 ||
    contract?.type !== 'TransferContract' ||
    !value ||
    TronWeb.address.fromHex(value.owner_address) !== sender ||
    TronWeb.address.fromHex(value.to_address) !== recipient ||
    Number(value.amount) !== amount
  ) {
    throw new Error('Transaction validation failed');
  }

  // Sign locally; privateKey is never sent to the node.
  const signedTxn = await tronWeb.trx.sign(unsignedTxn, privateKey);
  const result = await tronWeb.trx.sendRawTransaction(signedTxn);
  console.log(result);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
⚠️

Never send a private key to a node

Do not use node signing endpoints that require the private key in the request body, such as /wallet/gettransactionsign. In production, also use trusted SDK dependencies and hosts, and prefer a keystore, hardware wallet, or HSM for high-value keys.


Air-gapped signing (optional)

Air-gapped signing keeps the private key on a fully disconnected device. The online machine builds and exports unsigned transaction JSON, the offline device independently validates and signs it, and the online machine broadcasts it. TronWeb's transactionBuilder builds transactions through a node; leaving fullHost empty does not enable transaction construction in a fully offline environment.

Transaction fields to verify

When a TronWeb builder is connected to a Fullnode, the node populates the following protocol fields; fee_limit applies only to smart contract transactions. After transferring the transaction to the offline environment, verify the fields that apply to its transaction type, but do not edit them: any change alters the transaction hash and invalidates the existing txID.

FieldWhat it doesSource
raw_data.ref_block_bytesTAPOS — anchor to a recent block's numberSet by the node that builds the transaction
raw_data.ref_block_hashTAPOS — anchor to that block's IDSet by the node that builds the transaction
raw_data.expirationWhen the transaction is no longer validSet by the node; it must be later than the expected broadcast time
raw_data.timestampCreation timeSet by the node that builds the transaction
raw_data.fee_limitCaller-side Energy fee limit for smart contract transactionsSet when building a contract transaction, in sun

When constructing a transaction, the node normally uses the latest solidified block as the reference block. That block must be within the latest 65,536-block window recognized by the node.


Two-machine workflow

1. Online machine: build the unsigned transaction

The online machine calls a node to build the transaction but never loads the private key:

const { TronWeb } = require('tronweb');
const { writeFileSync } = require('node:fs');

async function main() {
  const onlineTron = new TronWeb({
    fullHost: 'https://api.shasta.trongrid.io'
  });

  const sender = process.env.TRON_SENDER_ADDRESS;
  const recipient = process.env.TRON_RECIPIENT_ADDRESS;
  const amount = 10_000_000; // 10 TRX in sun

  if (!TronWeb.isAddress(sender) || !TronWeb.isAddress(recipient)) {
    throw new Error('Invalid sender or recipient address');
  }

  let unsignedTxn = await onlineTron.transactionBuilder.sendTrx(
    recipient,
    amount,
    sender
  );

  // Optional: add one hour for cross-device transfer and recalculate raw_data_hex and txID.
  unsignedTxn = await onlineTron.transactionBuilder.extendExpiration(unsignedTxn, 3600);

  // Transfer this file to the offline machine over a controlled channel.
  writeFileSync('unsigned-transaction.json', JSON.stringify(unsignedTxn), { mode: 0o600 });
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

The default expiration window is typically about 60 seconds. This example calls extendExpiration online to add one hour; the method updates raw_data_hex and txID together. The resulting expiration must remain within the protocol limit. Do not edit raw_data.expiration directly after the txID has been generated.

2. Offline machine: validate and sign

Independently verify the contract type, sender, recipient, amount, and expiration before signing. This example reads the private key from an environment variable and performs no network request during signing:

const { TronWeb } = require('tronweb');
const { readFileSync, writeFileSync } = require('node:fs');

async function main() {
  const offlineTron = new TronWeb({ fullHost: 'http://placeholder.invalid' });
  const privateKey = process.env.TRON_PRIVATE_KEY;
  const expectedRecipient = process.env.TRON_RECIPIENT_ADDRESS;
  const expectedAmount = 10_000_000;
  const txToSign = JSON.parse(readFileSync('unsigned-transaction.json', 'utf8'));

  if (!/^[0-9a-fA-F]{64}$/.test(privateKey || '')) {
    throw new Error('TRON_PRIVATE_KEY must be a 64-character hex string');
  }
  if (!TronWeb.isAddress(expectedRecipient)) {
    throw new Error('Invalid expected recipient address');
  }

  const contract = txToSign.raw_data?.contract?.[0];
  if (
    txToSign.raw_data?.contract?.length !== 1 ||
    contract?.type !== 'TransferContract' ||
    !contract.parameter?.value
  ) {
    throw new Error('Expected exactly one TransferContract');
  }

  const value = contract.parameter.value;
  const sender = TronWeb.address.fromHex(value.owner_address);
  const recipient = TronWeb.address.fromHex(value.to_address);
  const keyAddress = TronWeb.address.fromPrivateKey(privateKey);

  if (sender !== keyAddress || recipient !== expectedRecipient) {
    throw new Error('Transaction address validation failed');
  }
  if (Number(value.amount) !== expectedAmount) {
    throw new Error('Transaction amount validation failed');
  }
  if (Date.now() >= Number(txToSign.raw_data.expiration)) {
    throw new Error('Transaction has expired');
  }

  // trx.sign validates raw_data, raw_data_hex, and txID, then signs the existing txID.
  const signedTxn = await offlineTron.trx.sign(txToSign, privateKey);
  writeFileSync('signed-transaction.json', JSON.stringify(signedTxn), { mode: 0o600 });
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

If the transaction was modified in transit, TronWeb rejects it with Invalid transaction; it does not derive a new txID from the modified raw_data. Return to the online machine and rebuild the complete transaction whenever any field must change.

📘

Fully offline construction

If the unsigned transaction body must also be generated offline, construct and encode raw_data according to TRON's Protobuf transaction schema, then derive raw_data_hex and txID from the encoded bytes. TronWeb's transactionBuilder builds transactions through a node and cannot construct them independently in a fully offline environment.


3. Online machine: broadcast the signed transaction

Once back online, broadcast the saved signed transaction with:

const { TronWeb } = require('tronweb');
const { readFileSync } = require('node:fs');

async function main() {
  const onlineTron = new TronWeb({
    fullHost: 'https://api.shasta.trongrid.io'
  });
  const txToSend = JSON.parse(readFileSync('signed-transaction.json', 'utf8'));
  const result = await onlineTron.trx.sendRawTransaction(txToSend);
  console.log(result.txid);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

If the transaction has passed its expiration, the node returns TRANSACTION_EXPIRATION_ERROR. TAPOS_ERROR instead means that the reference block is invalid or outside the node's recent-block window.


Hardware-wallet signing

For hardware-wallet workflows where signing happens on the device:

  1. Connected machine builds the unsigned transaction and generates the transaction hash (txID) to be signed.
  2. Hardware device displays the parsed transaction details for user confirmation, signs the transaction hash with the on-device key, and emits the 65-byte signature.
  3. Connected machine assembles signature into the transaction and broadcasts it.

This pattern lets the device maintain key custody without needing a full TRON node implementation onboard.


Common pitfalls

  • Reference block too oldref_block_bytes is only 2 bytes, so the node's RecentBlockStore retains a bounded window of reference blocks. Beyond that retention the TAPOS lookup fails (TaposException). Refresh the reference block within a few minutes of signing, and broadcast within the transaction's expiration window.
  • Offline clock skew — the online side has already fixed expiration and timestamp, but the offline example uses its local clock to check expiration. A badly skewed clock can make that local check inaccurate.
  • Transaction modified in transit — changing raw_data invalidates the existing raw_data_hex and txID. Return to the online side and rebuild the transaction instead of attempting to sign it.
  • Wrong network — the signed transaction is bound to the network whose reference block you used. A Mainnet reference block cannot be used to broadcast on Shasta.

Related resources