Skip to content

feat(kotlin-sdk): union-funding build_signed_payment send API (core primitive + FFI/JNI/Kotlin) - #4247

Open
bfoss765 wants to merge 2 commits into
dashpay:v4.2-devfrom
bfoss765:port/v4.1/send-raw-tx
Open

feat(kotlin-sdk): union-funding build_signed_payment send API (core primitive + FFI/JNI/Kotlin)#4247
bfoss765 wants to merge 2 commits into
dashpay:v4.2-devfrom
bfoss765:port/v4.1/send-raw-tx

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What

Adds the SDK's L1 send primitive build_signed_payment and plumbs it through FFI → JNI → Kotlin.

  • feat(platform-wallet): union-funding build_signed_payment core primitive — builds + signs an L1 payment transaction, union-funding across all funding accounts.
  • feat(sdk): plumb build_signed_payment through FFI, JNI, and Kotlin — exposes it to the Android/Kotlin SDK.

Why

This is the send half of the wallet SDK cutover: the Dash Wallet Android app calls it (SdkL1SendService / CoreSendAllNative) for post-cutover L1 sends. Once dashj's L1 engine is held after cutover, this primitive is what actually builds/signs/sends transactions — so it is load-bearing for the cutover.

Notes

  • Split out of the QA integration line onto a clean v4.2-dev base (2 self-contained commits); mirrors the other v4.1 Kotlin-SDK feature PRs.
  • No rust-dashcore dependency change required.
  • cargo check -p platform-wallet -p platform-wallet-ffi is clean.

Summary by CodeRabbit

  • New Features
    • Added a build-only API for creating and signing standard L1 payments without broadcasting or recording a debit.
    • Supports multiple recipients, configurable fees, automatic input selection, change calculation, and signed transaction output.
    • Added clear insufficient-funds reporting, including available and required amounts.
    • Excludes watch-only funds from payment funding.

bfoss765 and others added 2 commits July 28, 2026 01:36
Add CoreWallet::build_signed_payment — a first-class "send" primitive that
selects inputs across the UNION of every signable funds account (BIP44 +
BIP32 + CoinJoin + DashPay receiving), builds and signs a standard L1
payment, and returns the signed transaction plus its fee and change amount.

This is the build-only half of the Android send-path cutover: during the
dashj->SDK transition the app keeps dashj's transaction bookkeeping
(maybeCommitTx drives CrowdNode, memos, confidence listeners), so the SDK
must build + sign from the bound wallet and hand back the bytes while dashj
commits/broadcasts. The method therefore does NOT broadcast and does NOT
persist a debit — the only state touched is the in-memory ReservationSet
that set_funding/build_signed use to stop a concurrent SDK build from
re-selecting the same coins (released when the spend is later observed by
sync or by the reservation-TTL backstop).

Reuses the shielded asset-lock union-funding machinery
(all_funding_accounts + spendable_utxos + a spanning path resolver +
LargestFirst selection to keep CoinJoin's many small denominations from
blowing up BranchAndBound), but excludes watch-only DashpayExternalAccounts
(a contact's addresses, which this wallet cannot sign). Fee and change are
derived from the transaction itself (inputs - outputs), the always
self-consistent ground truth, rather than from build_signed's signed-size
fee recomputation which can drift by a few duffs from what is actually paid.

Adds the typed PaymentInsufficientFunds { available, required } error so a
shortfall carries the exact union-wide selectable total.

Tests: correct output/change/fee, BIP44+CoinJoin union selection, typed
union shortfall, watch-only exclusion, and input validation.
cargo test -p platform-wallet --lib: 442 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 38012d1)
Expose the union-funding send primitive as a public Kotlin suspend fun that
returns the signed raw transaction bytes (plus fee + change) WITHOUT
broadcasting — the last SDK gap for the Android wallet's send-path cutover.

- FFI (rs-platform-wallet-ffi): core_wallet_build_signed_payment, a one-shot
  call over CoreWallet::build_signed_payment. Recipients cross as a
  big-endian blob (u32 count; per row u32 addrLen, addr utf8, u64 amount),
  each address parsed + network-checked; returns the consensus-serialized
  signed bytes via out-pointers plus out_fee/out_change, freed with
  core_wallet_free_payment_bytes. cbindgen exports both automatically.
