Skip to content

feat(platform-wallet): pure transaction decoder + thin FFI/Swift wrappers#3981

Open
llbartekll wants to merge 4 commits into
v4.1-devfrom
feat/core-tx-decode-ffi
Open

feat(platform-wallet): pure transaction decoder + thin FFI/Swift wrappers#3981
llbartekll wants to merge 4 commits into
v4.1-devfrom
feat/core-tx-decode-ffi

Conversation

@llbartekll

@llbartekll llbartekll commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

Host apps consuming the platform-wallet FFI (dashwallet-ios mid-migration off DashSync) need to match arbitrary persisted transactions against external addresses and exact amounts — e.g. the CrowdNode on-chain API protocol: "did a transaction pay apiOffset + code duffs to the CrowdNode address?" and "did CrowdNode reply with amount X to my account address?".

The wallet layer persists every transaction's raw bytes, but only materializes TXO rows for the wallet's own outputs, and AccountTransactionEntryFFI carries no per-output data. There is currently no way to evaluate a per-output (address, amount) predicate from either the FFI or the Swift SDK — payments to external addresses (CrowdNode's) have no addressable representation at all. This blocks porting CrowdNode's on-chain response tracking (and several tx-history readers) off DashSync.

What was done?

Adds a stateless transaction-decode FFI entry point with a Swift wrapper. (Reworked per review: the initial version had an intermediate pure-crate representation in rs-platform-wallet; that layer is gone — the FFI decodes straight into dashcore::Transaction and maps it directly into the C structs.)

rs-platform-wallet-ffisrc/tx_decode.rs:

  • platform_wallet_decode_transaction(tx_bytes, len, network, out) -> PlatformWalletFFIResult — consensus-decodes raw tx bytes into dashcore::Transaction (rejecting trailing bytes) and marshals it into #[repr(C)] structs:
    • txid (consensus byte order),
    • per-output: address via Address::from_script (null for non-standard scripts like OP_RETURN), value_duffs, raw script_pubkey bytes,
    • per-input: previous outpoint + sender address, recovered only for strictly P2PKH-shaped scriptSigs (exactly two pushes: DER-shaped signature, then 33/65-byte pubkey — anything else, incl. P2SH redeem scripts, yields null). Documented as unauthenticated: display/matching hint only, never authorization; the attacker-resistant matching primitive is the per-output (address, value_duffs) pair.
  • platform_wallet_decoded_transaction_free — paired free (null-safe); *out_decoded is nulled on entry so failure paths can't leak stale pointers to C callers.
  • 13 unit tests, all through the public entry point. Fixtures use dashcore::test_utils (Address::dummy, Transaction::dummy, Transaction::dummy_coinbase) where they fit; the P2PKH-scriptSig fixture is hand-built (test_utils has no builder for that shape).

swift-sdkSources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift:

  • TransactionDecoder.decode(_ txData: Data, network: Network) throws -> DecodedTransaction — typed structs, PlatformWalletError mapping, defer-freed FFI memory, txidDisplayHex / prevTxidDisplayHex display helpers. Unit tests in TransactionDecoderTests (error-case assertions tightened separately in test(swift-sdk): assert transaction decoder error cases #3993).

How Has This Been Tested?

  • cargo test -p platform-wallet -p platform-wallet-ffi — full suites green (202 + 112 tests).
  • cargo fmt --check + cargo clippy --all-targets on both crates — clean.
  • The FFI + Swift wrapper were smoke-tested end-to-end on testnet by the consuming app (dashwallet-ios CrowdNode response tracking: decoding persisted transactions, matching response amounts/addresses) against the pre-rework build — the FFI surface is byte-identical across the rework.

Breaking Changes

None — purely additive (new FFI symbols, new Swift wrapper).

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation (rustdoc on the module and every public item; platform-wallet FFI has no separate docs-site surface)

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone (v4.1.0)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added transaction decoding support in the platform wallet, exposing transaction inputs, outputs, values, and addresses to the SDK.
    • Added Swift APIs to decode transactions into a structured, easy-to-use model.
  • Bug Fixes
    • Improved handling of invalid, empty, or malformed transaction data with clearer errors.
    • Added safer cleanup for decoded transaction data to reduce memory issues.
  • Tests
    • Expanded coverage for transaction decoding, address rendering, and error handling across networks.

@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@llbartekll, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f3624c7-a239-467c-b6cf-eb7108f3b3e4

📥 Commits

Reviewing files that changed from the base of the PR and between 4eaa825 and ffa8c94.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet-ffi/src/tx_decode.rs
📝 Walkthrough

Walkthrough

Adds a Rust tx_decode module to the platform wallet FFI crate exposing platform_wallet_decode_transaction and platform_wallet_decoded_transaction_free C-ABI functions, wires them into the crate exports, adds a test-only dashcore dependency, and adds a corresponding Swift TransactionDecoder/DecodedTransaction API with tests.

Changes

Transaction Decode Feature

Layer / File(s) Summary
C-ABI FFI decode/free surface
packages/rs-platform-wallet-ffi/src/tx_decode.rs, packages/rs-platform-wallet-ffi/src/lib.rs, packages/rs-platform-wallet-ffi/Cargo.toml
Defines DecodedTxInputFFI/DecodedTxOutputFFI/DecodedTransactionFFI structs and platform_wallet_decode_transaction/platform_wallet_decoded_transaction_free functions to deserialize raw transaction bytes, derive addresses, marshal owned C buffers, and safely free them; wires the module into the crate and adds a test-only dashcore dev-dependency with unit tests covering decode correctness, address derivation, error handling, and freeing.
Swift TransactionDecoder API and tests
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift
Adds DecodedTransaction/Input/Output models with explorer-hex helpers and TransactionDecoder.decode that calls the FFI, validates results, converts C data to Swift types, and frees the decoded structure; adds an XCTest suite validating outputs, inputs, txid rendering, network-dependent addresses, and error cases.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SwiftCaller
  participant TransactionDecoder
  participant platform_wallet_decode_transaction
  participant platform_wallet_decoded_transaction_free

  SwiftCaller->>TransactionDecoder: decode(txData, network)
  TransactionDecoder->>platform_wallet_decode_transaction: tx bytes, network
  platform_wallet_decode_transaction->>platform_wallet_decode_transaction: deserialize transaction, derive addresses
  platform_wallet_decode_transaction-->>TransactionDecoder: DecodedTransactionFFI pointer
  TransactionDecoder->>TransactionDecoder: convert pointers to Data/String/arrays
  TransactionDecoder->>platform_wallet_decoded_transaction_free: free decoded pointer (defer)
  TransactionDecoder-->>SwiftCaller: DecodedTransaction
Loading

Suggested reviewers: shumkov, QuantumExplorer, ZocoLini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a pure transaction decoder with thin FFI and Swift wrappers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/core-tx-decode-ffi

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.

@llbartekll llbartekll force-pushed the feat/core-tx-decode-ffi branch from 574363a to 5620fb0 Compare July 3, 2026 06:38
@llbartekll llbartekll changed the title feat(platform-wallet-ffi): pure transaction-decode FFI + Swift wrapper feat(platform-wallet): pure transaction decoder + thin FFI/Swift wrappers Jul 3, 2026
…wrappers

Host apps that consume the platform-wallet FFI need to match arbitrary
persisted transactions against external addresses and exact amounts (e.g. the
CrowdNode on-chain API: "did a tx pay amount X to address Y?"). The wallet
layer persists every transaction's raw bytes but only materializes TXO rows
for the wallet's own outputs, so matching an external payment's per-output
(address, amount) was unevaluable from either the FFI or the Swift SDK.

Layered so the logic lives in the pure crate and the FFI stays a marshalling
shell (mirroring how mnemonic_words.rs forwards to key_wallet::Mnemonic):

- rs-platform-wallet/src/transaction_decode.rs (pure, no FFI, no unsafe):
  decode_transaction(bytes, network) -> DecodedTransaction — consensus-decode
  (rejecting trailing bytes), per-output (Address::from_script, value, script),
  per-input (OutPoint + best-effort P2PKH sender address from the scriptSig).
  6 pure-Rust unit tests.

- rs-platform-wallet-ffi/src/tx_decode.rs: platform_wallet_decode_transaction /
  ..._free — thin C-ABI marshalling over the pure fn + #[repr(C)] structs.
  3 boundary tests (C round-trip, null/empty params, null-free safety).

- swift-sdk TransactionDecoder.swift: typed DecodedTransaction wrapper with
  PlatformWalletError mapping, defer-freed FFI memory, txidDisplayHex helper.
  7 unit tests.

Purely additive — no breaking changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@llbartekll llbartekll force-pushed the feat/core-tx-decode-ffi branch from 5620fb0 to 7cb62fe Compare July 3, 2026 07:17
@llbartekll llbartekll marked this pull request as ready for review July 3, 2026 08:50
@thepastaclaw

thepastaclaw commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit ffa8c94)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift (2)

58-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove tautological assertion.

Line 61's XCTAssertEqual(decoded.txid, Data(decoded.txid.reversed().reversed())) is always true by construction (double-reversal restores the original) — it doesn't verify anything about decoded.txid's correctness. The line above it (comparing txidDisplayHex) already does the real work.

🧹 Proposed fix
     func testTxidDisplayHexMatchesExplorerOrder() throws {
         let decoded = try TransactionDecoder.decode(fixtureData, network: .testnet)
         XCTAssertEqual(decoded.txidDisplayHex, Self.fixtureTxidDisplay)
-        XCTAssertEqual(decoded.txid, Data(decoded.txid.reversed().reversed()))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift`
around lines 58 - 62, The test in
TransactionDecoderTests.testTxidDisplayHexMatchesExplorerOrder contains a
tautological assertion that always passes, so remove the redundant XCTAsertEqual
on decoded.txid and keep the meaningful check against Self.fixtureTxidDisplay;
if you want additional coverage, replace it with an assertion that compares
decoded.txid to an independently derived expected value from the fixture rather
than reversing the same data twice.

70-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the specific thrown error, not just "any error".

testMalformedBytesThrowDeserialization and testEmptyInputThrowsInvalidParameter only check that some error is thrown, but their names (and TransactionDecoder.decode's documented contract of .deserialization for malformed bytes / .invalidParameter for empty input) imply verifying the specific case. As written, a regression that throws the wrong error type would pass silently.

🧪 Proposed fix
     func testMalformedBytesThrowDeserialization() {
         var bytes = fixtureData
         bytes.append(contentsOf: [0xDE, 0xAD]) // trailing garbage
-        XCTAssertThrowsError(try TransactionDecoder.decode(bytes, network: .testnet))
-        XCTAssertThrowsError(try TransactionDecoder.decode(Data([0xFF, 0xFF, 0xFF]), network: .testnet))
+        XCTAssertThrowsError(try TransactionDecoder.decode(bytes, network: .testnet)) { error in
+            guard case PlatformWalletError.deserialization = error else {
+                return XCTFail("Expected .deserialization, got \(error)")
+            }
+        }
+        XCTAssertThrowsError(try TransactionDecoder.decode(Data([0xFF, 0xFF, 0xFF]), network: .testnet)) { error in
+            guard case PlatformWalletError.deserialization = error else {
+                return XCTFail("Expected .deserialization, got \(error)")
+            }
+        }
     }

     func testEmptyInputThrowsInvalidParameter() {
-        XCTAssertThrowsError(try TransactionDecoder.decode(Data(), network: .testnet))
+        XCTAssertThrowsError(try TransactionDecoder.decode(Data(), network: .testnet)) { error in
+            guard case PlatformWalletError.invalidParameter = error else {
+                return XCTFail("Expected .invalidParameter, got \(error)")
+            }
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift`
around lines 70 - 79, The tests in TransactionDecoderTests are only asserting
that TransactionDecoder.decode throws, so they can miss regressions where the
wrong error is returned. Update testMalformedBytesThrowDeserialization and
testEmptyInputThrowsInvalidParameter to inspect the thrown error and verify the
specific TransactionDecoder error case matches the contract: malformed/trailing
bytes should map to deserialization and empty input should map to
invalidParameter. Use the existing TransactionDecoder.decode call sites in these
tests to locate the assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift`:
- Around line 58-62: The test in
TransactionDecoderTests.testTxidDisplayHexMatchesExplorerOrder contains a
tautological assertion that always passes, so remove the redundant XCTAsertEqual
on decoded.txid and keep the meaningful check against Self.fixtureTxidDisplay;
if you want additional coverage, replace it with an assertion that compares
decoded.txid to an independently derived expected value from the fixture rather
than reversing the same data twice.
- Around line 70-79: The tests in TransactionDecoderTests are only asserting
that TransactionDecoder.decode throws, so they can miss regressions where the
wrong error is returned. Update testMalformedBytesThrowDeserialization and
testEmptyInputThrowsInvalidParameter to inspect the thrown error and verify the
specific TransactionDecoder error case matches the contract: malformed/trailing
bytes should map to deserialization and empty input should map to
invalidParameter. Use the existing TransactionDecoder.decode call sites in these
tests to locate the assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 16940c17-0bcc-4af7-921e-ed66b8b231c9

📥 Commits

Reviewing files that changed from the base of the PR and between 4341d07 and 7cb62fe.

📒 Files selected for processing (6)
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/tx_decode.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/transaction_decode.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/TransactionDecoder.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionDecoderTests.swift

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Small, additive PR adding a pure transaction decoder in rs-platform-wallet plus a thin FFI/Swift wrapper. FFI memory management is correctly paired (Box::into_raw / Vec::from_raw_parts / CString round-trips), no consensus surfaces are touched, and tests cover the happy path plus trailing-garbage/coinbase/malformed cases. No blocking issues. Main suggestions cluster around the P2PKH sender-address heuristic (which infers a sender from the last scriptSig push without verifying P2PKH shape, so P2SH spends could occasionally be misattributed) and a couple of small performance/defensive tightenings.

🟡 5 suggestion(s) | 💬 2 nitpick(s)

Source: reviewers: general(opus, claude-sonnet-5, gpt-5.5[high] failed/unparseable), security-auditor(opus, claude-sonnet-5, gpt-5.5[high] failed/unparseable), rust-quality(opus, claude-sonnet-5, gpt-5.5[high] failed/unparseable), ffi-engineer(opus, claude-sonnet-5, gpt-5.5[high] failed/unparseable); verifier: opus.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/transaction_decode.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/transaction_decode.rs:96-111: P2PKH sender heuristic doesn't verify scriptSig shape, only the last push's length
  `input_address_from_script_sig` iterates every instruction in the scriptSig and takes whichever data push occurred last, then treats it as a pubkey if it is 33 or 65 bytes. It never checks that the scriptSig actually has the P2PKH shape (exactly two pushes: signature then pubkey). For a P2SH spend the last push is the redeem script; if that redeem script happens to be 33 or 65 bytes and parses as a valid secp256k1 point (spender-controllable, only constrained by hashing to the P2SH address), the function silently returns a P2PKH address unrelated to the actual signer. The module doc claims `None` for non-P2PKH, but the implementation doesn't uphold that.

  The simplest tightening is to require exactly two pushes (`sig`, `pubkey`) — anything else, including multisig P2SH redeem scripts, returns `None`. An additional guard that the first push looks like a DER-encoded ECDSA signature (starts with `0x30`, length ≤ 73) would eliminate essentially all remaining collision risk. This is not blocking for the stated CrowdNode use case (which matches on outputs, not input senders), but the failure mode — a wrong non-null address is worse than `None` — is worth closing since callers building sender-verification logic on top of `input.address` would be fooled.
- [SUGGESTION] packages/rs-platform-wallet/src/transaction_decode.rs:76-90: Unnecessary clone of every output's scriptPubkey
  `decode_transaction` iterates `tx.output` by reference and clones every `script_pubkey` to populate `DecodedOutput::script_pubkey`. Since `tx.txid()` only needs `&self` and doesn't care about the order relative to consuming other fields, the txid can be computed first, after which `tx.output` (and `tx.input`) can be consumed via `into_iter()` — eliminating the per-output `ScriptBuf` clone entirely. For transactions with large scripts (OP_RETURN payloads, multisig redeem scripts) this is a real, avoidable allocation on every decode call, and this function is positioned as the primitive for scanning arbitrary persisted transactions.
- [SUGGESTION] packages/rs-platform-wallet/src/transaction_decode.rs:113-228: No test exercises a non-P2PKH scriptSig (the documented `None` path)
  The only "no address" input test is `coinbase_input_has_no_address`. There is no test where the scriptSig's last push is not a valid P2PKH-shaped pubkey — for example a P2SH spend with a redeem-script push, a 3+-push scriptSig, or a 33/65-byte push that fails `PublicKey::from_slice`. These are exactly the ambiguous cases the length + parse checks are meant to filter out, and they'd pin the intended P2PKH-only contract so a future loosening of the heuristic (or the fix suggested above) has regression coverage.

In `packages/rs-platform-wallet-ffi/src/tx_decode.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/tx_decode.rs:87-100: `*out_decoded` is not zeroed on failure paths
  On every error return (`check_ptr!(tx_bytes)`, the `tx_bytes_len == 0` branch, `unwrap_result_or_return!(decode_transaction(...))`), the function returns a non-Success `PlatformWalletFFIResult` without assigning to `*out_decoded`. The Swift wrapper is safe because it initializes `outDecoded = nil` and checks the result code first, but any other C/Swift caller that reuses an already-populated out pointer — or reads it before checking the result code — will observe stale data and may double-free by re-calling `platform_wallet_decoded_transaction_free` on the previous value. Convention for out-pointers is to null on entry: after `check_ptr!(out_decoded)`, do `*out_decoded = std::ptr::null_mut();`. Small change, removes a foot-gun for anyone consuming this from C.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/tx_decode.rs:30-34: Document that `DecodedTxInputFFI::address` is unauthenticated and must not be used for authorization
  The per-input `address` is derived from the last scriptSig push with no signature verification tying it to the UTXO being spent. A crafter can put any 33/65-byte value in that position, so the field is trivially spoofable. The pure module doc says "best-effort / sent from", but the FFI struct comment only says the value is "recovered from a P2PKH scriptSig", which a downstream consumer (dashwallet-ios, future CrowdNode integration) could reasonably read as "the sender is P" and use for routing/authorization decisions. Strengthen the comment on `DecodedTxInputFFI::address` (and the mirrored Swift `Input.address`) to say explicitly: derived from an unauthenticated script push, safe for display or matching hints only, not for authentication/authorization. The safe matching primitive for CrowdNode-style protocols is per-output `(address, amount)`, which is already exposed and not attacker-influenceable in the same way.

Comment on lines +96 to +111
fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option<Address> {
let mut last_push: Option<Vec<u8>> = None;
for instruction in script_sig.instructions() {
match instruction {
Ok(Instruction::PushBytes(bytes)) => last_push = Some(bytes.as_bytes().to_vec()),
Ok(_) => {}
Err(_) => return None,
}
}
let candidate = last_push?;
if candidate.len() != 33 && candidate.len() != 65 {
return None;
}
let pubkey = PublicKey::from_slice(&candidate).ok()?;
Some(Address::p2pkh(&pubkey, network))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: P2PKH sender heuristic doesn't verify scriptSig shape, only the last push's length

input_address_from_script_sig iterates every instruction in the scriptSig and takes whichever data push occurred last, then treats it as a pubkey if it is 33 or 65 bytes. It never checks that the scriptSig actually has the P2PKH shape (exactly two pushes: signature then pubkey). For a P2SH spend the last push is the redeem script; if that redeem script happens to be 33 or 65 bytes and parses as a valid secp256k1 point (spender-controllable, only constrained by hashing to the P2SH address), the function silently returns a P2PKH address unrelated to the actual signer. The module doc claims None for non-P2PKH, but the implementation doesn't uphold that.

The simplest tightening is to require exactly two pushes (sig, pubkey) — anything else, including multisig P2SH redeem scripts, returns None. An additional guard that the first push looks like a DER-encoded ECDSA signature (starts with 0x30, length ≤ 73) would eliminate essentially all remaining collision risk. This is not blocking for the stated CrowdNode use case (which matches on outputs, not input senders), but the failure mode — a wrong non-null address is worse than None — is worth closing since callers building sender-verification logic on top of input.address would be fooled.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — P2PKH sender heuristic doesn't verify scriptSig shape, only the last push's length no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +76 to +90
let outputs = tx
.output
.iter()
.map(|txout| DecodedOutput {
address: Address::from_script(&txout.script_pubkey, network).ok(),
value_duffs: txout.value,
script_pubkey: txout.script_pubkey.clone(),
})
.collect();

Ok(DecodedTransaction {
txid: tx.txid(),
inputs,
outputs,
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Unnecessary clone of every output's scriptPubkey

decode_transaction iterates tx.output by reference and clones every script_pubkey to populate DecodedOutput::script_pubkey. Since tx.txid() only needs &self and doesn't care about the order relative to consuming other fields, the txid can be computed first, after which tx.output (and tx.input) can be consumed via into_iter() — eliminating the per-output ScriptBuf clone entirely. For transactions with large scripts (OP_RETURN payloads, multisig redeem scripts) this is a real, avoidable allocation on every decode call, and this function is positioned as the primitive for scanning arbitrary persisted transactions.

Suggested change
let outputs = tx
.output
.iter()
.map(|txout| DecodedOutput {
address: Address::from_script(&txout.script_pubkey, network).ok(),
value_duffs: txout.value,
script_pubkey: txout.script_pubkey.clone(),
})
.collect();
Ok(DecodedTransaction {
txid: tx.txid(),
inputs,
outputs,
})
let txid = tx.txid();
let inputs = tx
.input
.into_iter()
.map(|txin| {
let address = if txin.previous_output.is_null() {
None // coinbase
} else {
input_address_from_script_sig(&txin.script_sig, network)
};
DecodedInput {
previous_output: txin.previous_output,
address,
}
})
.collect();
let outputs = tx
.output
.into_iter()
.map(|txout| DecodedOutput {
address: Address::from_script(&txout.script_pubkey, network).ok(),
value_duffs: txout.value,
script_pubkey: txout.script_pubkey,
})
.collect();
Ok(DecodedTransaction {
txid,
inputs,
outputs,
})

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — Unnecessary clone of every output's scriptPubkey no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +87 to +100
pub unsafe extern "C" fn platform_wallet_decode_transaction(
tx_bytes: *const u8,
tx_bytes_len: usize,
network: FFINetwork,
out_decoded: *mut *mut DecodedTransactionFFI,
) -> PlatformWalletFFIResult {
check_ptr!(tx_bytes);
check_ptr!(out_decoded);
if tx_bytes_len == 0 {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"tx_bytes_len is zero",
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: *out_decoded is not zeroed on failure paths

On every error return (check_ptr!(tx_bytes), the tx_bytes_len == 0 branch, unwrap_result_or_return!(decode_transaction(...))), the function returns a non-Success PlatformWalletFFIResult without assigning to *out_decoded. The Swift wrapper is safe because it initializes outDecoded = nil and checks the result code first, but any other C/Swift caller that reuses an already-populated out pointer — or reads it before checking the result code — will observe stale data and may double-free by re-calling platform_wallet_decoded_transaction_free on the previous value. Convention for out-pointers is to null on entry: after check_ptr!(out_decoded), do *out_decoded = std::ptr::null_mut();. Small change, removes a foot-gun for anyone consuming this from C.

source: ['claude']

@thepastaclaw thepastaclaw Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: this broad out-param thread is not fully resolved in this update. The issue has been narrowed: most failure paths now clear *out_decoded, but the tx_bytes == NULL path still returns before the documented "nulled on entry" invariant is applied.

I carried the narrowed finding forward in the exact-SHA review for 225c1ae3: #3981 (review)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The remaining null-tx_bytes gap is closed in ffa8c94 — the out-param is now nulled before any input validation, and the pre-poison test covers that path too.

Comment on lines +113 to +228
#[cfg(test)]
mod tests {
use super::*;
use dashcore::consensus::serialize;
use dashcore::hashes::Hash;
use dashcore::secp256k1::{Secp256k1, SecretKey};
use dashcore::{TxIn, TxOut, Witness};

fn test_pubkey() -> PublicKey {
let secp = Secp256k1::new();
let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("valid secret key");
PublicKey::new(sk.public_key(&secp))
}

/// One P2PKH input (spending 11..11:3), one P2PKH output of 151072 duffs,
/// one OP_RETURN output. The 151072 amount mirrors a CrowdNode signUp
/// signal so the fixture doubles as documentation.
fn synthetic_tx(network: Network) -> (Transaction, Address) {
let pubkey = test_pubkey();
let addr = Address::p2pkh(&pubkey, network);
let script_sig = dashcore::blockdata::script::Builder::new()
.push_slice(&[0x30u8; 71])
.push_key(&pubkey)
.into_script();
let tx = Transaction {
version: 1,
lock_time: 0,
input: vec![TxIn {
previous_output: OutPoint {
txid: Txid::from_byte_array([0x11u8; 32]),
vout: 3,
},
script_sig,
sequence: 0xffffffff,
witness: Witness::default(),
}],
output: vec![
TxOut {
value: 151_072,
script_pubkey: addr.script_pubkey(),
},
TxOut {
value: 0,
script_pubkey: ScriptBuf::new_op_return(&[0xAAu8; 4]),
},
],
special_transaction_payload: None,
};
(tx, addr)
}

#[test]
fn decodes_outputs_with_addresses_and_values() {
let (tx, addr) = synthetic_tx(Network::Testnet);
let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap();

assert_eq!(decoded.txid, tx.txid());
assert_eq!(decoded.outputs.len(), 2);

assert_eq!(decoded.outputs[0].address.as_ref(), Some(&addr));
assert_eq!(decoded.outputs[0].value_duffs, 151_072);
assert_eq!(decoded.outputs[0].script_pubkey, addr.script_pubkey());
assert!(
addr.to_string().starts_with('y'),
"testnet P2PKH starts with 'y'"
);

// OP_RETURN: no address, script still present.
assert!(decoded.outputs[1].address.is_none());
assert!(!decoded.outputs[1].script_pubkey.as_bytes().is_empty());
}

#[test]
fn recovers_p2pkh_input_address_from_script_sig() {
let (tx, addr) = synthetic_tx(Network::Testnet);
let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap();

assert_eq!(decoded.inputs.len(), 1);
assert_eq!(
decoded.inputs[0].previous_output.txid,
Txid::from_byte_array([0x11u8; 32])
);
assert_eq!(decoded.inputs[0].previous_output.vout, 3);
assert_eq!(decoded.inputs[0].address.as_ref(), Some(&addr));
}

#[test]
fn network_changes_rendered_addresses() {
let (tx, testnet_addr) = synthetic_tx(Network::Testnet);
let decoded = decode_transaction(&serialize(&tx), Network::Mainnet).unwrap();
let rendered = decoded.outputs[0].address.as_ref().unwrap().to_string();
assert_ne!(rendered, testnet_addr.to_string());
assert!(rendered.starts_with('X'), "mainnet P2PKH starts with 'X'");
}

#[test]
fn coinbase_input_has_no_address() {
let (mut tx, _) = synthetic_tx(Network::Testnet);
tx.input[0].previous_output = OutPoint::null();
tx.input[0].script_sig = ScriptBuf::new();
let decoded = decode_transaction(&serialize(&tx), Network::Testnet).unwrap();
assert!(decoded.inputs[0].address.is_none());
}

#[test]
fn trailing_garbage_is_an_error() {
let (tx, _) = synthetic_tx(Network::Testnet);
let mut bytes = serialize(&tx);
bytes.extend_from_slice(&[0xDE, 0xAD]);
assert!(decode_transaction(&bytes, Network::Testnet).is_err());
}

#[test]
fn garbage_bytes_are_an_error() {
assert!(decode_transaction(&[0xFFu8; 16], Network::Testnet).is_err());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: No test exercises a non-P2PKH scriptSig (the documented None path)

The only "no address" input test is coinbase_input_has_no_address. There is no test where the scriptSig's last push is not a valid P2PKH-shaped pubkey — for example a P2SH spend with a redeem-script push, a 3+-push scriptSig, or a 33/65-byte push that fails PublicKey::from_slice. These are exactly the ambiguous cases the length + parse checks are meant to filter out, and they'd pin the intended P2PKH-only contract so a future loosening of the heuristic (or the fix suggested above) has regression coverage.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — No test exercises a non-P2PKH scriptSig (the documented None path) no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +30 to +34
/// Sender address recovered from a P2PKH scriptSig, or null when the
/// input is coinbase / non-P2PKH / unparseable. Owned by the parent
/// structure — freed by `platform_wallet_decoded_transaction_free`.
pub address: *mut c_char,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Document that DecodedTxInputFFI::address is unauthenticated and must not be used for authorization

The per-input address is derived from the last scriptSig push with no signature verification tying it to the UTXO being spent. A crafter can put any 33/65-byte value in that position, so the field is trivially spoofable. The pure module doc says "best-effort / sent from", but the FFI struct comment only says the value is "recovered from a P2PKH scriptSig", which a downstream consumer (dashwallet-ios, future CrowdNode integration) could reasonably read as "the sender is P" and use for routing/authorization decisions. Strengthen the comment on DecodedTxInputFFI::address (and the mirrored Swift Input.address) to say explicitly: derived from an unauthenticated script push, safe for display or matching hints only, not for authentication/authorization. The safe matching primitive for CrowdNode-style protocols is per-output (address, amount), which is already exposed and not attacker-influenceable in the same way.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — Document that DecodedTxInputFFI::address is unauthenticated and must not be used for authorization no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +96 to +111
fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option<Address> {
let mut last_push: Option<Vec<u8>> = None;
for instruction in script_sig.instructions() {
match instruction {
Ok(Instruction::PushBytes(bytes)) => last_push = Some(bytes.as_bytes().to_vec()),
Ok(_) => {}
Err(_) => return None,
}
}
let candidate = last_push?;
if candidate.len() != 33 && candidate.len() != 65 {
return None;
}
let pubkey = PublicKey::from_slice(&candidate).ok()?;
Some(Address::p2pkh(&pubkey, network))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: last_push reallocates on every push instead of holding a borrow

The loop allocates a fresh Vec<u8> for every push instruction, discarding the previous allocation, only to keep the last one. Since PushBytes borrows from the underlying ScriptBuf, you can hold &[u8] for the loop duration and only inspect it at the end. Not a hot-path concern, but easy to fix and avoids O(n_pushes) allocations.

Suggested change
fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option<Address> {
let mut last_push: Option<Vec<u8>> = None;
for instruction in script_sig.instructions() {
match instruction {
Ok(Instruction::PushBytes(bytes)) => last_push = Some(bytes.as_bytes().to_vec()),
Ok(_) => {}
Err(_) => return None,
}
}
let candidate = last_push?;
if candidate.len() != 33 && candidate.len() != 65 {
return None;
}
let pubkey = PublicKey::from_slice(&candidate).ok()?;
Some(Address::p2pkh(&pubkey, network))
}
fn input_address_from_script_sig(script_sig: &ScriptBuf, network: Network) -> Option<Address> {
let mut last_push: Option<&[u8]> = None;
for instruction in script_sig.instructions() {
match instruction {
Ok(Instruction::PushBytes(bytes)) => last_push = Some(bytes.as_bytes()),
Ok(_) => {}
Err(_) => return None,
}
}
let candidate = last_push?;
if candidate.len() != 33 && candidate.len() != 65 {
return None;
}
let pubkey = PublicKey::from_slice(candidate).ok()?;
Some(Address::p2pkh(&pubkey, network))
}

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — last_push reallocates on every push instead of holding a borrow no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +12 to +43
public struct DecodedTransaction: Sendable, Equatable {
public struct Input: Sendable, Equatable {
/// Previous output's txid in consensus (internal) byte order.
public let prevTxid: Data
/// Previous output's index.
public let prevVout: UInt32
/// Sender address recovered from a P2PKH scriptSig; nil for
/// coinbase / non-P2PKH / unparseable script sigs.
public let address: String?
}

public struct Output: Sendable, Equatable {
/// Destination address for standard scripts (P2PKH / P2SH); nil for
/// non-standard scripts (OP_RETURN, bare multisig, …).
public let address: String?
/// Output value in duffs.
public let valueDuffs: UInt64
/// Raw scriptPubKey bytes.
public let scriptPubkey: Data
}

/// Transaction id in consensus (internal) byte order — reverse for
/// explorer-style display (see `txidDisplayHex`).
public let txid: Data
public let inputs: [Input]
public let outputs: [Output]

/// Explorer-style (reversed, hex) rendering of `txid`.
public var txidDisplayHex: String {
txid.reversed().map { String(format: "%02x", $0) }.joined()
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: Input.prevTxid lacks a display-hex helper analogous to txidDisplayHex

txid gets a txidDisplayHex computed property that reverses to explorer order, but Input.prevTxid (same consensus/internal byte-order convention per the FFI doc comment) has no parallel helper. Callers matching a stored transaction's inputs against an explorer-copied txid string will silently produce a byte-reversed mismatch. Given the CrowdNode use case involves matching known txids, exposing a symmetric prevTxidDisplayHex (or a shared helper) would prevent a common footgun. Pure ergonomics/consistency note.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — Input.prevTxid lacks a display-hex helper analogous to txidDisplayHex no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.21%. Comparing base (9eb03c0) to head (ffa8c94).
⚠️ Report is 4 commits behind head on v4.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.1-dev    #3981      +/-   ##
============================================
+ Coverage     87.19%   87.21%   +0.02%     
============================================
  Files          2636     2642       +6     
  Lines        327822   328272     +450     
============================================
+ Hits         285852   286315     +463     
+ Misses        41970    41957      -13     
Components Coverage Δ
dpp 87.72% <ø> (+0.01%) ⬆️
drive 86.14% <ø> (ø)
drive-abci 89.45% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

Opened a focused helper PR for the CodeRabbit test feedback: #3993

It removes the tautological txid assertion and pins the expected PlatformWalletError cases for malformed and empty inputs. The two changed error-path tests pass individually; full TransactionDecoderTests still has unrelated decode happy-path failures with the freshly built FFI slice, noted in the helper PR body.


/// A consensus-decoded transaction with addresses rendered for a network.
#[derive(Debug, Clone)]
pub struct DecodedTransaction {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do you need this step?? Isn't decoding bytes into a Transaction and then transforming it into the FFI structure more than enough?? I don't see a usage for DecodedTransaction/DecodedInput/DecodedOutput

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — done in 4eaa825. platform_wallet_decode_transaction now deserializes straight into dashcore::Transaction and marshals that into the FFI structs; the transaction_decode module and its intermediate types are gone. The FFI struct layout and symbols are unchanged, so the generated header and the Swift wrapper API are unaffected.

/// One P2PKH input (spending 11..11:3), one P2PKH output of 151072 duffs,
/// one OP_RETURN output. The 151072 amount mirrors a CrowdNode signUp
/// signal so the fixture doubles as documentation.
fn synthetic_tx(network: Network) -> (Transaction, Address) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If you need Transaction for your tests there is a test_utils module in the dashcore crate, it may fit your needs, and if it doesn't let me now so I can consider updating the API

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted in 4eaa825 — the tests now use Address::dummy, Transaction::dummy, and Transaction::dummy_coinbase (via a dashcore = { workspace = true, features = ["test-utils"] } dev-dependency). One gap: there's no builder for a P2PKH-shaped scriptSig (signature push + pubkey push) — Transaction::dummy fills script_sig with the lock script — so the fixture for the sender-address recovery path stays hand-built. If you'd like to add something like Transaction::dummy_p2pkh_spend to test_utils I'd happily consume it, but it's not blocking.

/// One decoded transaction input.
#[repr(C)]
pub struct DecodedTxInputFFI {
/// Previous output's txid in consensus (internal) byte order.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Instead of creating new structs and mapping every field, have you consider an opaque pointer like:

#[repr(C)]
pub struct FFITransaction {
    inner: *mut c_void, // This is a pointer to the heap allocated transaction decoded
}

Then, instead of reading fields, we can call struct methods exposed though the FFI that are able to convert that inner pointer into the real transaction, and get the data you need. This may not be ideal, feel free to say no

/// One decoded transaction input.
#[repr(C)]
pub struct DecodedTxInputFFI {
/// Previous output's txid in consensus (internal) byte order.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can maybe update the places where the transaction bytes are being returned to return this FFI structure directly, avoiding having to decode it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good idea — that would save those consumers the decode round-trip. I'd keep it out of this PR though, since it changes the signatures of existing byte-returning APIs and their consumers. Follow-up material?

@llbartekll llbartekll marked this pull request as draft July 3, 2026 15:35
…structs

Review response for #3981:

- drop the intermediate DecodedTransaction/DecodedInput/DecodedOutput
  types and the transaction_decode module in rs-platform-wallet; the FFI
  entry point now consensus-decodes into dashcore::Transaction and
  marshals it straight into the (unchanged) C structures
- tighten the P2PKH sender heuristic: exactly two data pushes
  (DER-shaped signature, then 33/65-byte pubkey) and nothing else, so
  P2SH redeem scripts no longer masquerade as sender addresses
- null *out_decoded on entry so failure paths can't leak stale pointers
  to C callers
- consume the decoded tx (into_iter + ScriptBuf::into_bytes) instead of
  cloning every scriptPubkey; hold borrows in the heuristic instead of
  reallocating per push
- document input.address as unauthenticated (display/matching only) on
  both the FFI struct and the Swift mirror; add prevTxidDisplayHex
- move all tests to the FFI crate, through the public entry point; use
  dashcore test_utils fixtures where they fit (Address::dummy,
  Transaction::dummy, dummy_coinbase) and pin the new heuristic
  rejections (non-P2PKH scriptSig, redeem-script collision, non-DER
  first push) plus null-on-failure

FFI surface (struct layouts, symbols) is byte-identical to the previous
commit, so the generated header and Swift wrapper API are unaffected.
@llbartekll

Copy link
Copy Markdown
Contributor Author

Review-bot suggestions applied in 4eaa825 alongside the restructure: the P2PKH sender heuristic now requires exactly two pushes (DER-shaped signature + 33/65-byte pubkey, anything else → null) so redeem scripts can't masquerade as sender addresses; *out_decoded is nulled on entry (pinned by tests); input.address is documented as unauthenticated on both the FFI struct and the Swift mirror; the per-output scriptPubkey clone and the per-push reallocation are gone (the decoded tx is consumed); non-P2PKH / redeem-script-collision / non-DER-first-push tests pin the None path; and Input.prevTxidDisplayHex was added. No order-assertion test for the latter — the fixture's prev txid [0x11; 32] is reversal-invariant, so the assertion would be vacuous.

@llbartekll llbartekll marked this pull request as ready for review July 6, 2026 08:12
@llbartekll llbartekll requested a review from ZocoLini July 6, 2026 08:39

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Cumulative review from 4eaa8257 to 225c1ae3. The tx-decode FFI surface is unchanged since the prior review; the latest delta is a merge from origin/v4.1-dev containing unrelated work. The single carried-forward finding remains valid, and I found no new in-scope defects in the latest delta.

Source: reviewers = claude general opus (ok); codex general gpt-5.5 (ok, parsed after rc=5); claude security-auditor opus (ok); codex security-auditor gpt-5.5 (ok); claude ffi-engineer opus (ok); codex ffi-engineer gpt-5.5 (ok); verifier = claude opus (ok); specialists = security-auditor, ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.

🟡 1 suggestion(s)

Prior Reconciliation

  • prior-1 from 4eaa8257: STILL VALID. At 225c1ae3, packages/rs-platform-wallet-ffi/src/tx_decode.rs:142-144 still checks tx_bytes before validating and clearing out_decoded, while the safety docs still promise out_decoded is nulled on entry.

Carried-Forward Prior Findings

  • [SUGGESTION] packages/rs-platform-wallet-ffi/src/tx_decode.rs:142-144: *out_decoded not nulled on the tx_bytes == NULL path, contradicting the documented safety contract
    The rustdoc promises callers that the out-slot is cleared on entry, but check_ptr!(tx_bytes) returns before *out_decoded = null_mut() runs. A C/FFI caller that reuses an out-slot can therefore observe a stale pointer after ErrorNullPointer. The Swift wrapper initializes its local out pointer, so the current Swift path is not exposed; the risk is on the public C ABI surface. Validate and clear out_decoded before checking tx_bytes, and extend the pre-poison test pattern to cover the null-tx_bytes case.

New Findings In Latest Delta

  • None. git diff 4eaa8257..225c1ae3 is the upstream merge delta and does not touch the tx-decode FFI surface.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-ffi/src/tx_decode.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/tx_decode.rs:142-144: `*out_decoded` not nulled on the tx_bytes-null path, contradicting documented safety contract
  The rustdoc on `platform_wallet_decode_transaction` (lines 132-134) promises that `out_decoded` "is nulled on entry", so callers can trust the out-slot is cleared before any failure path. In the current ordering, `check_ptr!(tx_bytes)` returns early via the macro before line 144 clears `*out_decoded`. A C/FFI caller that reuses an out-slot containing a previously-decoded pointer and then invokes this function with `tx_bytes == NULL` receives `ErrorNullPointer` while the stale pointer remains visible. The fix is to validate and clear `out_decoded` first, then validate `tx_bytes`.

Comment on lines +142 to +144
check_ptr!(tx_bytes);
check_ptr!(out_decoded);
*out_decoded = std::ptr::null_mut();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: *out_decoded not nulled on the tx_bytes-null path, contradicting documented safety contract

The rustdoc on platform_wallet_decode_transaction (lines 132-134) promises that out_decoded "is nulled on entry", so callers can trust the out-slot is cleared before any failure path. In the current ordering, check_ptr!(tx_bytes) (line 142) returns early via the macro before line 144 clears *out_decoded. A C/FFI caller that reuses an out-slot containing a previously-decoded (and possibly already-freed) DecodedTransactionFFI* and then invokes this function with tx_bytes == NULL receives ErrorNullPointer while the stale pointer remains visible. Contract-following cleanup code that calls platform_wallet_decoded_transaction_free(*out) on the assumption the slot was cleared then double-frees or dereferences freed memory. The Swift wrapper (TransactionDecoder.decode) zero-initializes its local outDecoded, so it is not exposed today; the risk lives on the public C ABI surface. The existing rejects_null_and_empty_input test never asserts the out-param state after the null-tx_bytes case, which is why the mismatch slipped through — extending the pre-poison pattern used by trailing_garbage_is_a_deserialization_error would pin the fix. The fix is one line: validate and clear out_decoded first, then validate tx_bytes.

Suggested change
check_ptr!(tx_bytes);
check_ptr!(out_decoded);
*out_decoded = std::ptr::null_mut();
check_ptr!(out_decoded);
*out_decoded = std::ptr::null_mut();
check_ptr!(tx_bytes);

source: ['claude', 'codex']

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ffa8c94out_decoded is now validated and nulled before check_ptr!(tx_bytes), so the documented "nulled on entry" contract holds on every failure path. rejects_null_and_empty_input now pre-poisons the out param (same pattern as trailing_garbage_is_a_deserialization_error) and asserts it comes back null for both the null-tx_bytes and zero-length cases.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in ffa8c94*out_decoded not nulled on the tx_bytes-null path, contradicting documented safety contract no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

The safety docs promise out_decoded is nulled on entry, but
check_ptr!(tx_bytes) returned first, so a NULL tx_bytes left a stale
pointer visible in the out-slot on the public C ABI. Reorder the
checks and pre-poison the out param in rejects_null_and_empty_input
to pin both failure paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Cumulative review from 225c1ae3 to ffa8c943. The latest delta directly fixes the prior FFI out-param contract issue: platform_wallet_decode_transaction now validates and clears out_decoded before checking tx_bytes, and rejects_null_and_empty_input pre-poisons the out slot and asserts it is nulled on both null-tx_bytes and zero-length failure paths. No carried-forward prior findings remain, and I found no new in-scope defects in the latest delta or current PR head.

Source: reviewers = claude general opus (ok); codex general gpt-5.5 (ok, parsed after rc=5); claude security-auditor opus (ok); codex security-auditor gpt-5.5 (ok); claude ffi-engineer opus (ok); codex ffi-engineer gpt-5.5 (ok); verifier = claude opus (ok); specialists = security-auditor, ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.

Prior Reconciliation:

  • prior-1 from 225c1ae3: FIXED. At ffa8c943, packages/rs-platform-wallet-ffi/src/tx_decode.rs:142-144 now runs check_ptr!(out_decoded); *out_decoded = std::ptr::null_mut(); check_ptr!(tx_bytes);, so the documented "nulled on entry" contract holds even when tx_bytes == NULL. The regression test now pre-poisons the out pointer and asserts it is nulled for the null and empty-input failure paths.

Carried-Forward Prior Findings:

  • None.

New Findings In Latest Delta:

  • None. git diff 225c1ae3..ffa8c943 only reorders the FFI pointer checks and strengthens the null/empty-input test around the prior finding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants