fix(tvm): never lose the txid on a Tron broadcast failure - #1532
fix(tvm): never lose the txid on a Tron broadcast failure#1532droplet-rl wants to merge 4 commits into
Conversation
Tron has no nonce, so a transaction cannot be replaced -- a resubmission is
a second, independent transaction. That makes the txid the only handle a
caller has on an in-flight submission, and submitTransaction was dropping it
in two places.
- A throw from sendRawTransaction discarded the signed transaction's txID,
even though signing had already fixed it. The send now rethrows a
TronBroadcastError carrying the txid, so a caller can reconcile on-chain
instead of blindly resubmitting.
- DUP_TRANSACTION_ERROR ("already known") was reported as a failed send. The
node is holding that exact transaction, so it is now a successful send with
the txid, matching how the EVM path treats a duplicate submission.
Rejected broadcasts also carry the node's response_code and its (hex-decoded)
message, so callers can tell a validation rejection from an ambiguous one.
The native-transfer path had the same defect via trx.sendTransaction, which
fuses build/sign/broadcast; it now runs the three steps separately and shares
the broadcast handling.
Co-Authored-By: Claude <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2f3ffcf937
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| const broadcast = await tronWeb.trx.sendTransaction(recipient, amount); | ||
| const txn = await tronWeb.transactionBuilder.sendTrx(recipient, amount, owner); |
There was a problem hiding this comment.
Reject fractional transfer amounts before sendTrx
When callValue is a positive non-integer number of SUN, this path no longer preserves the validation performed by the previous trx.sendTransaction call. TronWeb's transactionBuilder.sendTrx applies parseInt(amount) before validating it, so a value such as 1.9 is silently converted to 1 and broadcast instead of being rejected, causing the recipient to receive a different amount than requested. Validate that amount is an integer before calling the builder.
Useful? React with 👍 / 👎.
| const code = isDefined(broadcast.code) ? String(broadcast.code) : undefined; | ||
|
|
||
| // A duplicate is not a failed send: the node is holding this exact transaction. | ||
| if (code === DUP_TRANSACTION_ERROR) { |
There was a problem hiding this comment.
Handle numeric duplicate response codes
When a provider returns the numeric response-code form declared by TronWeb's Types.BroadcastReturn (DUP_TRANSACTION_ERROR is enum value 5), converting it with String produces "5", so this comparison cannot recognize the duplicate. The exact same transaction is then reported as failed, potentially prompting the unsafe rebuild/resubmission this change is intended to prevent; normalize the numeric enum to its name or explicitly accept value 5.
Useful? React with 👍 / 👎.
|
I've used my 1 automated round on this PR, so I'm ignoring further automated-reviewer activity. To re-activate me: have a human @-mention |
Two follow-ups from review of the txid-preservation change. transferNative replaced trx.sendTransaction, which rejected a non-integer amount outright, with transactionBuilder.sendTrx, which parseInt()s the amount before validating it -- so a fractional value was truncated and broadcast rather than rejected, short-changing the recipient. Restore the guard. The contract-call path needs none: TronWeb validates callValue as an integer before building. The DUP_TRANSACTION_ERROR test compared against the response_code name, which is what a TRON HTTP node returns, but TronWeb's typings declare the protocol's numeric enum (DUP_TRANSACTION_ERROR = 5). Against a provider using that form the node's own copy of a transaction was reported as a failed send -- the resubmission this change exists to prevent. Resolve an ordinal, in either numeric or string form, back to its name, and preserve an unrecognised code rather than dropping it. Co-Authored-By: Claude <noreply@anthropic.com>
|
Both findings are real and are fixed in 1. Fractional transfer amounts — confirmed regression. The claim holds exactly as described. if (!utils.isInteger(amount) || amount <= 0) throw new Error('Invalid amount provided');whereas async sendTrx(to, amount = 0, ...) {
amount = parseInt(amount);
this.validator.notValid([..., { name: 'amount', type: 'integer', gt: 0, value: amount }]);So Worth noting the blast radius is narrower than it first looks, and only in one direction: the contract-call path never had this problem. 2. Numeric response codes — accepted, with a caveat on reachability. Being straight about what I could and couldn't verify: I found no provider that actually returns the numeric form. A TRON HTTP node returns the name, which is why the original code compared against names and why the tests passed. So this was a latent gap against a shape TronWeb's own typings declare, not a live bug. I still took it — the fix is a lookup table, and the cost of being wrong is unbounded. Rather than special-case
The table mirrors Verification: 17 passing in One process note: a canned "automated round used" comment went out from my account at 16:58 as the review landed — that fired on a timer before I'd read the findings and doesn't reflect my assessment. This reply supersedes it. That said, the round budget is genuinely spent, so I won't respond to further automated activity here; if you want another pass, assign me to the PR. |
884a663 to
d21e935
Compare
| export interface TronTransactionResult { | ||
| txid: string; | ||
| result: boolean; | ||
| /** TRON `response_code` for a rejected broadcast (e.g. "TAPOS_ERROR"); absent on success. */ | ||
| code?: string; | ||
| /** The node's rejection reason, utf8-decoded where TRON hex-encoded it; absent on success. */ | ||
| message?: string; | ||
| } |
There was a problem hiding this comment.
If these fields are absent on success, should we instead return a discriminated type on failure vs. success?
There was a problem hiding this comment.
Agreed — done in 832e5bf.
export interface TronTransactionSuccess {
txid: string;
result: true;
}
export interface TronTransactionFailure {
txid: string;
result: false;
code?: string;
message?: string;
}
export type TronTransactionResult = TronTransactionSuccess | TronTransactionFailure;txid stays common to both branches — it is fixed at signing and reported whichever way the node decides, which is the whole point of the PR.
I checked the narrowing rather than assuming it, since the payoff here is entirely compile-time:
error TS2339: Property 'code' does not exist on type 'TronTransactionResult'. // unnarrowed
error TS2339: Property 'code' does not exist on type 'TronTransactionSuccess'. // if (r.result)
and if (!r.result) { r.code } resolves to string | undefined.
Both branches are exported so a consumer can name the one it handles rather than reaching for Extract<...>.
Two notes on the shape:
codeandmessagestay optional on the failure branch. A node need not send either, so making them required would be a lie one level down; a rejection is still only ever inferred fromresult: false, never from their absence.- On blast radius: nothing in-repo consumes this type, and
code/messagewere introduced by this PR, so no existing reader gets narrowed. The one theoretical break is a caller constructing aTronTransactionResultwith a non-literalresult: boolean— that no longer assigns. Worth knowing, but there is no such caller today.
I also added a test that reads code through the narrowing. It is really a compile-time assertion wearing a runtime test's clothes: flattening the union back to one optional-field interface would still satisfy the deep.equal assertions in the neighbouring tests, but would stop that one from type-checking.
One thing I deliberately left alone, since it is adjacent to your question rather than in it. A DUP_TRANSACTION_ERROR rejection is mapped to { txid, result: true }, so the success branch cannot express "the node already had this". I think that is right — the contract is "the node holds this txid", and a duplicate satisfies it — but it does mean a caller cannot distinguish a fresh accept from a duplicate. Adding duplicate?: true to the success branch later is a non-breaking change, so I would rather not guess at it now. Say the word if you want it.
The optional code/message fields were reachable on a successful broadcast, where they can never be set. Split the type on `result` so the compiler enforces what the doc comments only asserted: code and message exist on the failure branch and nowhere else. TronTransactionSuccess and TronTransactionFailure are exported so a consumer can name the branch it handles. Nothing in-repo consumed the old shape, and code/message were introduced by this PR, so no existing reader is narrowed by this. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks @pxrl — good call, and it's in as
export interface TronTransactionSuccess { txid: string; result: true }
export interface TronTransactionFailure { txid: string; result: false; code?: string; message?: string }
export type TronTransactionResult = TronTransactionSuccess | TronTransactionFailure;
Safe to tighten: nothing in-repo consumes the type, and Two things I flagged on the thread rather than deciding unilaterally:
Verification: 18 passing in Also worth surfacing since it's easy to miss in the commit list: the automated reviewer caught a real regression I'd introduced, fixed in |
d21e935 to
832e5bf
Compare
| /** | ||
| * TRON `response_code`, resolved to its name (e.g. "TAPOS_ERROR"). Optional because a node need | ||
| * not send one — a rejection is never inferred from its absence, only from `result: false`. | ||
| */ | ||
| code?: string; | ||
| /** The node's rejection reason, utf8-decoded where TRON hex-encoded it. */ | ||
| message?: string; |
There was a problem hiding this comment.
Are these still optional even when returning a discriminated failure type?
There was a problem hiding this comment.
Good push — you were right to ask twice. My previous answer ("a node need not send one") was accurate but it was defending a weak type instead of asking why the weak case existed. Digging into it turned up a bug. code is now required, in d312788.
The reason a code-less failure was representable at all is that sendRawTransaction returns the HTTP body verbatim:
const result = await this.tronWeb.fullNode.request<...>('wallet/broadcasttransaction', signedTransaction, 'post');
return { ...result, transaction: signedTransaction };No normalisation, and notably no Error check — TronWeb does guard if (transaction.Error) throw ... on its transaction-building paths (helper.ts:17, TransactionBuilder.ts:1075, trx.ts:1549) but not here. So TRON's bare { Error: "..." } shape, an empty body, or anything a proxy mangles all reach us as result falsy with no response_code.
The type question turned out to be the smaller half of it. None of those responses establish that the transaction was rejected. The node may have accepted it and failed to say so. Reporting result: false there asserts a verdict we do not have, and on TRON a false "it failed" invites the resubmit that — with no nonce to replace through — executes a second time. That is the exact hazard this PR exists to close, and the original code walked into it one branch over from where I was looking.
So rather than make code required by inventing a filler value (OTHER_ERROR would have been actively harmful — indistinguishable from the node actually saying OTHER_ERROR), I routed the case to the error that already means "may or may not have been sent" and carries the txid:
if (!isDefined(code)) {
throw new TronBroadcastError(
`TRON broadcast for ${txID} returned no response code, so its outcome is unknown: ${describeCodelessRejection(broadcast)}`,
txID
);
}describeCodelessRejection surfaces the Error string so the diagnostic is not swallowed. Three outcomes now, and only the middle one is safe to retry blindly: accepted (including a duplicate the node already holds), definitely rejected with a reason, unknown.
With that, a failure is only ever constructed when the node named a reason, so code: string is a guarantee rather than a hope. Verified, since the payoff is compile-time:
if (!r.result) { const code: string = r.code }— compiles cleanconst message: string = r.message— stillerror TS2322: Type 'string | undefined' is not assignable
message I did keep optional, and I want to be explicit that this is a considered split rather than the same hand-wave as before: the protocol's Return.message is genuinely empty for some rejections, and it is human prose, not something to branch on — code is the machine-readable half, and that is the one now guaranteed.
Behavioural change worth flagging on review: a { result: false } response with no code used to return; it now throws. Only reachable via malformed/proxy responses, and callers already have to handle TronBroadcastError, but it is a contract change rather than pure tightening. Test updated accordingly, plus new coverage for the code-less and bare-Error shapes. 20 passing, lint and all three tsc targets clean.
TronTransactionFailure.code was optional, which meant a caller matching on it
had to handle a rejection that named no reason. That case existed only because
a code-less response was being reported as a definite failure in the first
place, and it should not have been.
sendRawTransaction returns the HTTP body verbatim -- it does not apply the
`{ Error: ... }` check TronWeb uses on its transaction-building paths -- so a
bare Error body, an empty response, or anything a proxy mangled all arrive as
`result` falsy with no response_code. None of those establish that the node
rejected the transaction; it may have taken it and failed to say so. Reporting
a definite failure there invites the resubmit that, with no nonce to replace
through, executes a second time: the hazard this PR exists to close.
Route that case to TronBroadcastError, which already means "may or may not
have been sent" and carries the txid, surfacing the node's Error string as the
diagnostic. A failure is now only constructed when the node named a reason, so
code is required.
message stays optional: the protocol's Return.message is empty for some
rejections, and it is prose rather than something to branch on.
Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks @pxrl — asking that a second time was the right call. It shook out a bug, not just a type tweak. My last answer ("a node need not send a code") was true but was defending a weak type rather than asking why the weak case existed. Chasing that down: The typing was the smaller half. None of those responses prove a rejection — the node may have taken the transaction and failed to say so. Reporting Fix: route the code-less case to That makes
Please flag if you'd rather not take this: a Verification: 20 passing in |
arch/tvm/submitTransaction()signs before it broadcasts, so the txid is known locally beforesendRawTransaction()is called. Two exits threw it away. Tron has no nonce, so a resubmission is a second, independent transaction — the txid is the only handle a caller has, and losing it is a double-execution risk.TronBroadcastErrorcarryingtxid, so the caller can verify on-chain instead of resubmitting.DUP_TRANSACTION_ERRORwas reported as a failed send. That's Tron's "already known" — the node holds this transaction. Now a successful send with the txid, as the EVM path does.codeandmessage. A bare boolean can't distinguish a definite rejection (TAPOS_ERROR) from an ambiguous one (SERVER_BUSY).messageis utf8-decoded, since Tron hex-encodes it.trx.sendTransaction(), which fuses build/sign/broadcast. Now three steps, sharing the broadcast handling.Notes:
TronBroadcastErrorships withisTronBroadcastError(). Prefer the guard overinstanceof: the CJS and ESM builds don't share class identity.code/messageare omitted when absent, so existing result assertions hold. The behavioural changes are the new throw type and the duplicate-as-success verdict.response_codevalues prove a transaction is not on-chain. Exposingcodeleaves that policy to the caller; say the word if it belongs here.Testing:
test/Tvm.TransactionUtils.test.ts, 13 passing — txid surviving a throw and duplicate-as-success on both the call and transfer paths, decodedcode/message, and the type guard.🤖 Generated with Claude Code