- JNI (rs-unified-sdk-jni): WalletManagerNative.coreWalletBuildSignedPayment
  returns a byte[] packed big-endian as (u64 fee, u64 change, tx bytes); the
  FFI-owned bytes are freed before returning.
- Kotlin (kotlin-sdk): ManagedPlatformWallet.buildSignedPayment(recipients,
  coreSignerHandle, feePerKb) -> SignedCorePayment(txBytes, fee, change),
  serialized under the same shared per-wallet coreSendMutex as
  sendToAddresses so a concurrent build cannot select the same UTXO. Unlike
  sendToAddresses it neither broadcasts nor picks a funding account (coin
  selection auto-spans every signable account). ManagedCoreWallet gains the
  matching native call on the transient core handle.

cargo check/test green across platform-wallet-ffi (149) and rs-unified-sdk-jni
(2); :sdk:compileDebugKotlin BUILD SUCCESSFUL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit d18c9c0)

[port to v4.2-dev] ManagedPlatformWallet.buildSignedPayment is serialized
through the manager's TeardownGate (gate.op {}), matching sendToAddresses and
every other native op on this branch, instead of the pre-refactor per-wallet
`coreSendMutex` (which no longer exists on v4.2-dev). Concurrent builds still
cannot select the same UTXO: CoreWallet::build_signed_payment holds the
wallet-manager write lock across coin selection and signing.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a build-only Core L1 payment flow that selects signable funds, signs without broadcasting or debit persistence, and returns fee, change, and serialized transaction bytes through Rust FFI, JNI, and Kotlin APIs.

Changes

Core signed payment

