feat(tx): parse Tron raw_data to verify it, and split tx-codec out of tx - #17
Conversation
`verify_transfer` checks that the recipient's hex appears *somewhere* in the node-built `raw_data`. That is weaker than it reads: - The address appearing somewhere does not make it the `to_address` of the contract being signed. A node can pay someone else and leave the requested address in an unrelated field. - The amount is not checked at all, so a node that builds the right recipient with the wrong value passes. Add `tx::proto`, a ~120-line structural protobuf reader, and `verify_contract` on top of it: contract type, the recipient at its declared field number, the amount, and for TRC-20 the full calldata, `call_value` and `fee_limit`. Every accessor is singular and refuses a repeated field, because "last one wins" is how a second recipient gets smuggled past a checker that reads the first. Two tests pin the gap directly — a decoy field and a substituted amount both pass `verify_transfer` and fail `verify_contract`. `verify_transfer` is kept for its existing callers and documented as the weaker check. `tx::proto` needs no new dependency: it walks `&[u8]`. Ported from the equivalent verifier in tinyhumansai/openhuman's `web3/wallet/chains/tron.rs`, which is being deleted in favour of this. Co-authored-by: Medulla <medulla@tinyhumans.ai>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
tinysweeper found nothing blocking. Approving.
$0.0742 · 79,892 in / 39,157 out · 64,233 cached (80%) · z-ai/glm-5.2
critique: $0.0218 · 31,622 in / 22,504 out · 26,130 cached (83%) · z-ai/glm-5.2
security: $0.0192 · 19,211 in / 5,980 out · 15,342 cached (80%) · z-ai/glm-5.2
tests: $0.0217 · 14,132 in / 7,550 out · 11,041 cached (78%) · z-ai/glm-5.2
description: $0.0114 · 14,927 in / 3,123 out · 11,720 cached (79%) · z-ai/glm-5.2
| "the transaction has a non-zero TRC-20 call_value", | ||
| )); | ||
| } | ||
| if let (Some(expected), Some(actual)) = ( |
There was a problem hiding this comment.
Enforce a pinned fee_limit even when the response omits the field
The fee_limit comparison is gated on both sides being Some, so a request that pinned a fee limit is not protected against a response that omits the field entirely. if let (Some(expected), Some(actual)) only fires when the request set a limit and the response carries one; when fee_limit_sun is Some(expected) but optional_varint(...) returns None, the bindings don't match and the check is silently skipped, so verification passes despite the node having dropped the limit the caller asked for. The docstring frames this field as "the fee_limit the request specified," and the only test (a_raised_fee_limit_is_rejected_when_the_request_pinned_one) exercises the both-present-and-differ path, leaving the request-pinned/response-absent path uncovered. A pinned fee limit that the node refuses to honour is exactly the kind of alteration this function exists to catch.
[RULE] Fee limit pinned by the request is not enforced when the response omits it ·
| "the transaction has a non-zero TRC-20 call_value", | ||
| )); | ||
| } | ||
| if let (Some(expected), Some(actual)) = ( |
There was a problem hiding this comment.
Cover the fee_limit branch where the request pins one but the transaction omits
The fee_limit consistency check only fires when both the requested fee_limit_sun and the on-wire field 18 are Some. When the caller pins a fee limit but the node omits field 18 entirely, the if let does not match and verification silently returns Ok(()). No test exercises this path — every TRC-20 test either sets both to the same value, sets both to different values, or sets both to None. A node that strips fee_limit to dodge the check is the exact adversary this function exists to catch, and a regression that changed this fall-through to an error (or vice versa) would break no existing test.
[RULE] new branches with no coverage ·
What this change touches6 files, +880 -10 across 4 components. The code graph knows nothing about these files yet — normal for newly added files, and a cold index otherwise. flowchart LR
n0["src/tx<br/>3 files +693 -9<br/>6 findings"]:::blocking
n1["src/tx/proto<br/>1 file +172 -0"]:::changed
n2["root<br/>1 file +14 -0<br/>2 findings"]:::blocking
n3["src<br/>1 file +1 -1"]:::changed
classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Green: changed. Grey: untouched, reached through an import or a call. Orange: has findings. Red: has a finding that blocks the merge.
Changed files
|
…bitcoin Verifying a node-built Tron transaction and *signing* one are different jobs with different costs. The first is `&[u8]` walking plus sha2; the second wants `bitcoin`'s secp256k1 and therefore a native C build. They shared one gate, so a host that had moved signing into a loadable module — the case the `tx` gate comment already describes — could not reach the verification half without paying for the signing half it had deliberately shed. Add `tx-codec`, implied by `tx`, covering `tx::proto` and the verification half of `tx::tron` (`recompute_txid`, `verify_transfer`, `verify_contract`, `digest`, `attach_signature`, `signature_hex`). `bitcoin` now gates exactly one function, `tx::tron::sign`, plus `tx::btc`, `tx::evm`, `tx::solana` and `tx::rlp`. `tx` implies `tx-codec`, so no existing consumer sees a change. Measured: `--no-default-features --features "tron,tx-codec"` resolves 22 packages with `bitcoin` and `secp256k1` both absent. Verified across the matrix: no features, `tron`, `tron,tx-codec`, and the full host set all check clean; `--all-features` keeps 297 + 7 + 9 + 18 tests green. Co-authored-by: Medulla <medulla@tinyhumans.ai>
tx-codec out of tx
Second commit:
|
| Gate | Needs | |
|---|---|---|
tx::proto, tx::tron::{recompute_txid, verify_transfer, verify_contract, digest, attach_signature, signature_hex} |
tx-codec |
sha2 |
tx::tron::sign, tx::btc, tx::evm, tx::solana, tx::rlp |
tx |
bitcoin |
bitcoin now gates exactly one function in tx::tron. Note digest, attach_signature and signature_hex are on the codec side: they are pure, and they are precisely what a host doing its own k256 signing over a returned digest needs.
tx = ["tx-codec", ...], so nothing that took tx before sees any change.
Measured, not asserted:
$ cargo tree -p tinywallet --no-default-features --features "tron,tx-codec" \
-e normal --prefix none | sort -u | wc -l
22
$ ... | grep -cE "^(bitcoin|secp256k1) "
0
And on the consuming side, with openhuman enabling tx-codec:
$ cargo tree --features "$(bash scripts/ci/product-features.sh)" \
-e normal --prefix none | sort -u | grep -icE "^bitcoin |^secp256k1 "
0
Gate matrix — all check clean: no features · tron · tron,tx-codec · the full host set. --all-features keeps 297 + 7 + 9 + 18 tests green, clippy and fmt clean.
There was a problem hiding this comment.
Requesting changes: 3 lane(s) blocking, worst finding is high.
Fix or reply to the findings below and push. The next review clears this automatically once they are gone — you should not need to dismiss anything by hand.
$0.0788 · 69,777 in / 22,750 out · 45,095 cached (65%) · z-ai/glm-5.2
critique: $0.0326 · 23,459 in / 11,213 out · 18,996 cached (81%) · z-ai/glm-5.2
security: $0.0204 · 15,450 in / 4,111 out · 1,792 cached (12%) · z-ai/glm-5.2
tests: $0.0142 · 15,038 in / 4,229 out · 11,719 cached (78%) · z-ai/glm-5.2
description: $0.0118 · 15,830 in / 3,197 out · 12,588 cached (80%) · z-ai/glm-5.2
| "the transaction has a non-zero TRC-20 call_value", | ||
| )); | ||
| } | ||
| if let (Some(expected), Some(actual)) = ( |
There was a problem hiding this comment.
Enforce a pinned fee_limit even when the response omits the field
The fee_limit comparison only runs when both sides are Some. When the request pins fee_limit_sun: Some(expected) but the transaction omits field 18 entirely, proto::optional_varint returns None, the if let pattern does not match, and verification succeeds without checking anything. A node can strip the fee_limit from the response and the caller's stated limit is never enforced. The fix is to treat Some(expected) with None actual as a mismatch, not just Some vs Some with unequal values.
[RULE] guard ·
| } | ||
|
|
||
| #[test] | ||
| fn a_raised_fee_limit_is_rejected_when_the_request_pinned_one() { |
There was a problem hiding this comment.
Cover the fee_limit branch where the request pins one but the transaction omits
a_raised_fee_limit_is_rejected_when_the_request_pinned_one covers Some vs Some with different values, and a_well_formed_trc20_transfer_verifies_structurally covers Some vs Some with equal values. No test covers the case the first finding describes: the request pins fee_limit_sun: Some(…) and the transaction omits field 18, which currently passes silently. A failing test for that case should precede the fix.
[RULE] test ·
| "the transaction has a non-zero TRC-20 call_value", | ||
| )); | ||
| } | ||
| if let (Some(expected), Some(actual)) = ( |
There was a problem hiding this comment.
Enforce a pinned fee_limit even when the response omits the field
The guard only fires when both the request pinned a fee_limit and the response includes field 18. If the node strips fee_limit from raw_data while the caller set fee_limit_sun = Some(_), the let fails to match and verification passes silently — exactly the class of discrepancy this function exists to catch. The caller's intent (a capped fee) is unenforceable, and a node can bypass the pin by omission rather than substitution.
[RULE] , ·
| "the transaction has a non-zero TRC-20 call_value", | ||
| )); | ||
| } | ||
| if let (Some(expected), Some(actual)) = ( |
There was a problem hiding this comment.
Enforce a pinned fee_limit even when the response omits the field
Still standing
The fee_limit enforcement only fires when both the request and the transaction carry a value:
if let (Some(expected), Some(actual)) = (
*fee_limit_sun,
proto::optional_varint(&raw_fields, 18, "Transaction.raw.fee_limit")?,
)
&& actual != expected
{
return Err(untrusted("the transaction has a different fee_limit"));
}When fee_limit_sun is Some but the transaction omits field 18, actual is None, the if let pattern does not match, and no error is returned. A node that drops fee_limit entirely bypasses the pinned-value check. The prior review raised this and it has not been addressed.
[RULE] Maintain at least 80% coverage of meaningful library behavior and add/update tests with every behavior change. ·
| ) | ||
| } | ||
|
|
||
| fn trc20_raw(contract_address: &str, parameter_hex: &str, fee_limit: Option<u64>) -> String { |
There was a problem hiding this comment.
Cover the fee_limit branch where the request pins one but the transaction omits
Still standing
No test covers the case where fee_limit_sun is Some and the transaction omits field 18. The existing fee_limit test, a_raised_fee_limit_is_rejected_when_the_request_pinned_one, uses a transaction that includes a different fee_limit. There is no test that builds a TRC-20 transaction without field 18 and asserts the pinned value is still enforced. Because the if let guard requires both to be Some, such a transaction currently passes silently — and no test would catch that regression.
[RULE] Maintain at least 80% coverage of meaningful library behavior and add/update tests with every behavior change. ·
| return Err(untrusted("the transaction is not a smart-contract trigger")); | ||
| } | ||
| let payload = proto::parse_fields(contract.payload)?; | ||
| if proto::one_bytes(&payload, 2, "TriggerSmartContract.contract_address")? |
There was a problem hiding this comment.
Cover the TRC-20 contract_address mismatch branch
New finding
The TRC-20 branch checks that contract_address (field 2) matches the expected recipient:
if proto::one_bytes(&payload, 2, "TriggerSmartContract.contract_address")?
!= expected_recipient
{
return Err(untrusted("the transaction targets a different contract"));
}No test sends a TRC-20 transaction whose contract_address differs from the requested to. Every TRC-20 test — the well-formed case, the calldata mismatch, the call_value smuggling, and the fee_limit mismatch — builds the transaction with the correct contract address. If this check were removed, all existing tests would still pass. The native-recipient mismatch is tested by a_recipient_present_but_not_as_the_to_address_is_rejected, but the TRC-20 contract-address check is a distinct branch on a distinct field of a distinct contract type.
[RULE] Maintain at least 80% coverage of meaningful library behavior and add/update tests with every behavior change. ·
…ocal codec
`tron_transaction_spec` hand-rolled a protobuf reader — varint decode, field
walking, singular-field accessors, contract unwrapping — to check what a Tron
node returned before signing it. None of that is OpenHuman-specific: it is how
a Tron transaction is encoded, which is the same for every host.
It moves to `tinywallet::tx::{proto, tron::verify_contract}`
(tinyhumansai/tinywallet#17), which also strengthens the crate's own check: its
`verify_transfer` only scanned the hex for the recipient's bytes, so a decoy
field or a substituted amount got past it. Both are now pinned as regression
tests there.
What stays here is the part that is ours: the fee limit this client pins, and
the `TransactionSpec` handed to the wallet module. `tron.rs` loses 230 lines
and gains 50.
The crate is taken with the new `tx-codec` feature rather than `tx`, so the
verification code arrives without `bitcoin` or its native secp256k1 build —
confirmed absent from the product graph.
Two test assertions move from "TRC20" to "TRC-20" to match the crate's error
wording. Behaviour is unchanged; the same inputs are still rejected.
Co-authored-by: Medulla <medulla@tinyhumans.ai>
… out of `tx` (#18) #15 taught `verify_transfer` to check the amount as well as the recipient, but both checks are still byte-run searches over `raw_data`. A value appearing somewhere in the bytes does not make it the field that will execute: a node can pay someone else and leave the requested address in an unrelated field, and the search is satisfied by the decoy. `a_recipient_present_but_not_as_the_to_address_is_rejected` pins exactly that — it passes `verify_transfer` and fails the new check. Add `tx::proto`, a ~120-line structural protobuf reader, and `verify_contract` on top of it: contract type, the recipient at its declared field number, the amount, and for TRC-20 the calldata including the selector, `call_value` and `fee_limit`. Every accessor is singular and refuses a repeated field, because "last one wins" is how a second recipient gets past a checker reading the first. `verify_transfer` keeps its callers and is documented as the weaker check. `tx::tron`'s private `encode_varint` is now `proto::encode_varint` — one copy. Also splits `tx-codec` out of `tx`. Verifying a transaction and signing one are different jobs with different costs: the first is `&[u8]` walking plus sha2, the second wants `bitcoin`'s secp256k1 and a native C build. They shared one gate, so a host that had moved signing into a loadable module — the case the `tx` comment already describes — could not reach verification without paying for the signing half it had deliberately shed. `bitcoin` now gates exactly one function, `tx::tron::sign`, plus `tx::{btc,evm,solana,rlp}`. `tx` implies `tx-codec`, so no existing consumer sees a change. Measured: `--no-default-features --features "tron,tx-codec"` resolves 26 packages with `bitcoin` and `secp256k1` both absent. Replaces #17, whose content never reached main: it merged into `fix/slip10-raw-index-bound` 74 seconds after that branch had already been squash-merged as #16, so the squash did not include it. Co-authored-by: Medulla <medulla@tinyhumans.ai>
The gap
Tron inverts the usual split — the node builds the transaction and the client signs what it is handed.
verify_transferis the check that stands between a compromised endpoint and a signature, and today it does this:A substring scan over the hex. Two things that lets through:
to_address. A node can build a transfer paying someone else and leave the requested address in an unrelated field. The scan is satisfied.Both are now regression tests, and both pass
verify_transferbefore failing the new check:The change
tx::proto— a ~120-line structural protobuf reader. Not a schema compiler and notprost: it recovers field numbers and raw values over a message whose shape is already known, borrows throughout (Value::Bytespoints into the caller's buffer), and leaves the meaning of field 11 totx::tron. No new dependency — it walks&[u8].Every accessor is singular and refuses a repeated field. The spec does permit repetition, but "last one wins" is exactly how a second recipient gets smuggled past a checker that reads the first, so a repeated singular field is treated as the attack it would be rather than a value to disambiguate.
tx::tron::verify_contract— checks contract type, the recipient at its declared field number, the amount, and for TRC-20 the full calldata including selector,call_value(a token transfer moves no TRX, so non-zero means native funds leaving alongside it) andfee_limit.verify_transferis kept for its existing callers inclient::tronand the module service, and is now documented as the weaker check with a pointer to this one.Provenance
Ported from the equivalent verifier in
tinyhumansai/openhuman'sweb3/wallet/chains/tron.rs. That host had independently written the stronger check; this brings it where every host can reach it, and openhuman deletes its copy in the companion PR (tinyhumansai/openhuman#5533 and its follow-up).Verification
cargo test --all-featurescargo clippy --all-features --all-targets-D warnings)cargo fmttx::prototeststx::trontestsCoverage on the parser is adversarial rather than happy-path: repeated singular fields, wrong wire type, field number zero, unsupported wire types 3/4/6/7, truncation at each stage, a varint overrunning 64 bits, and fixed-width fields skipped without desynchronising the stream.