离线签名交易
如何使用 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_bytes | TAPOS:锚定近期区块高度 | 由构建交易的节点写入 |
raw_data.ref_block_hash | TAPOS:锚定同一参考区块 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_hex 和 txID。延长后的过期时间不得超过协议允许的上限。不要在生成 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_hex和txID。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_ERROR。TAPOS_ERROR 表示参考区块无效或已超出节点可识别的近期区块窗口,不是交易过期错误。
硬件钱包签名流程
硬件钱包托管私钥时,常见流程分三步:
- 联网机器构建未签名交易,并生成待签名的交易哈希(
txID)。 - 硬件设备展示交易信息,用户确认后用设备内私钥签名,输出 65 字节签名。
- 联网机器把
signature组装回交易体,再广播交易。
这种模式让私钥始终留在硬件设备中,联网机器只负责构建、组装和广播交易。
常见问题
- 参考区块太旧:
ref_block_bytes只有 2 字节,节点只在近期区块窗口中查找参考区块。超出窗口后,TAPOS 查找会失败并抛出TaposException。签名前应刷新参考区块,并在交易过期前完成广播。 - 离线时钟偏差:交易的
expiration和timestamp已由联网端确定,但离线代码仍用本地时间检查是否过期。本地时钟偏差过大会让这项检查产生误判。 - 传输中修改交易:修改
raw_data后,原有raw_data_hex和txID会失效。不要尝试继续签名,应回到联网端重新构建交易。 - 网络不匹配:签名交易已经绑定到参考区块所在网络。使用主网参考区块签名的交易,不能广播到 Shasta 或 Nile。
相关资源
- 交易 — 完整交易结构与 TAPOS 字段
- 签名校验 — 签名校验与 65 字节上链格式
- Trident — Java SDK 的离线交易支持
- Wallet-cli — 支持离线签名流程的命令行工具
Updated 14 days ago