离线签名交易

如何使用 SDK 构建交易,在本地完成签名,再将已签名交易广播到 TRON 网络。

离线签名是指在节点之外使用私钥完成签名。运行 SDK 的机器可以联网查询链状态和构建交易,只要私钥不发送给节点,签名仍属于本地签名。签名完成后,节点只接收已签名交易。

对于冷钱包、多方签名仪式和隔离密钥托管,还可以把签名操作放在完全断网的设备上。本文先介绍常规的 SDK 本地签名,再介绍可选的双机完全离线签名流程。

📘

前置阅读


SDK 本地签名流程

TronWeb 的 transactionBuilder 会调用全节点构建未签名交易,trx.sign() 使用传入的私钥在 SDK 本地签名,sendRawTransaction() 再把已签名交易发送给节点。私钥不会包含在构建或广播请求中。

以下示例从环境变量读取私钥,并在签名前核对节点返回的交易内容:

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,单位为 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');
  }

  // 本地签名;privateKey 不会发送给节点。
  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;
});
⚠️

不要把私钥发送给节点

不要调用要求在请求体中提交私钥的节点签名接口,例如 /wallet/gettransactionsign。生产环境还应使用受信任的 SDK 依赖和运行主机,并优先通过 keystore、硬件钱包或 HSM 管理高价值密钥。


完全离线签名流程(可选)

完全离线签名把私钥保存在完全断网的设备上。联网机器构建未签名交易并导出 JSON,完全断网的设备独立核对并签名,最后由联网机器广播。TronWeb 的 transactionBuilder 通过节点构建交易;将 fullHost 留空并不能使其在完全离线环境中构建交易。

需要核对的交易字段

联网调用 TronWeb 构建方法时,全节点会填充以下协议字段,其中 fee_limit 仅适用于智能合约交易。把交易传入离线环境后,应核对适用于该交易类型的字段,但不要直接修改;任何修改都会改变交易哈希并使原 txID 失效。

字段作用来源
raw_data.ref_block_bytesTAPOS:锚定近期区块高度由构建交易的节点写入
raw_data.ref_block_hashTAPOS:锚定同一参考区块 ID由构建交易的节点写入
raw_data.expiration交易过期时间由节点按配置写入;必须晚于预计广播时间
raw_data.timestamp交易构建时间由构建交易的节点写入
raw_data.fee_limit智能合约交易调用方可承担的 Energy 费用上限构建合约交易时设置,单位为 sun

节点构建交易时通常使用最新固化区块作为参考区块;该区块必须位于节点可识别的最近 65,536 个区块窗口内。


双机工作流

1. 联网机器:构建未签名交易

联网机器只负责调用节点构建交易,不加载私钥:

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,单位为 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
  );

  // 可选:为跨设备传输增加 1 小时,并同步重新计算 raw_data_hex 和 txID。
  unsignedTxn = await onlineTron.transactionBuilder.extendExpiration(unsignedTxn, 3600);

  // 将该文件通过二维码或其他受控通道传入离线机器。
  writeFileSync('unsigned-transaction.json', JSON.stringify(unsignedTxn), { mode: 0o600 });
}

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

默认过期窗口通常约为 60 秒。示例在联网端调用 extendExpiration 延长 1 小时;该方法会同步更新 raw_data_hextxID。延长后的过期时间不得超过协议允许的上限。不要在生成 txID 后直接修改 raw_data.expiration

2. 离线机器:校验并签名

离线端必须独立核对交易类型、发送方、接收方、金额和过期时间。以下示例从环境变量读取私钥,且签名过程不会发起网络请求:

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 会校验 raw_data、raw_data_hex 与 txID 是否一致,然后签署现有 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;
});

如果交易在传输过程中被修改,TronWeb 的一致性校验会拒绝签名并返回 Invalid transaction,而不是根据修改后的 raw_data 自动生成新的 txID。需要修改任何交易字段时,应回到联网机器重新构建完整交易。

📘

完全离线构建

如果业务要求在离线端生成未签名交易体,需要按照 TRON 的 Protobuf 交易格式构造并编码 raw_data,再根据编码结果生成 raw_data_hextxID。TronWeb 的 transactionBuilder 通过节点构建交易,不支持在完全离线环境中独立构建交易。


3. 联网机器:广播已签名交易

回到联网环境后,广播已签名交易:

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;
});

如果广播时交易已经超过 expiration,节点会返回 TRANSACTION_EXPIRATION_ERRORTAPOS_ERROR 表示参考区块无效或已超出节点可识别的近期区块窗口,不是交易过期错误。


硬件钱包签名流程

硬件钱包托管私钥时,常见流程分三步:

  1. 联网机器构建未签名交易,并生成待签名的交易哈希(txID)。
  2. 硬件设备展示交易信息,用户确认后用设备内私钥签名,输出 65 字节签名。
  3. 联网机器signature 组装回交易体,再广播交易。

这种模式让私钥始终留在硬件设备中,联网机器只负责构建、组装和广播交易。


常见问题

  • 参考区块太旧ref_block_bytes 只有 2 字节,节点只在近期区块窗口中查找参考区块。超出窗口后,TAPOS 查找会失败并抛出 TaposException。签名前应刷新参考区块,并在交易过期前完成广播。
  • 离线时钟偏差:交易的 expirationtimestamp 已由联网端确定,但离线代码仍用本地时间检查是否过期。本地时钟偏差过大会让这项检查产生误判。
  • 传输中修改交易:修改 raw_data 后,原有 raw_data_hextxID 会失效。不要尝试继续签名,应回到联网端重新构建交易。
  • 网络不匹配:签名交易已经绑定到参考区块所在网络。使用主网参考区块签名的交易,不能广播到 Shasta 或 Nile。

相关资源

  • 交易 — 完整交易结构与 TAPOS 字段
  • 签名校验 — 签名校验与 65 字节上链格式
  • Trident — Java SDK 的离线交易支持
  • Wallet-cli — 支持离线签名流程的命令行工具