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 response with result: true has the following form:

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

Continue querying the original txID after this response. For a direct TRX or TRC-10 transfer, use /walletsolidity/gettransactionbyid to confirm inclusion in the solidified chain. For a smart-contract transaction, also query /walletsolidity/gettransactioninfobyid to verify the solidified execution result.

A broadcast response is not confirmation. result: true means only that the node reported no error for that broadcast request. It does not prove that the transaction remains in the node's Pending Pool, has propagated, has been included, has executed successfully, or has become solidified. See Broadcast and RPC errors.


Step 4 — Confirm the transaction result

After sending a signed transaction to a node, continue querying the original txID to determine whether the transaction has been included in a block, executed successfully, and become solidified. The table below shows which API to use at each stage and what its response means.

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 call completed without a reported error; it does not establish later transaction state.Continue querying transaction status
Query latest-state transaction bodyGetTransactionByIdReturns original transaction data, including raw_data, signatures, and contract-call parameters. It does not include the execution receipt.Use for a low-latency observation; continue to solidified-state confirmation
Confirm solidified inclusion/walletsolidity/gettransactionbyidReturns the transaction body only after its block is solidified.Use this for direct TRX and TRC-10 transfers
Query latest-state execution receiptGetTransactionInfoByIdReturns fees, Energy usage, contract execution results, events, and internal transactions from the FullNode view.Use for an early execution result; continue to solidified-state confirmation
Confirm solidified smart-contract executionGetTransactionInfoById (solidified state)Returns execution information only from solidified blocks.Require the top-level result not to be FAILED and receipt.result to be SUCCESS

To confirm a transaction's final state, first use /walletsolidity/gettransactionbyid to verify that its body is in the solidified canonical chain. For a smart-contract transaction, also use /walletsolidity/gettransactioninfobyid to check the solidified execution result. If a required result is not yet available, retain the original txID and continue querying; do not immediately mark the transaction as failed.

Before creating a replacement, establish that the original transaction was not included. Wait until a reliable, synchronized node's latest solidified block timestamp is greater than or equal to the original raw_data.expiration, then query the original txID through /walletsolidity/gettransactionbyid. An empty response does not by itself prove that the transaction was not included. If the node or RPC service can provide every solidified block from the transaction's first submission through raw_data.expiration, inspect that interval block by block through /walletsolidity/getblockbynum. If the first submission time is unknown or the current service cannot provide complete block history for the interval, cross-check against a source that retains complete history. Only after completing these checks and finding no record of the original txID can you conclude that the transaction did not enter the solidified canonical chain.

To confirm an asset transfer, first identify the protocol-level transaction type and then read the corresponding data:

  • System-contract transfer transaction: TransferContract transfers TRX, while TransferAssetContract transfers TRC-10. The transaction body records the recipient and amount. After the transaction is solidified, ret[0].contractRet, when present, must be SUCCESS.
  • Smart-contract call transaction: For TriggerSmartContract, first query the solidified receipt and confirm that the top-level result is not FAILED and receipt.result is SUCCESS. Then examine the asset movements involved in the call:
    • call_value sends TRX from the caller to the target contract. call_token_value together with token_id sends TRC-10 to the target contract.
    • Any further TRX or TRC-10 movements produced during contract execution appear in internal_transactions[]. Exclude entries with rejected = true, and validate transferTo_address and callValueInfo[].
    • A TRC-20 transfer is implemented as a state change in the token contract. Accept only Transfer(address,address,uint256) events emitted by token contracts that the integrating service has reviewed and allow-listed, and validate the sender, recipient, and amount. An event from an unreviewed contract is not sufficient evidence of a successful transfer.

Wait for solidification and check execution results

The following Node.js 18+ script answers two questions after broadcast: whether the transaction has entered the solidified canonical chain and, for a smart-contract transaction, whether execution succeeded. It always tracks the original txID and never constructs or broadcasts another transaction.

