Skip to content

feat(tx): parse Tron raw_data to verify it, and split tx-codec out of tx - #17

Merged
senamakel merged 2 commits into
fix/slip10-raw-index-boundfrom
feat/tx-codec-gate
Aug 13, 2026
Merged

feat(tx): parse Tron raw_data to verify it, and split tx-codec out of tx#17
senamakel merged 2 commits into
fix/slip10-raw-index-boundfrom
feat/tx-codec-gate

Conversation

@senamakel

Copy link
Copy Markdown
Member

Stacked on #16. Review that first; base retargets to main when it merges.

The gap

Tron inverts the usual split — the node builds the transaction and the client signs what it is handed. verify_transfer is the check that stands between a compromised endpoint and a signature, and today it does this:

if !raw_data_hex.to_ascii_lowercase().contains(&to_hex.to_ascii_lowercase())

A substring scan over the hex. Two things that lets through:

  1. The address appearing somewhere is not the same as it being to_address. A node can build a transfer paying someone else and leave the requested address in an unrelated field. The scan is satisfied.
  2. The amount is never checked. A node that builds the right recipient with the wrong value passes unconditionally.

Both are now regression tests, and both pass verify_transfer before failing the new check:

assert!(verify_transfer(&raw, TO, &id).is_ok(), "the weak check is fooled by the decoy");
// ...
match verify_contract(&raw, TO, &id, &transfer).unwrap_err() {
    Error::UntrustedResponse { reason } => assert!(reason.contains("does not pay the requested recipient")),

The change

tx::proto — a ~120-line structural protobuf reader. Not a schema compiler and not prost: it recovers field numbers and raw values over a message whose shape is already known, borrows throughout (Value::Bytes points into the caller's buffer), and leaves the meaning of field 11 to tx::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) and fee_limit.

verify_transfer is kept for its existing callers in client::tron and 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's web3/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

Check Result
cargo test --all-features 297 + 7 + 9 + 18 doctests, 0 failed
cargo clippy --all-features --all-targets clean (workspace lints, -D warnings)
cargo fmt clean
new tx::proto tests 12
new tx::tron tests 10

Coverage 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.

`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>
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e3bb025e-9cd7-43c5-baa4-4129b4ef23cf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/tx/tron.rs
"the transaction has a non-zero TRC-20 call_value",
));
}
if let (Some(expected), Some(actual)) = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique likely

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 ·

Comment thread src/tx/tron.rs
"the transaction has a non-zero TRC-20 call_value",
));
}
if let (Some(expected), Some(actual)) = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium tests likely

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 ·

@tinysweeper

tinysweeper Bot commented Aug 13, 2026

Copy link
Copy Markdown

What this change touches

6 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
Loading

Green: changed. Grey: untouched, reached through an import or a call. Orange: has findings. Red: has a finding that blocks the merge.

Component Files Lines Findings
src/tx changed 3 +693 -9 6 (high)
src/tx/proto changed 1 +172 -0
(root) changed 1 +14 -0 2 (high)
src changed 1 +1 -1
Changed files

src/tx

  • src/tx/mod.rs
  • src/tx/proto.rs
  • src/tx/tron.rs

src/tx/proto

  • src/tx/proto/test.rs

(root)

  • Cargo.toml

src

  • src/lib.rs

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot added the priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. label Aug 13, 2026
…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>
@senamakel senamakel changed the title feat(tx): verify Tron transactions by parsing raw_data, not scanning it feat(tx): parse Tron raw_data to verify it, and split tx-codec out of tx Aug 13, 2026
@senamakel

Copy link
Copy Markdown
Member Author

Second commit: tx-codec

Wiring the new verify_contract into tinyhumansai/openhuman surfaced the reason it could not be used there:

error[E0433]: cannot find `tx` in `tinywallet`
    |
133 |     let recomputed_txid = tinywallet::tx::tron::recompute_txid(...)
    |                                       ^^ could not find `tx` in `tinywallet`
    |
note: found an item that was configured out

OpenHuman takes this crate without tx, deliberately — that gate pulls bitcoin and its native secp256k1 build, and transaction building happens in the loaded wallet module instead. But verification is not building. It is &[u8] walking plus sha2, and a host that has moved signing elsewhere still has to check what a Tron node handed back before it signs.

So tx-codec splits along that line:

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.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/tx/tron.rs
"the transaction has a non-zero TRC-20 call_value",
));
}
if let (Some(expected), Some(actual)) = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high critique confident

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 ·

Comment thread src/tx/tron.rs
}

#[test]
fn a_raised_fee_limit_is_rejected_when_the_request_pinned_one() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique likely

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 ·

Comment thread src/tx/tron.rs
"the transaction has a non-zero TRC-20 call_value",
));
}
if let (Some(expected), Some(actual)) = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security likely

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] , ·

Comment thread src/tx/tron.rs
"the transaction has a non-zero TRC-20 call_value",
));
}
if let (Some(expected), Some(actual)) = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high tests confident

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. ·

Comment thread src/tx/tron.rs
)
}

fn trc20_raw(contract_address: &str, parameter_hex: &str, fee_limit: Option<u64>) -> String {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high tests confident

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. ·

Comment thread src/tx/tron.rs
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")?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium tests likely

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. ·

@tinysweeper tinysweeper Bot added priority: p1 Next. Wrong behaviour a user will hit, or a security weakness behind a condition. and removed priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. labels Aug 13, 2026
senamakel added a commit to senamakel/openhuman that referenced this pull request Aug 13, 2026
…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>
@senamakel
senamakel merged commit 4d19c73 into fix/slip10-raw-index-bound Aug 13, 2026
9 of 16 checks passed
senamakel added a commit that referenced this pull request Aug 13, 2026
… 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>
@senamakel
senamakel deleted the feat/tx-codec-gate branch August 14, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p1 Next. Wrong behaviour a user will hit, or a security weakness behind a condition.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant