Sign and broadcast — the API workflow

Every state-changing operation on TRON follows the same three-step workflow: construct an unsigned transaction, sign it client-side with the sender's private key, and broadcast the signed transaction. This page explains the lifecycle, gives a worked example, and lists common pitfalls.

Every TRON operation that changes state — a TRX transfer, a contract call, staking, voting, deploying a contract — follows the same three-step workflow:

  1. Construct the unsigned transaction by calling a node API
  2. Sign the transaction client-side with the sender's private key
  3. Broadcast the signed transaction back to a node

Read-only operations (querying balances, reading contract state, fetching blocks) skip steps 2 and 3 entirely — they are single requests returning chain state. This page is about the write path.

📘

Prerequisites


The three-step workflow

┌───────────────────┐     ┌──────────────────┐     ┌────────────────────┐
│ 1. Construct      │     │ 2. Sign          │     │ 3. Broadcast       │
│ POST a builder    │ ──> │ Client-side:     │ ──> │ POST signed tx to  │
│ endpoint to get   │     │ SHA-256(raw_data)│     │ /wallet/broadcast- │
│ unsigned raw_data │     │ + secp256k1 sign │     │ transaction        │
└───────────────────┘     └──────────────────┘     └────────────────────┘
        ↓ unsigned tx              ↓ signed tx              ↓ txid

The signing step happens on the client side. Your private key never leaves your machine — neither the node nor TronGrid ever sees it.


Step 1 — Construct the unsigned transaction

The TRON node exposes one builder endpoint per contract type. The client sends a JSON request describing the operation; the node returns a fully-formed but unsigned transaction object.

OperationBuilder endpoint
Transfer TRX/wallet/createtransaction
Transfer TRC-10/wallet/transferasset
Call a smart contract (including TRC-20 transfer)/wallet/triggersmartcontract
Stake TRX (Stake 2.0)/wallet/freezebalancev2
Vote for Super Representatives/wallet/votewitnessaccount
Deploy a smart contract/wallet/deploycontract

The returned JSON has three top-level fields you need to understand:

  • txID — the SHA-256 hash of raw_data; this is what gets signed
  • raw_data — the structured transaction body (contract type, parameters, TAPOS reference, expiration)
  • raw_data_hex — protobuf serialization of raw_data, ready for hashing

The node also populates two independent timing fields:

  • TAPOS reference (ref_block_bytes, ref_block_hash) — pins the transaction to a recent block, preventing replay across forks. If that block falls off the canonical chain (microfork / reorg), the TAPOS check fails on inclusion.
  • expiration — a separate deadline field. Default is ~60 seconds, set at construction time. If the node built the transaction (via /wallet/createtransaction and similar endpoints), that node's trx.expiration.timeInMilliseconds in config.conf chooses the default; if you built it client-side with an SDK (TronWeb, Trident, GoTron), the SDK sets the value (most SDKs let you override per transaction). The network rejects the transaction as expired if not included by that deadline; the upper bound is 24 hours from the head block.

Construct, sign, and broadcast within ~50 seconds — otherwise the transaction is rejected as expired.


Step 2 — Sign the transaction

Signing happens in three sub-steps client-side:

  1. Compute SHA-256(raw_data) — the result equals txID
  2. Sign that hash with the sender's secp256k1 private key
  3. Append the 65-byte signature to transaction.signature[]

All SDKs wrap these three sub-steps behind a single call:

SDKSigning call
TronWeb (JavaScript)tronWeb.trx.sign(unsignedTx, privateKey)
Trident (Java)wrapper.signTransaction(unsignedTx)

For applications that cannot use an SDK — air-gapped signers, HSMs, hardware wallets — compute the hash yourself from raw_data_hex and sign with a secp256k1 library. The signing primitives are documented at the protocol level; see Transactions.

Multi-signature transactions follow the same flow with one difference: each cosigner produces a signature against the same txID, and all signatures are collected into signature[] before broadcasting. See Account permission management.


Step 3 — Broadcast the signed transaction

POST the fully-signed transaction back to /wallet/broadcasttransaction:

POST /wallet/broadcasttransaction
Content-Type: application/json

{
  "txID": "...",
  "raw_data": { ... },
  "raw_data_hex": "...",
  "signature": ["..."]
}

A successful broadcast returns:

{ "result": true, "txid": "f947f1283f7b43c111eba662..." }

The transaction is now in the mempool. Block production picks it up within one block interval (3 seconds on average). To confirm execution, poll /walletsolidity/gettransactioninfobyid until the transaction appears at a solidified height.

Broadcast does not mean executed. A successful broadcast means the transaction was accepted into the mempool. It can still fail at execution time — for example, a smart-contract call might revert, or insufficient Bandwidth/Energy might cause execution failure. If your application needs final state, query the receipt from a solidified block instead of treating the broadcast response as final. See Broadcast and RPC errors.


Step 4 — Confirm the transaction result

After broadcast, a transaction still moves through acceptance, block inclusion, execution, and solidification. Different APIs return data from different stages and should not be used interchangeably.

StageRecommended APIReturn semanticsNext step
Construct unsigned transactionCreateTransaction, TriggerSmartContract, and other builder APIsReturns raw_data, raw_data_hex, and txID. The transaction is not signed and has not entered the network.Sign locally
Check signature weightGetSignWeightShows whether current signatures reach the target permission threshold.Broadcast after the threshold is reached
Broadcast signed transactionBroadcastTransaction, BroadcastHexresult: true means the broadcast node accepted the transaction into its local mempool.Continue querying transaction status
Query transaction bodyGetTransactionByIdReturns original transaction data, including raw_data, signatures, and contract-call parameters. It does not include the execution receipt.Query the receipt when execution result matters
Query execution receiptGetTransactionInfoByIdReturns fee, Energy usage, contract execution result, events, and internal transactions.Decide whether execution succeeded or failed
Query solidified resultGetTransactionInfoById (SolidityNode)Returns receipts only from solidified blocks.Use this when final state is required

If you need a final result, use the SolidityNode receipt as the confirmation source. If a receipt is not found immediately, do not mark the transaction as failed right away. Wait for the transaction expiration window and combine follow-up queries with block scanning to determine whether it was included.

For ordinary TRX and TRC-10 top-level transfers, the transaction body usually contains the recipient and amount. For TriggerSmartContract, read the receipt to determine the actual execution result. TRC-20 transfers should be detected from the Transfer(address,address,uint256) event in the receipt logs. Contract-derived TRX or TRC-10 transfers should be detected from internal_transactions[].

Wait for solidification and check the execution result

The following Node.js 18+ script queries the same txID until SolidityNode returns a solidified receipt. Ordinary non-contract transactions may omit receipt.result; smart-contract transactions should return SUCCESS. The script also checks whether the top-level result is FAILED. If the retry limit is reached, keep querying the original txID while investigating instead of creating a replacement transaction that could duplicate a payment.

const solidityNode = (process.env.SOLIDITY_NODE_URL || 'https://api.trongrid.io')
  .replace(/\/$/, '');
const txID = process.env.TX_ID;

if (!/^[0-9a-fA-F]{64}$/.test(txID || '')) {
  throw new Error('Set TX_ID to a 64-character hexadecimal transaction ID');
}

async function queryReceipt() {
  const response = await fetch(`${solidityNode}/walletsolidity/gettransactioninfobyid`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ value: txID }),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

for (let attempt = 1; attempt <= 30; attempt += 1) {
  const info = await queryReceipt();
  if (info.id?.toLowerCase() === txID.toLowerCase()) {
    const executionResult = info.receipt?.result;
    if (info.result === 'FAILED' || (executionResult && executionResult !== 'SUCCESS')) {
      throw new Error(`Transaction execution failed: ${executionResult || info.result}`);
    }
    console.log(`Transaction solidified in block ${info.blockNumber}`);
    process.exit(0);
  }
  await new Promise((resolve) => setTimeout(resolve, 3000));
}

throw new Error('No solidified receipt found yet; continue querying the same TX_ID');

The script uses the TronGrid Mainnet endpoint by default. Set SOLIDITY_NODE_URL to query a self-hosted SolidityNode. Save the script as confirm-transaction.mjs, then run:

export TX_ID="replace-with-the-txid-from-the-broadcast-response"
# Optional: export SOLIDITY_NODE_URL="https://your-solidity-node"
node confirm-transaction.mjs

Worked example: stake TRX

The full lifecycle for a Stake 2.0 deposit:

1. Construct

POST /wallet/freezebalancev2
Content-Type: application/json

{
  "owner_address": "41e552f6487585c2b58bc2c9bb4492bc1f17132cd0",
  "frozen_balance": 1000000,
  "resource": "ENERGY"
}

Response:

{
  "visible": false,
  "txID": "f947f1283f7b43c111eba662ab5d27b31dc906b77fdb0b3e52ee626335b21108",
  "raw_data": {
    "contract": [{
      "parameter": {
        "value": {
          "resource": "ENERGY",
          "frozen_balance": 1000000,
          "owner_address": "41e552f6487585c2b58bc2c9bb4492bc1f17132cd0"
        },
        "type_url": "type.googleapis.com/protocol.FreezeBalanceV2Contract"
      },
      "type": "FreezeBalanceV2Contract"
    }],
    "ref_block_bytes": "a59c",
    "ref_block_hash": "aef359052c4aa176",
    "expiration": 1681117668000,
    "timestamp": 1681117610802
  },
  "raw_data_hex": "0a02a59c2208aef359052c4aa17640a0c5afd3f6305a59083612550a34..."
}

2. Sign

const signedTx = await tronWeb.trx.sign(unsignedTx, privateKey);
// signedTx now has signature[0] populated

3. Broadcast

POST /wallet/broadcasttransaction
Content-Type: application/json

{ ...signedTx }

Response:

{ "result": true, "txid": "f947f1283f7b43c111eba662ab5d27b31dc906b77fdb0b3e52ee626335b21108" }

The 1 TRX stake (1,000,000 sun) is now in the mempool. Once included in a block and solidified, the staked amount and resulting Energy quota are visible via /wallet/getaccountresource.


Common pitfalls

  • TAPOS reference invalidationref_block_bytes and ref_block_hash must point to a block that remains on the canonical chain. The primary failure mode is a chain reorganization (microfork) that drops the referenced block, causing TAPOS_ERROR rejection on inclusion. Fix: where possible, reference a recently solidified block rather than the chain tip.
  • Transaction expiry — separately from TAPOS, the expiration field (default ~60 seconds) caps how long the transaction can sit before inclusion. The default comes from the constructor: node-built transactions take it from the node's trx.expiration.timeInMilliseconds (config.conf), client-built transactions take it from the SDK (most SDKs accept a per-call override). Long delays between construct and broadcast cause expired-transaction rejection. Fix: construct as late as possible, sign quickly, broadcast immediately.
  • visible flag mismatch — when visible=true, addresses in the request and response are base58 (T...); when false, they are hex (41...). Inconsistent use across construct and sign causes signature verification failure. See Encoding.
  • Signing the wrong hash — the signing hash is SHA-256(raw_data), not Keccak-256, and not raw_data_hex's hash directly (although both produce the same bytes). Use the SDK signing call rather than rolling your own.
  • Reusing txID after expiry — once a transaction expires, the same txID is dead; reconstruct from scratch rather than re-signing the old raw_data.
  • Treating broadcast result as success{"result": true} only confirms mempool acceptance. Check /walletsolidity/gettransactioninfobyid for execution outcome. Smart-contract reverts and Energy shortfalls report success on broadcast but FAILED on execution.
  • Double-broadcasting — a duplicate broadcast of the same signed transaction returns the same txid but is harmless; the network deduplicates. Mid-flight retries are safe.
  • Server-side signing of user keys — never sign with a user's private key on your server. Either keep keys client-side, use a wallet (TronLink, Adapter), or use an HSM with strict access controls.

Related resources