The script first queries /walletsolidity/gettransactionbyid for the transaction body. A matching response confirms inclusion in the solidified canonical chain. If ret[0].contractRet is present and is not SUCCESS, the script reports an execution failure. For CreateSmartContract and TriggerSmartContract, it also queries /walletsolidity/gettransactioninfobyid for the execution receipt and reports successful execution only when the top-level result is not FAILED and receipt.result is SUCCESS.

If the transaction body is still unavailable after the retry limit, or if a required smart-contract execution receipt has not yet appeared, the script does not classify the transaction as failed. Retain the original txID and continue querying; do not immediately create a replacement that could cause a duplicate 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 query(path) {
  const response = await fetch(`${solidityNode}/${path}`, {
    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 transaction = await query('walletsolidity/gettransactionbyid');
  if (transaction.txID?.toLowerCase() === txID.toLowerCase()) {
    const contractResult = transaction.ret?.[0]?.contractRet;
    const transactionFailed = contractResult && contractResult !== 'SUCCESS';
    if (transactionFailed) {
      throw new Error(`The transaction is solidified but execution failed: ${contractResult}`);
    }

    const contractTypes = transaction.raw_data?.contract?.map(({ type }) => type) || [];
    const needsReceipt = contractTypes.some((type) =>
      type === 'CreateSmartContract' || type === 'TriggerSmartContract');

    if (!needsReceipt) {
      console.log('Transaction included in the solidified chain');
      process.exit(0);
    }

    const info = await query('walletsolidity/gettransactioninfobyid');
    if (info.id?.toLowerCase() !== txID.toLowerCase()) {
      await new Promise((resolve) => setTimeout(resolve, 3000));
      continue;
    }
    const executionResult = info.receipt?.result;
    if (info.result === 'FAILED') {
      throw new Error('Smart-contract execution failed: FAILED');
    }
    if (executionResult === undefined) {
      await new Promise((resolve) => setTimeout(resolve, 3000));
      continue;
    }
    if (executionResult !== 'SUCCESS') {
      throw new Error(`Smart-contract execution failed: ${executionResult}`);
    }
    console.log(`Smart-contract transaction solidified and executed successfully in block ${info.blockNumber}`);
    process.exit(0);
  }
  await new Promise((resolve) => setTimeout(resolve, 3000));
}

throw new Error('Required solidified result not found yet; continue querying the same TX_ID');

The script uses the TronGrid Mainnet endpoint by default. To use another node service that exposes the /walletsolidity endpoints, set its HTTP base URL in SOLIDITY_NODE_URL. 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-walletsolidity-endpoint"
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" }

Continue tracking this stake transaction by its original txID. Once it is 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.
  • Creating a replacement solely because the local clock has passed raw_data.expiration — the local clock passing this value neither proves that the original transaction was not included nor replaces a check based on solidified block timestamps. If the transaction was sent or its broadcast outcome is unknown, first follow the safe rebroadcast and replacement procedure: wait for the solidified view to cover the transaction's validity window and check the relevant solidified blocks. Rebuild and sign only after confirming that the original txID is absent from the solidified canonical chain. If you can establish that the transaction was never sent to any node and simply expired before its first submission, you may rebuild it immediately.
  • Treating the broadcast result as final success{"result": true} means only that the broadcast call completed without a reported error. It does not prove Pending Pool retention, propagation, inclusion, successful execution, or solidification. Confirm direct TRX and TRC-10 transfers through /walletsolidity/gettransactionbyid; for smart-contract transactions, also inspect /walletsolidity/gettransactioninfobyid, require the top-level result not to be FAILED, and require receipt.result to be SUCCESS.
  • Double-broadcasting — rebroadcasting the exact signed transaction is harmless because its txID does not change and the network deduplicates it. A receiving node may return DUP_TRANSACTION_ERROR; that response does not by itself prove block inclusion.
  • 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