Layer / File(s) Summary
Core wallet payment builder
packages/rs-platform-wallet/src/wallet/core/*, packages/rs-platform-wallet/src/error.rs, packages/rs-platform-wallet/src/lib.rs
CoreWallet builds and signs payments from eligible signable accounts, excludes watch-only funds, computes fee and change, reports insufficient funds, and exposes SignedCorePayment; tests cover selection, signing, validation, and shortfalls.
FFI payment bridge
packages/rs-platform-wallet-ffi/src/core_wallet/*
The FFI decodes packed outputs, validates inputs, invokes CoreWallet signing, returns serialized transaction data with fee and change, and frees allocated payment bytes.
Kotlin signed payment API
packages/rs-unified-sdk-jni/src/wallet_manager.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/*
JNI and Kotlin layers validate and encode payment inputs, call the native flow, and decode the packed result into SignedCorePayment without broadcasting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ManagedPlatformWallet
  participant WalletManagerNative
  participant JNI
  participant CoreWalletFFI
  participant CoreWallet
  participant Signer
  ManagedPlatformWallet->>WalletManagerNative: request signed payment
  WalletManagerNative->>JNI: invoke native API
  JNI->>CoreWalletFFI: pass encoded outputs and handles
  CoreWalletFFI->>CoreWallet: build and sign payment
  CoreWallet->>Signer: sign selected inputs
  Signer-->>CoreWallet: signed transaction
  CoreWallet-->>CoreWalletFFI: transaction, fee, and change
  CoreWalletFFI-->>JNI: serialized transaction bytes
  JNI-->>ManagedPlatformWallet: packed fee, change, and transaction
Loading

Possibly related issues

Suggested reviewers: shumkov, lklimek, quantumexplorer

🚥 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 matches the main change: a union-funded build_signed_payment API exposed through core, FFI, JNI, and Kotlin.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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

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.

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

Actionable comments posted: 2

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

Inline comments:
In `@packages/rs-platform-wallet-ffi/src/core_wallet/send.rs`:
- Around line 62-63: Validate the decoded count in the send transaction decoding
flow before the Vec::with_capacity(count) allocation. Use the remaining blob
length and the minimum encoded size of one output to reject impossible or
excessively large counts with the existing TransactionBuild decode error, then
allocate only after validation; preserve normal decoding for valid counts.

In `@packages/rs-platform-wallet/src/wallet/core/send.rs`:
- Line 149: Update build_signed_payment to limit the wallet_manager write lock
to primary-account lookup, transaction input building, and reservation; clone
the primary account before releasing wm, then perform
builder.build_signed(signer, ...) after the lock is dropped. Match the
lock-lifetime split used by finalize_transaction so signing never holds the
global manager write lock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 535b5447-abf3-4bea-99d0-99a1cbe6ebc6

📥 Commits

Reviewing files that changed from the base of the PR and between ed4116b and 9a104d5.

📒 Files selected for processing (10)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt
  • packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/send.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/core/mod.rs
  • packages/rs-platform-wallet/src/wallet/core/send.rs
  • packages/rs-unified-sdk-jni/src/wallet_manager.rs

Comment on lines +62 to +63
let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::with_capacity(count);

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Untrusted count drives an unbounded upfront allocation.

count is read directly from the blob header with no relation to blob.len() before being used in Vec::with_capacity(count). A malformed/corrupted blob (or a future encoding bug) with a large count triggers a large allocation attempt before any row is validated, which can abort the process via OOM instead of returning a clean TransactionBuild decode error.

🛡️ Proposed fix
     let count = read_u32(blob, &mut cursor)? as usize;
-    let mut outputs = Vec::with_capacity(count);
+    // Don't trust `count` for pre-allocation; rows accumulate incrementally
+    // and each is bounds-checked below.
+    let mut outputs = Vec::new();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::with_capacity(count);
let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::new();
🤖 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/rs-platform-wallet-ffi/src/core_wallet/send.rs` around lines 62 -
63, Validate the decoded count in the send transaction decoding flow before the
Vec::with_capacity(count) allocation. Use the remaining blob length and the
minimum encoded size of one output to reject impossible or excessively large
counts with the existing TransactionBuild decode error, then allocate only after
validation; preserve normal decoding for valid counts.

}
let outputs_total: u64 = outputs.iter().map(|(_, amount)| *amount).sum();

let mut wm = self.wallet_manager.write().await;

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n --type=rust -C15 'pub(crate) async fn finalize_transaction' packages/rs-platform-wallet/src/wallet/core/transaction.rs
rg -n --type=rust 'MnemonicResolverCoreSigner' -C10 --glob '*rs-sdk-ffi*'

Repository: dashpay/platform

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -u

echo "Repo files matching send.rs:"
fd -a 'send\.rs$|transaction\.rs$' . 2>/dev/null | sed 's#^\./##' | head -100

echo
echo "Locate send.rs and transaction.rs:"
git ls-files | rg '(^|/)send\.rs$|(^|/)transaction\.rs$' | head -100

echo
echo "Search finalizer/signer terms in Rust files:"
rg -n -C8 'pub\(crate\)? async fn finalize_transaction|build_signed|MnemonicResolverCoreSigner|wallet_manager.write' -g '*.rs' . | head -300

Repository: dashpay/platform

Length of output: 30973


🏁 Script executed:

#!/bin/bash
set -u

echo "Inspect target send.rs around reported lines if present:"
target="packages/rs-platform-wallet/src/wallet/core/send.rs"
if [ -f "$target" ]; then
  wc -l "$target"
  sed -n '120,270p' "$target" | nl -ba -v120
else
  echo "missing $target"
fi

echo
echo "Inspect transaction.rs around finalize_transaction if present:"
target="packages/rs-platform-wallet/src/wallet/core/transaction.rs"
if [ -f "$target" ]; then
  wc -l "$target"
  rg -n -C20 'pub\(crate\)? async fn finalize_transaction' "$target" || true
else
  echo "missing $target"
fi

Repository: dashpay/platform

Length of output: 430


🏁 Script executed:

#!/bin/bash
set -u

target="packages/rs-platform-wallet/src/wallet/core/send.rs"
sed -n '130,270p' "$target"

echo
echo "Send module outline:"
ast-grep outline "$target" --view expanded | sed -n '1,220p' || true

echo
echo "Find build_signed payment/finalizer definitions:"
rg -n -C25 'async fn (build_signed|finalize_transaction|build_signed_payment)|pub \(crate\) async fn (build_signed|finalize_transaction|build_signed_payment)|pub async fn (build_signed|finalize_transaction|build_signed_payment)|fn finalize|fn build_signed' packages/rs-platform-wallet/src packages/rs-platform-wallet-ffi/src packages/rs-sdk/src | sed -n '1,260p'

Repository: dashpay/platform

Length of output: 34779


🏁 Script executed:

#!/bin/bash
set -u

echo "send.rs tail around lock drop:"
sed -n '148,290p' packages/rs-platform-wallet/src/wallet/core/send.rs | sed -n '1,160p'

echo
echo "transaction.rs finalize_transaction implementation:"
sed -n '118,230p' packages/rs-platform-wallet/src/wallet/core/transaction.rs | sed -n '1,140p'

echo
echo "Search for signers around MnemonicResolverCoreSigner:"
rg -n -C15 'struct MnemonicResolverCoreSigner|impl .*Signer .*for MnemonicResolverCoreSigner|MnemonicResolverCoreSigner|impl .*TransactionSigner' packages/rs-platform-wallet packages/rs-platform-wallet-ffi packages/rs-sdk packages/rs-unified-sdk-jni -g '*.rs' | sed -n '1,260p'

Repository: dashpay/platform

Length of output: 36273


🏁 Script executed:

#!/bin/bash
set -u

echo "Find ManagedPlatformWallet.kt and CoreWallet build_signed_payment consumers:"
fd -a 'ManagedPlatformWallet\.kt$|.*SignedCorePayment.*\.(kt|swift|java)$|.*build_signed_payment.*\.(kt|swift|java)$' . 2>/dev/null | sed 's#^\./##' | head -200

echo
echo "Search build_signed_payment and MnemonicResolverCoreSigner in Kotlin/Swift/FFI docs:"
rg -n -C18 'build_signed_payment|MnemonicResolverCoreSigner|underlying |holds the wallet-manager write lock' packages -g '*.{kt,swift,java,rs,md}' | sed -n '1,260p'

Repository: dashpay/platform

Length of output: 22528


🏁 Script executed:

#!/bin/bash
set -u

echo "ManagedPlatformWallet.kt:"
wc -l packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt
rg -n -C20 'build_signed_payment|wallet-manager write lock|MnemonicResolverCoreSigner|CoreWallet|finalize_transaction' packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk -g '*.kt' | sed -n '1,260p'

Repository: dashpay/platform

Length of output: 37056


Scope the wallet-manager write lock to reservation/funding, not sign.

finalize_transaction intentionally releases the manager lock before signing, while build_signed_payment keeps wm around for the whole builder.build_signed(signer, ...) call. Use the same split: clone the primary account, build and reserve transaction inputs under the manager lock, then sign without holding that write lock so production Keychain/Keystore auth does not block the global wallet manager.

🤖 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/rs-platform-wallet/src/wallet/core/send.rs` at line 149, Update
build_signed_payment to limit the wallet_manager write lock to primary-account
lookup, transaction input building, and reservation; clone the primary account
before releasing wm, then perform builder.build_signed(signer, ...) after the
lock is dropped. Match the lock-lifetime split used by finalize_transaction so
signing never holds the global manager write lock.

@thepastaclaw

thepastaclaw commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 9a104d5)
Canonical validated blockers: 6

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

Preliminary review — Codex only

The new payment API has six blocking defects: secondary-account inputs can be signed twice, malformed recipient metadata can abort the process, unchecked output and fee arithmetic can wrap, insufficient-funds errors lose their Kotlin type, and the added unit tests do not compile. The signing path also holds the wallet-manager write lock throughout repeated host-backed key derivation and signing, unnecessarily blocking wallet sync and access.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 6 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/wallet/core/send.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/send.rs:237-238: Secondary-account inputs are not reserved in their owning accounts
  `set_funding` filters against and captures only the primary BIP44 account's `ReservationSet`, while `add_inputs` appends UTXOs gathered from every other account without reservation filtering. `TransactionBuilder::assemble_unsigned` consequently records every selected outpoint in the primary set. A later union build re-adds the same secondary-account UTXO because `spendable_utxos` does not consult the primary set, and an account-specific build checks only the secondary account's own set. The manager lock serializes builds while each call is running but does not make the first build's secondary reservations visible after it returns, so sequential calls can return signed transactions that spend the same CoinJoin, BIP32, or DashPay UTXO. Implement wallet-wide or multi-account reservation tracking that filters and reserves every selected outpoint in the ledger consulted by all funding paths.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/send.rs:329-331: The new payment tests reference a missing fixture
  `split_funded_wallet_manager` is imported and used by the new union-funding tests, but it is not defined in `crate::test_support` or elsewhere at this head. Running `cargo test -p platform-wallet --lib --no-run` fails with E0432 at this import, followed by cascading type-inference errors, so the crate's unit-test target cannot compile. Add the omitted split-account fixture or rewrite these tests using an existing fixture.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/send.rs:147: Unchecked output aggregation can build an invalid transaction
  The requested output total is calculated with unchecked `u64` summation, and key-wallet independently performs the same unchecked sum while building the transaction. Kotlin can supply four positive amounts of `1L shl 62`; their mathematical total is `2^64`, which wraps to zero in release builds. Coin selection can then fund only the fee while retaining all four enormous outputs, returning a signed transaction that consensus and relay will reject and reporting meaningless fee/change metadata. Overflow-checking builds panic inside the C FFI path. Use checked aggregation and reject totals above Dash's `MAX_MONEY` before invoking the builder.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/core/send.rs:154-155: Unbounded fee rates overflow key-wallet fee calculation
  The public Kotlin and FFI APIs accept any non-negative fee rate, including values near `Long.MAX_VALUE`, and pass it directly to `FeeRate::new`. Key-wallet computes fees as `sat_per_kb * size_bytes` with unchecked `u64` multiplication. Large caller-supplied rates therefore panic in overflow-checking Android builds or wrap in release builds, potentially turning an astronomical requested rate into a tiny fee. Validate the rate against a monetary and transaction-size bound or change the fee calculation call chain to return an error from checked arithmetic.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/send.rs:149: External signing holds the wallet-manager write lock
  The write guard acquired here remains live through `build_signed(...).await`. A union-funded payment can select hundreds of inputs, and the production signer resolves and parses the mnemonic, derives the seed and BIP32 key, and invokes the host callback separately for each input. This exclusively blocks wallet sync and every other manager-backed operation for the entire signing phase. The existing `finalize_transaction` path reserves and snapshots the unsigned transaction, UTXOs, and paths under the lock, drops the guard, and signs afterward; this path should follow that model once reservations cover every funding account, releasing all reservations if signing fails.

In `packages/rs-platform-wallet-ffi/src/core_wallet/send.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/core_wallet/send.rs:62-66: Untrusted recipient count can abort the process through an enormous allocation
  The decoder passes the caller-controlled `u32` count directly to `Vec::with_capacity` before checking whether the blob contains that many rows. A four-byte blob declaring `u32::MAX` outputs can therefore request hundreds of gigabytes and enter Rust's process-aborting allocation-failure path instead of returning the documented decode error. On 32-bit targets, capacity or `cursor + addr_len` overflow can also panic inside the nested `extern "C"` function, where the JNI guard cannot safely recover it. Bound the count using the remaining blob length and the standard-transaction output limit, use `try_reserve_exact`, and use checked cursor arithmetic.

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/error.rs:327-339: Payment insufficient-funds errors lose their Kotlin type
  The new `PlatformWalletError::PaymentInsufficientFunds` variant has no conversion arm and falls through to `ErrorUnknown` (99). JNI consequently throws code 1099, which Kotlin maps to `DashSdkError.PlatformWallet.Generic`, instead of the existing `PlatformWallet.CoreInsufficientFunds` type mapped from FFI code 22. This erases the expected shortfall classification from the new API even though the Rust variant preserves `available` and `required` in its message. Map both Core and payment shortfalls to `ErrorCoreInsufficientFunds`, or introduce a dedicated ABI code and Kotlin type.

Comment on lines +237 to +238
.set_funding(primary_funds, &primary_account)
.add_inputs(extra_inputs);

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.

🔴 Blocking: Secondary-account inputs are not reserved in their owning accounts

set_funding filters against and captures only the primary BIP44 account's ReservationSet, while add_inputs appends UTXOs gathered from every other account without reservation filtering. TransactionBuilder::assemble_unsigned consequently records every selected outpoint in the primary set. A later union build re-adds the same secondary-account UTXO because spendable_utxos does not consult the primary set, and an account-specific build checks only the secondary account's own set. The manager lock serializes builds while each call is running but does not make the first build's secondary reservations visible after it returns, so sequential calls can return signed transactions that spend the same CoinJoin, BIP32, or DashPay UTXO. Implement wallet-wide or multi-account reservation tracking that filters and reserves every selected outpoint in the ledger consulted by all funding paths.

source: ['codex']

Comment on lines +329 to +331
use crate::test_support::{
funded_wallet_manager, split_funded_wallet_manager, AlwaysRejectedBroadcaster,
};

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.

🔴 Blocking: The new payment tests reference a missing fixture

split_funded_wallet_manager is imported and used by the new union-funding tests, but it is not defined in crate::test_support or elsewhere at this head. Running cargo test -p platform-wallet --lib --no-run fails with E0432 at this import, followed by cascading type-inference errors, so the crate's unit-test target cannot compile. Add the omitted split-account fixture or rewrite these tests using an existing fixture.

source: ['codex']

Comment on lines +62 to +66
let count = read_u32(blob, &mut cursor)? as usize;
let mut outputs = Vec::with_capacity(count);
for _ in 0..count {
let addr_len = read_u32(blob, &mut cursor)? as usize;
let end = cursor + addr_len;

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.

🔴 Blocking: Untrusted recipient count can abort the process through an enormous allocation

The decoder passes the caller-controlled u32 count directly to Vec::with_capacity before checking whether the blob contains that many rows. A four-byte blob declaring u32::MAX outputs can therefore request hundreds of gigabytes and enter Rust's process-aborting allocation-failure path instead of returning the documented decode error. On 32-bit targets, capacity or cursor + addr_len overflow can also panic inside the nested extern "C" function, where the JNI guard cannot safely recover it. Bound the count using the remaining blob length and the standard-transaction output limit, use try_reserve_exact, and use checked cursor arithmetic.

source: ['codex']

"every output amount must be greater than zero".to_string(),
));
}
let outputs_total: u64 = outputs.iter().map(|(_, amount)| *amount).sum();

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.

🔴 Blocking: Unchecked output aggregation can build an invalid transaction

The requested output total is calculated with unchecked u64 summation, and key-wallet independently performs the same unchecked sum while building the transaction. Kotlin can supply four positive amounts of 1L shl 62; their mathematical total is 2^64, which wraps to zero in release builds. Coin selection can then fund only the fee while retaining all four enormous outputs, returning a signed transaction that consensus and relay will reject and reporting meaningless fee/change metadata. Overflow-checking builds panic inside the C FFI path. Use checked aggregation and reject totals above Dash's MAX_MONEY before invoking the builder.

Suggested change
let outputs_total: u64 = outputs.iter().map(|(_, amount)| *amount).sum();
let outputs_total = outputs
.iter()
.try_fold(0u64, |total, (_, amount)| total.checked_add(*amount))
.ok_or_else(|| {
PlatformWalletError::TransactionBuild("output amount total overflow".to_string())
})?;
if outputs_total > dashcore::blockdata::constants::MAX_MONEY {
return Err(PlatformWalletError::TransactionBuild(
"output amount total exceeds MAX_MONEY".to_string(),
));
}

source: ['codex']

Comment on lines +154 to +155
let height = info.core_wallet.last_processed_height();
let fee_rate = FeeRate::new(fee_per_kb.unwrap_or(DEFAULT_FEE_PER_KB));

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.

🔴 Blocking: Unbounded fee rates overflow key-wallet fee calculation

The public Kotlin and FFI APIs accept any non-negative fee rate, including values near Long.MAX_VALUE, and pass it directly to FeeRate::new. Key-wallet computes fees as sat_per_kb * size_bytes with unchecked u64 multiplication. Large caller-supplied rates therefore panic in overflow-checking Android builds or wrap in release builds, potentially turning an astronomical requested rate into a tiny fee. Validate the rate against a monetary and transaction-size bound or change the fee calculation call chain to return an error from checked arithmetic.

source: ['codex']

}
let outputs_total: u64 = outputs.iter().map(|(_, amount)| *amount).sum();

let mut wm = self.wallet_manager.write().await;

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: External signing holds the wallet-manager write lock

The write guard acquired here remains live through build_signed(...).await. A union-funded payment can select hundreds of inputs, and the production signer resolves and parses the mnemonic, derives the seed and BIP32 key, and invokes the host callback separately for each input. This exclusively blocks wallet sync and every other manager-backed operation for the entire signing phase. The existing finalize_transaction path reserves and snapshots the unsigned transaction, UTXOs, and paths under the lock, drops the guard, and signs afterward; this path should follow that model once reservations cover every funding account, releasing all reservations if signing fails.

source: ['codex']

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants