Skip to content

feat(platform-wallet): token-minting finalize from a funding path (spendable DashPay receival accounts) - #4256

Open
bfoss765 wants to merge 31 commits into
dashpay:v4.2-devfrom
bfoss765:port/v4.1/funding-path-finalize
Open

feat(platform-wallet): token-minting finalize from a funding path (spendable DashPay receival accounts)#4256
bfoss765 wants to merge 31 commits into
dashpay:v4.2-devfrom
bfoss765:port/v4.1/funding-path-finalize

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What this does

Bridges two flows that were previously disconnected in platform-wallet:

New finalize_signed_payment_from_funding_path returns a FinalizedCorePayment (signed tx, fee, change, the resolved FundingAccountRef, and the reservation token). The key correctness property: the reservation is recorded against the resolved account, never a BIP44 default, so release/broadcast bookkeeping lands in the same ledger the build reserved into. FundingAccountRef::Path makes non-standard accounts (DashPay receival) nameable to the release machinery, which previously could only address StandardAccountTypes.

Surface:

  • wallet/core/transaction.rsFundingAccountRef enum + release_reservation_for
  • wallet/core/send.rsfinalize_signed_payment_from_funding_path, FinalizedCorePayment, abandon_payment
  • wallet/signed_payment_registry.rsregister_funded_by
  • FFI core_wallet_build_signed_payment_with_token
  • JNI coreWalletBuildSignedPaymentWithToken
  • Kotlin ManagedPlatformWallet.buildSignedPaymentWithToken(recipients, coreSignerHandle, feePerKb, fundingPath), returning the existing SignedCoreTransaction (now carrying changeDuffs)

Guardrails preserved and covered by tests: single-account funding only (the funding-privacy guardrails still pass — no cross-account union), change goes to BIP44/0, watch-only accounts are refused, and the fee is taken from the signed transaction rather than re-estimated.

Stacked on

Stacked on #4185, and includes #4247's commits.

The branch is based on #4185's head (port/v4.1/split-build-broadcast) with #4247's three commits (port/v4.1/send-raw-tx) cherry-picked beneath this change, because it needs both:

Merge #4185 and #4247 first, then rebase this PR — its own contribution is the top two commits.

Two scoping notes for reviewers

1. The test-support commit is a verbatim carry of #4184's fixture — drop it on rebase once #4184 merges.

The receival tests need a wallet whose balance is split between BIP44 account 0 and a DashPay funds account, which WalletAccountCreationOptions::Default does not provision. That fixture — split_funded_wallet_manager_dashpay, DashpayLeg, and the foreign_contact_account_xpub helper — already exists in #4184's test_support.rs, and the last commit here (test(platform-wallet): DashPay-funded split wallet fixture…) is a verbatim copy of it, carried so this PR does not have to stack on 2.9k lines of unrelated asset-lock production code just to reach a test fixture. No production code from #4184 is included.

Once #4184 merges, drop that commit when rebasing#4184's copy is the canonical one. The only intentional deviation is that the three items are #[cfg(test)]-gated here (ungated they would trip dead_code in a test-utils-only build of the FFI crate, since this crate's unit tests are their sole consumer); #4184 widens the consumer set, so its ungated copy supersedes this one cleanly.

2. De-contaminated from the source branch. On kotlinSDK-v4-qa3 the finalize commit and its parent had swapped pieces: the finalize commit carried the masternodes-by-voting-key JNI export (plus its read_id20 helper), while the parent carried this feature's external fun coreWalletBuildSignedPaymentWithToken declaration. This PR drops the masternodes bridge (its FFI half is not in this stack, so it would not build here) and includes the Kotlin declaration that belongs to it. The masternodes-by-voting-key feature is untouched and remains to be submitted on its own.

Tests

  • cargo test -p platform-wallet --lib528 passed, 0 failed, no warnings. Includes all 11 wallet::core::send tests, all 20 wallet::signed_payment_registry tests, and all 3 wallet::funding_privacy::guardrail tests.
  • cargo check -p platform-wallet-ffi -p rs-unified-sdk-jni — clean

Three new tests cover the receival funding path end to end, including reservation hold/release cycles:

  • receival_funding_path_selects_signs_and_reserves_in_that_account
  • receival_reservation_is_held_and_released_against_the_receival_account
  • default_funding_reservation_is_held_and_released_on_bip44

References

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for building, signing, broadcasting, and releasing deferred Core payments.
    • Payments can use a selected funding account or derivation path and return fee, change, transaction, and reservation details.
    • Added reservation-token management for safely completing or abandoning payments.
    • Added clear handling for stale, consumed, and wallet-mismatched reservation tokens.
  • Bug Fixes

    • Improved wallet-generation validation and reservation cleanup to prevent incorrect payment or reservation reuse.
    • Added insufficient-funds reporting with available and required amounts.

bfoss765 and others added 29 commits July 23, 2026 11:36
…BIP70 deferred submission

BIP70/BIP270 (CTX/DashSpend) sends must sign, POST the raw bytes to a
merchant server, and broadcast only on ack — structurally impossible on the
one-shot `sendToAddresses`. Expose the existing internal build/broadcast split
with an explicit reservation lifecycle, keeping `CoreTransactionBuilder`
internal so the manager stays the sole driver of the setFunding/buildSigned
race.

Rust core (rs-platform-wallet):
- New `SignedPaymentRegistry`: a generic, in-memory registry that owns a
  built+signed tx and its held UTXO reservation between build and submission,
  keyed by an opaque `ReservationToken`. `broadcast` removes the entry before
  sending (no double-broadcast — a repeat/concurrent call gets `StaleToken`),
  binds each token to its originating wallet instance (`Arc::ptr_eq` on the
  shared `WalletManager`, so a re-created wallet is rejected), and reconciles
  the reservation on failure via the existing release-on-rejection path.
  `release` is idempotent. Reservations are memory-only, so a crash between
  build and broadcast drops both the entry and the reservation on restart —
  the same property dashj has.
- `CoreWallet::release_transaction_reservation` — the explicit "abandoned /
  nacked" release arm.

FFI (platform-wallet-ffi) — additive C ABI:
- `core_wallet_transaction_get_bytes`, `core_wallet_signed_payment_register`
  (token + fee + txid), `core_wallet_signed_payment_broadcast`,
  `core_wallet_signed_payment_release`, backed by one process-global registry
  pinned to `SpvBroadcaster`.
- New `ErrorStaleReservationToken` (22) result code.

JNI (rs-unified-sdk-jni) — additive: `coreTransactionGetBytes`,
`coreWalletRegisterSignedPayment` (BLOB), `coreWalletBroadcastSignedPayment`,
`coreWalletReleaseSignedPayment`.

Kotlin — additive: `ManagedPlatformWallet.SignedCoreTransaction`,
`buildSignedPayment` (build under coreSendMutex), `broadcastSigned(token)`,
`releaseReservation(token)`; `DashSdkError.PlatformWallet.StaleReservationToken`.
No existing signatures change.

Refs dashpay#4089, dashpay/dash-wallet#1507 Phase 5c GAP-4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er/release

Address review of the SignedPaymentRegistry deferred build→broadcast/release
flow.

BLOCKING: registry tokens never expired even though the key-wallet UTXO
reservation they depend on is swept after RESERVATION_TTL_BLOCKS (24) and
released by raw outpoint with no ownership check, so a long-outstanding
token's broadcast/release could free or spend against an unrelated newer
reservation. Bound the token lifetime: capture the wallet's synced height at
register and refuse broadcast/release once the wallet has synced
RESERVATION_MAX_AGE_BLOCKS (20, < TTL) past it, returning the typed
StaleReservationToken WITHOUT releasing (which could free a newer build's
reservation). The pinned key-wallet exposes no per-outpoint generation check,
so this client-side bound is the primary guard.

Also:
- WalletMismatch now compares wallet_id in addition to Arc::ptr_eq on the
  shared WalletManager, so two wallets in one multi-wallet manager are told
  apart.
- register() returns the raw tx bytes in the same native call and the JNI
  folds them into the register BLOB; the now-unused core_wallet_transaction_get_bytes
  / coreTransactionGetBytes is removed (one native round trip per kotlin-sdk rule).
- register() does its fallible/pure marshalling before the reservation-holding
  insert, and the JNI releases the token if it can't hand the BLOB back to
  Kotlin — no orphaned reservation on a marshalling failure.
- PlatformWallet teardown sweeps the registry of that wallet's tokens so a
  destroyed wallet's WalletManager is no longer pinned alive by a captured
  CoreWallet clone (hooked at platform_wallet_destroy, not the transient
  core-handle destroy the deferred flow cycles through).
- Registry mutex recovers from poisoning instead of panicking, matching
  key-wallet's ReservationSet.

Adds tests for token expiry (broadcast + release), same-manager different
wallet_id mismatch, and the teardown sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dk-and-example-app

Rebasing the split build/broadcast work onto the current base surfaced three
semantic collisions the textual merge could not catch:

- error code 22 was reassigned on base (ErrorCoreInsufficientFunds and the
  asset-lock family 22-25); moved ErrorStaleReservationToken to the next free
  code 26 in platform-wallet-ffi and DashSdkError's native-code mapping.
- base added its own CoreWallet::release_transaction_reservation (taking
  AccountTypePreference, superset incl. CoinJoin) for the finalized-transaction
  abandon path, colliding with this PR's identically-named StandardAccountType
  method. Renamed this PR's deferred-payment release to
  release_payment_reservation (sole caller: SignedPaymentRegistry::release).
- base removed the per-wallet coreSendMutex and now serializes/gates core
  sends through the TeardownGate (gate.op), moving send concurrency safety into
  the Rust reservation layer. buildSignedPayment now opens with gate.op like its
  sibling sendToAddresses instead of the removed mutex, which also satisfies the
  GateCoverageLintTest handle-borrowing fence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n mapping

The rebase onto feat/kotlin-sdk-and-example-app reassigned native code 22 to
ErrorCoreInsufficientFunds and moved ErrorStaleReservationToken to code 26 (on
both the Rust enum and DashSdkError's mapping), but DashSdkErrorTest still
constructed code 22 and asserted StaleReservationToken. That deterministically
resolved to CoreInsufficientFunds, so platformWalletCodesMapToPlatformWalletSubtree
failed and :sdk:testDebugUnitTest — the "Kotlin SDK build + tests (x86_64
emulator)" CI job — went red without actually verifying the code-26 mapping.

Point the assertion at code 26 so it exercises the real production mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s own height clock

The SignedPaymentRegistry age guard stamped registered_height with
CoreWallet::synced_height() and compared it against a later synced_height(),
while the funding reservation it is meant to stay under is stamped with
last_processed_height() (the height finalize_transaction / build_signed pass to
set_current_height, and the clock key-wallet's ReservationSet TTL sweeps
against). synced_height can regress during a rescan while last_processed_height
is monotonic, so measuring the reservation's age against synced_height could let
a token outlive its reservation and act on an outpoint key-wallet had already
swept and re-selected for an unrelated build.

Read last_processed_height() for both the registration stamp and the current
comparison so the guard measures the same clock the reservation is stamped with,
trips strictly before the underlying TTL, and never regresses. Add
CoreWallet::last_processed_height(); drop the now-unused synced_height().
The registry's expiry tests now stamp and advance last_processed_height to match
production, and outstanding() is exposed under test-utils for downstream FFI
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llet alias is destroyed

platform_wallet_destroy unconditionally called remove_entries_for_wallet, which
matches every registry entry sharing the destroyed handle's WalletManager
pointer + wallet_id. But platform_wallet_manager_get_wallet hands out an
independent handle per alias of the same logical wallet (the loadPersistedWallets
path can publish a new wrapper while callers still hold an older one). Destroying
one alias therefore consumed a sibling alias's still-live deferred-payment token:
the sibling's later broadcast failed as stale while the sweep left the UTXO
reserved until its TTL.

Gate the sweep on final-alias liveness: after removing this handle, scan the
remaining PlatformWallet handles for one that shares the same (WalletManager
pointer + wallet_id) — exactly the key remove_entries_for_wallet matches. While a
sibling is live the destructor only drops this handle; the sweep runs (releasing
the registry's WalletManager pin) only once the last alias goes. Adds
HandleStorage::any for the scan and a test_support helper that builds real
PlatformWallet aliases; a new FFI test proves a sibling alias's token survives
one alias's destruction and is swept when the final alias is destroyed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-register path

buildSignedPayment funded, signed, and registered a deferred payment as three
separate native round-trips (setFunding + buildSigned + registerSignedPayment).
Once the base branch removed the per-wallet coreSendMutex in favour of the
TeardownGate — which only counts active ops for safe teardown and does not
serialize sends — that split lost its atomic select-and-reserve boundary: two
concurrent deferred builds, or a deferred build racing an immediate send, could
select the same UTXO before either reserved it and return two signed
transactions spending the same input.

Restore atomicity in the Rust reservation layer, the correct home now that the
Kotlin mutex is gone: add core_wallet_signed_payment_finalize, which runs the
same finalize_transaction the immediate V2 path uses — selection and
ReservationSet insertion commit as one unit under the wallet-manager lock,
signing only after the lock drops — and then registers the built, reserved tx in
the same call. buildSignedPayment now issues that single native operation
(CoreTransactionBuilder.finalizeSignedPayment + coreWalletFinalizeSignedPayment),
so the select+reserve window can no longer interleave. The existing
concurrent_same_account_finalizers_cannot_reserve_the_same_input test already
covers the atomic boundary the deferred path now shares. The deprecated split
wrappers remain but are no longer on the deferred path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After finalize routing landed, the four-layer deferred-register chain
core_wallet_signed_payment_register (FFI) → coreWalletRegisterSignedPayment
(JNI) → WalletManagerNative.coreWalletRegisterSignedPayment (Kotlin) →
ManagedCoreWallet.registerSignedPayment had zero callers. It is the unsafe
variant whose age guard baselines registered_height at registration time
(after external signing) rather than at the reservation's own height, so
removing it also removes that mis-baselined path. The atomic
core_wallet_signed_payment_finalize path is the only remaining register site.

Delete all four layers; repoint the surviving broadcast/finalize doc comments
at the finalize entry point; drop the now-unused FFICoreTransaction::fee
accessor (keep the ABI field, silence the lint).

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

finalize_transaction captures last_processed_height inside the funding critical
section and stamps the selected inputs' reservation with it, then signs after
dropping the wallet-manager lock. The registry, however, sampled a FRESH
last_processed_height in register() — run AFTER the (possibly slow, external)
signer returned. A slow signer could let the wallet advance so the token's
baseline was higher than the reservation's true stamp height, making the age
guard measure from the wrong side of signing: the token looked young while its
reservation had already aged toward key-wallet's TTL sweep, risking a
release/broadcast against an outpoint key-wallet had swept and re-selected.

Carry the stamp height on SignedCoreTransaction (reservation_height, captured
in the funding section before signing) and have register() take the height as
an explicit parameter instead of sampling. The atomic finalize FFI passes
finalized.reservation_height(); the age guard now baselines on the same clock
the reservation was stamped with. Adds a regression test that registers after
the wallet advanced (modelling a slow signer) and proves the guard trips
MAX_AGE past the reservation height, not past a post-signing sample.

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

The V2 finalized-transaction handle validated only wallet_id, while the
registry-token path validated the shared WalletManager Arc plus wallet_id.
Neither can tell one wallet generation from another: after a wallet is removed
and re-created under the same id, both the manager Arc and wallet_id are equal,
so an old V2 handle could act through the old generation while the new
generation selects the same inputs.

Add CoreWallet::is_same_generation — the single generation identity both paths
now share. Aliases of one generation share the per-generation Arc<WalletBalance>
(created fresh in the wallet-lifecycle create/load paths); a re-created wallet
gets a new one, so Arc::ptr_eq on it distinguishes generations that wallet_id +
the manager Arc cannot. Holding either handle pins the balance Arc, so its
address can't be reused for a different generation — the same soundness argument
the registry already uses for the manager Arc.

Apply it to both V2 broadcast and abandon (replacing the wallet_id-only check).
The registry broadcast path adopts the same identity in the follow-up
validate-under-lock change. Adds a unit test proving alias-vs-recreation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only a match

SignedPaymentRegistry::broadcast removed the entry first and validated the
wallet binding second, so a mismatched caller (wrong wallet, or a re-created
generation) destroyed the ORIGINAL wallet's token and left its reservation
stranded until the TTL backstop — a wrong-wallet broadcast could grief the
rightful owner's in-flight payment.

Peek under the registry lock, reject a non-matching caller with WalletMismatch
WITHOUT removing the entry, and only remove (consume) an entry whose generation
matches. The check-then-remove is one lock hold, so it stays atomic against a
concurrent broadcast — the double-broadcast guard is unchanged (the second
consumer finds nothing → StaleToken). The binding check now uses the shared
CoreWallet::is_same_generation identity, so the registry-token and V2 handle
paths agree on when a caller owns a token.

Updates the two existing mismatch tests (which asserted the old drop-on-mismatch
behaviour) and adds a regression proving a wrong-wallet broadcast preserves the
owner's token and the owner can still broadcast it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…; drop them at generation teardown

platform_wallet_destroy called remove_entries_for_wallet, which only DROPPED
the registry entries. But destroying the last wrapper alias does not remove the
logical wallet from its manager — the accounts' ReservationSets stay live and
the same wallet can be handed out again — so the dropped tokens' inputs stayed
reserved until key-wallet's TTL. Tokens were consumed without releasing live
reservations.

Split the two teardown moments under one generation identity:

- Final-alias destroy (wallet still live): release_entries_for_wallet RELEASES
  each of the generation's reservations against the still-live wallet (honouring
  the age guard), so a wallet handed out again can respend the inputs. The
  final-alias check and the match are both by CoreWallet::is_same_generation.

- Actual generation teardown (platform_wallet_manager_remove_wallet): the wallet
  and its ReservationSets are gone, so remove_entries_for_wallet DROPS the
  generation's registry tokens (nothing to reconcile) and remove_matching drops
  its finalized-tx V2 handles. This makes any stale handle to the removed
  generation inert, which is what makes the destroy-time release provably
  race-free: a torn-down generation has already had its tokens swept here, so
  destroy/release can never release-by-outpoint against a re-created
  generation's inputs.

platform_wallet_destroy now block_on's the release (as it already runs off the
tokio runtime on the JNI / NativeCleaner threads). Adds HandleStorage::remove_matching,
registry release_entries_for_wallet, a registry regression proving destroy-time
release frees the reservation while teardown drop does not, and reworks the FFI
destroy test to invoke destroy off-runtime.

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

Native code 26 (ErrorStaleReservationToken) mapped all three
SignedPaymentError variants — StaleToken (unknown/already-broadcast/released),
WalletMismatch (different wallet generation), and StaleReservationToken (aged
out) — so a host could not tell "you already broadcast this", "wrong wallet",
and "the reservation aged out; rebuild" apart, even though the remedy and
messaging differ.

Split at the FFI (additive sibling codes, no renumbering):
- 26 ErrorStaleReservationToken   -> StaleReservationToken (aged out)
- 27 ErrorReservationTokenConsumed -> StaleToken (unknown/already broadcast/released)
- 28 ErrorReservationWalletMismatch -> WalletMismatch (different generation)

core_wallet_signed_payment_broadcast now maps each variant to its own code.
All three remain non-retryable-in-place and none touch the network.

Host impact (Kotlin SDK only — the Swift host does not map these codes): adds
DashSdkError.PlatformWallet.ReservationTokenConsumed / ReservationWalletMismatch,
maps 27/28, narrows the code-26 doc, updates the JNI/Kotlin broadcast KDocs, and
extends DashSdkErrorTest to assert all three.

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

The KDoc still described the pre-finalize build (`new → addOutput* → setFunding
→ buildSigned`) and credited buildSigned with reserving the inputs. The deferred
path now issues a single atomic finalizeSignedPayment (select + reserve + sign +
register under the wallet-manager lock). Update the described step sequence and
the atomicity claim to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ble with a Cleaner backstop

buildSignedPayment returned a plain SignedCoreTransaction through a cancellable
coroutine. The blocking JNI registration mints the reservation token before the
Kotlin object exists, so if cancellation was observed after that native call
returned — or the caller simply dropped the value — the token (and its funding
reservation) was orphaned until key-wallet's TTL, with no release path.

Make SignedCoreTransaction an AutoCloseable that registers a NativeCleaner
backstop at construction: close(), or GC if the caller never calls it, releases
the token exactly once. Native release is idempotent and tokens are
process-unique, so releasing a token already consumed by broadcastSigned /
releaseReservation (or closing twice) is a harmless no-op. This closes the
cancellation window — the object is Cleaner-backed the instant it exists (no
suspension point between the native return and construction), so a discarded
object always releases its token.

Adds a pure-JVM test pinning the ownership contract (owning AutoCloseable) and
the Cleaner run-once guarantee it relies on, and documents the ownership on
buildSignedPayment. :sdk:testDebugUnitTest passes.

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

Normalize two pre-existing long lines in coreWalletFinalizeSignedPayment that
`cargo fmt --check` flags, so the JNI crate is formatting-clean after the
register-chain removal touched this file.

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

Review round 2: the bare-Long token API couples the reservation's
lifetime to the SignedCoreTransaction's GC-reachability — extracting the
token and dropping the object lets the Cleaner backstop release the
reservation out from under a pending broadcast. The object overloads
keep the payment reachable across the native call (reachabilityFence)
and disarm the backstop once the token is consumed; the bare-token docs
now warn about the reachability requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed deferred payments

The deferred-payment registry stored only an `Option<StandardAccountType>`, so a
CoinJoin funding — which has no `StandardAccountType` — reconciled nothing on
rejection/abandon/free and kept its inputs reserved until key-wallet's 24-block
TTL, even though `finalize` reserves the selected inputs for every account
variant.

Carry the full `AccountTypePreference` (BIP44/BIP32/CoinJoin) as the entry's
releasable account handle. The registry now broadcasts through the new
`broadcast_payment_releasing_reservation` and releases through
`release_transaction_reservation` (both `AccountTypePreference`-typed and
CoinJoin-capable), so a rejected or abandoned CoinJoin deferred payment frees its
reservation immediately. The FFI finalize passes `account_type.into()` instead of
the `StandardAccountType` subset; the now-unused `release_payment_reservation`
(registry-only) is removed.

Test: `coinjoin_funded_release_frees_the_reservation_immediately` funds CoinJoin
account 0, finalizes a sweep, registers the token, and proves release makes the
input immediately spendable again. Adds a `#[cfg(test)]`
`funded_coinjoin_wallet_manager` fixture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… wallet generation

The deferred registry validated a token's generation at the registry lock, then
released its reservation later, off that lock. `ReservationSet::release` removes
an outpoint unconditionally and is reached via `wallet_id` — an identity a
same-id remove-then-recreate preserves — so a wallet re-created in that window
could have the NEW generation's reservation freed by the old token's cleanup.

Bind the cleanup to the token's own generation: `release_transaction_reservation`
now re-validates the generation and mutates the `ReservationSet` under a single
manager read-lock hold, acting only when the wallet still registered under the id
carries the same per-generation balance `Arc` the handle captured. A recreation
needs the manager write lock, so it cannot interleave between the check and the
release — validate-and-mutate is atomic. This protects both the registry
(release/abandon and broadcast-on-rejection) and the V2 finalized-transaction
handle path, which share this primitive. Adds `CoreWallet::generation()`.

Test: `recreation_between_validation_and_cleanup_cannot_release_new_generation`
recreates the wallet under the same id between registration and release and
asserts the input stays reserved (the reservation the new generation owns is
untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`PlatformWalletResultCode` jumped from 25 straight to 98, so the three deferred
build->broadcast/release codes this PR owns (26 StaleReservationToken, 27
ReservationTokenConsumed, 28 ReservationWalletMismatch) fell through to
`.errorUnknown` on iOS, erasing their distinct retry semantics.

Add the three raw codes to `PlatformWalletResultCode`, matching cases to
`PlatformWalletError`, and map them in both `init(ffi:)` and `init(result:)`.
The `init(result:)` switch (no default) stays exhaustive — the same
non-exhaustive-switch class shumkov flagged on dashpay#4184. Messages pass the Rust
`Display` string straight through, matching the Kotlin SDK's mapping verbatim.

Verified with `swiftc -parse` (the DashSDKFFI xcframework — cbindgen header +
cdylib — is built separately by build_ios.sh and is not present in this
checkout, so a full `swift build` type-check isn't possible here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…error codes

`core_wallet_signed_payment_broadcast` still documented the pre-split semantics:
a repeated broadcast and a re-created wallet both as `ErrorStaleReservationToken`
(26). Since the three-way split, a repeated/concurrent broadcast yields
`ErrorReservationTokenConsumed` (27) and a re-created wallet generation yields
`ErrorReservationWalletMismatch` (28); 26 is reserved for the aged-out case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point all rust-dashcore crates at bfoss765/rust-dashcore
e99959ced0062159d629930f488374e29f63c42b (PR dashpay/rust-dashcore#916),
which is v4.1-dev's rust-dashcore tip 70d4bf8 plus the additive
owner-tagged reservation API: key_wallet::ReservationToken,
ReservationSet::reserve/release_if_owner,
TransactionBuilder::build_{unsigned,signed}_reserved,
ManagedCoreFundsAccount::release_reservation_if_owner, and
AssetLockResult.reservation_token. Additive over 70d4bf8, so it stays
compatible with the rest of the v4.1-dev workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v4.1-dev added ErrorTransactionBroadcastRejected = 26, colliding with
this PR's three deferred-reservation siblings that also claimed 26/27/28.
Keep v4.1-dev's 26 and shift this PR's codes up by one:

  27 = ErrorStaleReservationToken     (was 26)
  28 = ErrorReservationTokenConsumed  (was 27)
  29 = ErrorReservationWalletMismatch (was 28)

29 is free on v4.1-dev (dashpay#4184's AssetLockInsufficientFunds is not yet
merged there). The Rust FFI enum and Swift bindings were renumbered in
the rebase conflict resolution; this finishes the propagation through the
Kotlin runtime mapping and KDoc (DashSdkError.kt, WalletManagerNative.kt),
the Kotlin error-code test, and the signed_payment FFI doc comments (also
recast from fix-round narration to an as-built description).

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

A deferred send reserves its funding inputs at build, awaits the
broadcast, and on a definitive rejection releases the reservation for an
immediate rebuild. That release was unconditional (release-by-outpoint):
during the broadcast await, key-wallet's TTL sweep can reclaim the
reservation and a concurrent build can re-reserve the same outpoint under
a new token, so the by-outpoint release would free that other build's
inputs — the dashpay#4185 release/re-reserve double-spend window.

Capture the key_wallet::ReservationToken build_unsigned_reserved stamps
onto the selected inputs, carry it on SignedCoreTransaction alongside
reservation_height, thread it through the deferred registry
(RegisteredPayment / register / broadcast / reconcile) and
broadcast_payment_releasing_reservation, and release via
ManagedCoreFundsAccount::release_reservation_if_owner so a rejected or
abandoned send frees only inputs its own build still owns. The finalize
sign-failure path (a platform-side await between reserve and release) is
owner-guarded the same way. None (no reservation taken) keeps the old
by-outpoint fallback, never reached on the funded finalize path.

Adds a regression test: a rejected deferred broadcast whose outpoint was
swept and re-reserved under a new token leaves that new reservation
intact. Docs name the shared generation identity's sibling V2 handle path
(dashpay#4196).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
… funding

build_signed_payment funded from the union of all signable funds accounts
(BIP44 + CoinJoin + …) with BIP44 change — the privacy-domain-crossing design
blocked on dashpay#4184 (shumkov, 2026-07-21) and replaced there by single-account
selection. This code predated that re-scope.

- add funding_path: Option<DerivationPath>; None = unmixed BIP44 account 0,
  Some(path) = strictly the one funds account whose account path matches. No
  union, no cross-account accumulation; shortfall returns PaymentInsufficientFunds
  for that account only. Change routes to BIP44 (explicit change addr when a
  non-Standard account funds).
- new wallet::funding_privacy guardrail: crate-wide static test fails the build
  if any wallet-wide funds-account iteration lacks a PRIVACY-DOMAIN-OK marker.
- replace the union-asserting test with default-never-crosses-domains and
  explicit-path-selects-strictly tests.
- review-fix hardening: bounded FFI allocation + checked cursor math, output-total
  overflow guard, fee-rate bound, typed PaymentInsufficientFunds (code 22).
- thread funding_path through FFI/JNI/Kotlin (null = unmixed BIP44).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…endable DashPay receival accounts)

Bridges the two previously-disconnected flows: build_signed_payment's
single-account-by-derivation-path selection (the only path that can
reach a dashpay_receival account) and the reservation/broadcast flow
(token -> broadcastSigned/releaseReservation). New
finalize_signed_payment_from_funding_path returns FinalizedCorePayment
(tx, fee, change, resolved FundingAccountRef, reservation token); the
reservation is recorded against the RESOLVED account (never a BIP44
default), so release/broadcast bookkeeping lands in the ledger the
build reserved into. FundingAccountRef::Path makes non-standard
accounts nameable to the release machinery. FFI
core_wallet_build_signed_payment_with_token + JNI + Kotlin
buildSignedPaymentWithToken(recipients, coreSignerHandle, feePerKb,
fundingPath) returning the existing SignedCoreTransaction. Invariants
preserved and tested: single account only (funding-privacy guardrails
pass), change to BIP44/0, watch-only refused, fee from the signed tx.
3 new tests incl. reservation hold/release cycles; full send suite
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ceival finalize tests

The receival funding-path tests need a wallet whose balance is split
between BIP44 account 0 and a DashPay funds account, which
`WalletAccountCreationOptions::Default` does not provision. Ports the
`DashpayLeg` / `foreign_contact_account_xpub` /
`split_funded_wallet_manager_dashpay` test_support fixture verbatim from
the asset-lock multi-account work (dashpay#4184, commit 4655eef) so this PR
does not have to stack on 2.9k lines of unrelated asset-lock production
code just to reach the fixture.

Test-support only — no production code is taken from dashpay#4184. The three
items are `#[cfg(test)]`-gated here (unlike on dashpay#4184, where the
asset-lock `test-utils` build also consumes them) because only this
crate's own unit tests use them; ungated they would trip `dead_code` in
a `test-utils`-only build of the FFI crate.

When dashpay#4184 and this PR both land, test_support.rs will conflict on these
three items; resolve by keeping a single copy (drop the `#[cfg(test)]`
gates, since dashpay#4184 widens the consumer set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added this to the v4.2.0 milestone Jul 31, 2026
@thepastaclaw

thepastaclaw commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 1a7a4b6)
Canonical validated blockers: 1

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 28 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6bd3e994-f278-42f6-9184-081b2a035a4e

📥 Commits

Reviewing files that changed from the base of the PR and between 727db77 and 1a7a4b6.

📒 Files selected for processing (12)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt
  • packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/send.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/utils.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/core/send.rs
  • packages/rs-platform-wallet/src/wallet/funding_privacy.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs
  • packages/rs-unified-sdk-jni/src/wallet_manager.rs
📝 Walkthrough

Walkthrough

This PR adds a deferred "build-sign-later-broadcast" Core L1 payment workflow across the Rust wallet core, FFI, JNI, Kotlin SDK, and Swift SDK, using opaque reservation tokens. It adds a signed-payment registry, generation-bound wallet identity checks, funding-domain isolation guardrails, new error codes, and a dependency revision bump.

Changes

Deferred Signed Core Payment Workflow

Layer / File(s) Summary
Core wallet payment building and reservation-aware lifecycle
packages/rs-platform-wallet/src/wallet/core/send.rs, .../transaction.rs, .../broadcast.rs, .../wallet.rs, .../mod.rs, packages/rs-platform-wallet/src/error.rs, packages/rs-platform-wallet/src/lib.rs
Adds single-account build_signed_payment/finalize_signed_payment_from_funding_path, FundingAccountRef, PaymentInsufficientFunds, reservation height/token on SignedCoreTransaction, wallet-generation identity checks, and token-guarded release/broadcast.
Signed payment registry
packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs, .../mod.rs
Adds SignedPaymentRegistry keyed by ReservationToken, with generation-bound broadcast, idempotent release, age-guard expiration, and wallet-teardown cleanup, verified by extensive concurrency and regression tests.
Rust FFI exports
packages/rs-platform-wallet-ffi/src/core_wallet/send.rs, .../signed_payment.rs, .../transaction_builder.rs, .../broadcast.rs, .../mod.rs, packages/rs-platform-wallet-ffi/src/error.rs, .../handle.rs, .../manager.rs, .../utils.rs, .../wallet.rs
Adds functions to build, finalize, broadcast, and release tokenized payments; adds new FFI error codes; adds handle-predicate helpers and generation-aware teardown cleanup.
JNI bindings
packages/rs-unified-sdk-jni/src/funding.rs, .../wallet_manager.rs
Adds JNI exports wrapping the Rust FFI for building, finalizing, broadcasting, and releasing tokenized signed payments.
Kotlin SDK APIs, errors, and tests
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt, .../ffi/WalletManagerNative.kt, .../wallet/CoreTransactionBuilder.kt, .../wallet/ManagedCoreWallet.kt, .../wallet/ManagedPlatformWallet.kt, test files
Adds SignedCoreTransaction/SignedCorePayment, build/finalize/broadcast/release APIs, and typed reservation-token errors with corresponding tests.
Swift SDK error mapping
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
Adds result codes and PlatformWalletError cases for stale, consumed, and mismatched reservation tokens.
Funding-domain isolation guardrail and test fixtures
packages/rs-platform-wallet/src/wallet/funding_privacy.rs, packages/rs-platform-wallet/src/test_support.rs
Adds a static source-scan test and behavioral tests enforcing single-account funding isolation, plus CoinJoin/split-account/DashPay wallet test fixtures.

Dependency Revision Bump

Layer / File(s) Summary
Workspace dependency revision update
Cargo.toml
Points eight rust-dashcore workspace dependencies to bfoss765/rust-dashcore at a new revision.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant KotlinSDK as Kotlin/Swift SDK
  participant FFI as Rust FFI
  participant Registry as SignedPaymentRegistry
  participant Network

  Client->>KotlinSDK: buildSignedPaymentWithToken(recipients)
  KotlinSDK->>FFI: core_wallet_build_signed_payment_with_token
  FFI->>Registry: register(signed tx, reservation)
  Registry-->>FFI: token
  FFI-->>KotlinSDK: token, txid, fee, change
  KotlinSDK-->>Client: SignedCoreTransaction

  Client->>KotlinSDK: broadcastSigned(transaction)
  KotlinSDK->>FFI: core_wallet_signed_payment_broadcast(token)
  FFI->>Registry: broadcast(token)
  Registry->>Network: send transaction bytes
  Network-->>Registry: accepted or rejected
  Registry-->>FFI: Txid or SignedPaymentError
  FFI-->>Client: txid or typed error
Loading

Possibly related issues

Possibly related PRs

  • dashpay/platform#4247: Both PRs modify the same single-account build_signed_payment Rust/FFI/JNI/Kotlin APIs; this PR extends them with reservation-token and deferred-finalization workflows.

Suggested reviewers: quantumexplorer, lklimek, llbartekll, shumkov

🚥 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 identifies the main change: tokenized finalization from a funding path for spendable DashPay receival accounts.
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 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 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.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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 single-account funding and owner-guarded reservation plumbing is generally coherent, but the fee cap relies on the 100,000-byte standard transaction limit without enforcing that limit. Oversized recipient lists can therefore overflow key-wallet's fee arithmetic, so this PR requires changes; several additional lifecycle, concurrency, FFI, test, and documentation issues remain as non-blocking follow-ups.

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)

🔴 1 blocking | 🟡 5 suggestion(s) | 💬 1 nitpick(s)

🤖 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:295-306: Fee cap does not prevent overflow for oversized recipient lists
  `MAX_FEE_PER_KB` is safe only when the transaction is at most 100,000 bytes, but this method never enforces that size limit. The pinned key-wallet builder includes roughly `outputs.len() * 34` in its base size and then evaluates `sat_per_kb * size_bytes` with unchecked `u64` multiplication. At the accepted maximum rate, about 25,835 outputs produce an estimated size around 878,434 bytes and overflow that multiplication. Such a recipient list fits in a practical JNI blob. Overflow-checking builds can panic inside the `extern "C"` path, while release builds wrap the fee and may return a signed, non-standard transaction with a fee unrelated to the requested rate. Reject transactions whose estimated size exceeds the standard limit before invoking key-wallet, and ensure the final selected-input size is also bounded or calculated with checked arithmetic.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/send.rs:308-311: Wallet-manager write lock remains held while awaiting the signer
  `wm` is a named `tokio::sync::RwLockWriteGuard` that remains live through the later `build_signed_reserved(...).await` at lines 509-511. The selected account borrow ends before signing, but the manager's exclusive lock does not. A slow hardware or keystore signer therefore blocks wallet synchronization and every other manager read or build; a signer that re-enters this wallet can deadlock. Follow `finalize_transaction`'s established pattern: assemble and reserve the unsigned transaction inside a scoped manager lock, drop the guard, then await signing while retaining the owner token needed to release the reservation on failure.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/send.rs:116-140: FinalizedCorePayment does not enforce its reservation lifecycle
  This type represents a linear obligation: exactly one registration or abandonment should take responsibility for its held reservation. However, it derives `Clone`, exposes all coupled bookkeeping fields publicly, and `abandon_payment` only borrows it. Safe callers can abandon one clone and register another, register the same payment more than once, or pair the transaction with an unrelated funding path or owner token. That can expose a registered payment after its inputs have already become selectable or mint multiple registry tokens capable of broadcasting the same transaction. Make the finalized payment opaque and non-`Clone`, and expose consuming registration and abandonment operations that accept the payment as one inseparable value.

In `packages/rs-platform-wallet-ffi/src/wallet.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/wallet.rs:395-427: Wallet cleanup is not atomic with deferred-payment registration
  Handle removal, the final-alias check, and the registry sweep use separate synchronization boundaries. A concurrent finalize can clone the wallet before destruction, then insert its registry entry after `release_entries_for_wallet` has collected matching entries, leaving a token and reservation behind after the final alias is gone. The opposite ordering can let cleanup remove a newly registered entry while the finalize call is still returning its token, so the caller receives an already-stale token. `platform_wallet_manager_remove_wallet` has the analogous remove-then-sweep window. Coordinate generation closing state, registration, alias counts, and cleanup under one lifecycle boundary, or make registration atomically reject a generation that has begun closing.

In `packages/rs-platform-wallet-ffi/src/core_wallet/send.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/send.rs:246-254: Initialize all owned C out-parameters before fallible work
  Only `out_token` receives a sentinel value before parsing and wallet operations. Failures from funding-path parsing, handle lookup, recipient decoding, or signing leave `out_txid`, `out_tx_bytes`, `out_tx_len`, `out_fee`, and `out_change` unchanged. The JNI caller happens to pass zero-initialized locals, but this `extern "C"` API is independently public; a generated C or Swift caller that performs cleanup on an error can observe a stale pointer/length pair and free or consume old output state. After validating all out-pointers, initialize the owned pointers to null and all scalar outputs to zero before any fallible operation.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt:293-310: Add coverage for the change-bearing registration blob
  The new funding-path API uses a distinct wire layout with `changeDuffs` inserted before the txid, and decoding it with the existing registration decoder silently mis-frames every subsequent field. `SignedCoreTransactionTest` exercises only `fromRegisterBlob`; it never calls `fromPaymentRegisterBlob` or verifies `changeDuffs`. Add a focused decoder test using the JNI packing order and assert the token, fee, change, txid, and raw transaction bytes.

Comment on lines +295 to +306
// Bound the caller-supplied fee rate for the same reason: key-wallet's
// `FeeRate::calculate_fee` computes `sat_per_kb * size_bytes` with
// unchecked `u64` multiplication, so a rate near `u64::MAX` (the public
// Kotlin/FFI APIs accept any non-negative `Long`) panics in an
// overflow-checking Android build, or wraps in release — turning an
// astronomical requested rate into a tiny fee.
let fee_per_kb = fee_per_kb.unwrap_or(DEFAULT_FEE_PER_KB);
if fee_per_kb > MAX_FEE_PER_KB {
return Err(PlatformWalletError::TransactionBuild(format!(
"fee rate {fee_per_kb} duffs/kB exceeds the maximum {MAX_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: Fee cap does not prevent overflow for oversized recipient lists

MAX_FEE_PER_KB is safe only when the transaction is at most 100,000 bytes, but this method never enforces that size limit. The pinned key-wallet builder includes roughly outputs.len() * 34 in its base size and then evaluates sat_per_kb * size_bytes with unchecked u64 multiplication. At the accepted maximum rate, about 25,835 outputs produce an estimated size around 878,434 bytes and overflow that multiplication. Such a recipient list fits in a practical JNI blob. Overflow-checking builds can panic inside the extern "C" path, while release builds wrap the fee and may return a signed, non-standard transaction with a fee unrelated to the requested rate. Reject transactions whose estimated size exceeds the standard limit before invoking key-wallet, and ensure the final selected-input size is also bounded or calculated with checked arithmetic.

source: ['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 34c0894. The finding was exactly right — MAX_FEE_PER_KB = MAX_MONEY / 100 was sound only under a 100 kB assumption the method never enforced, and ~25.8k recipients does fit in a practical JNI blob.

Three parts, in wallet/core/send.rs:

  1. The size limit is now enforced at build time. MAX_STANDARD_TX_SIZE is derived from dashcore::policy::MAX_STANDARD_TX_WEIGHT / 4 rather than hard-coded. The recipient count is bounded against it with checked arithmetic mirroring key-wallet's own base-size formula — 8 + 1 + varint + outputs*34 + 34, taking the varint at its 9-byte maximum so the estimate never comes in under key-wallet's — and requiring room for at least one 148-byte input. This runs before the wallet lock and before anything is reserved.

  2. MAX_FEE_PER_KB is re-derived as u64::MAX / u32::MAX. This is the part that does not depend on the input count, and I think it's needed independently of (1): the size limit bounds the output side, but a funding account holding a few thousand small denominations reaches ~878 kB through the input side with a perfectly ordinary recipient list. With sat_per_kb ≤ u64::MAX / u32::MAX, sat_per_kb * size_bytes is representable for any size a u32 can express, which no transaction can exceed. ~43 DASH/kB is three orders of magnitude above the 1_000 default, so nothing legitimate is rejected.

  3. The final selected-input size is checked, per the last sentence of the finding: the signed transaction is re-measured and refused if over the limit. That refusal discharges the reservation it had already taken through abandon_payment, rather than stranding the account's coins until the TTL backstop.

Tests:

  • an_oversized_finalize_is_refused_and_reserves_nothing — the 25,835-recipient case at the maximum accepted rate, on the finalize path, plus the assertion that an ordinary payment still funds immediately afterwards;
  • an_over_limit_signed_transaction_is_refused_and_releases_its_reservation — 900 recipients funded by 500 small UTXOs, which passes the output-side bound and then pulls ~496 inputs to land just over 100 kB. This is the only test that exercises the post-build check, and it doubles as the proof that the manager guard must be released before abandon_payment (tokio's RwLock is not reentrant, so it would deadlock rather than fail);
  • max_fee_rate_cannot_overflow_key_wallets_fee_product — asserts no overflow at 100 kB, at the 878_434 figure from this finding, and at u32::MAX. It fails if a cleanup restores MAX_MONEY / 100.

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 34c0894Fee cap does not prevent overflow for oversized recipient lists 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 +308 to +311
let mut wm = self.wallet_manager.write().await;
let (wallet, info) = wm
.get_wallet_and_info_mut(&self.wallet_id)
.ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?;

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: Wallet-manager write lock remains held while awaiting the signer

wm is a named tokio::sync::RwLockWriteGuard that remains live through the later build_signed_reserved(...).await at lines 509-511. The selected account borrow ends before signing, but the manager's exclusive lock does not. A slow hardware or keystore signer therefore blocks wallet synchronization and every other manager read or build; a signer that re-enters this wallet can deadlock. Follow finalize_transaction's established pattern: assemble and reserve the unsigned transaction inside a scoped manager lock, drop the guard, then await signing while retaining the owner token needed to release the reservation on failure.

source: ['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.

Deferring this one, and I want to be explicit about why rather than just deferring it — I attempted the change and backed it out.

The obvious form of the fix (drop wm once the builder is constructed, as the sibling CodeRabbit comment proposes) is not safe here, and would introduce a double-spend rather than a latency win. set_funding only snapshots the account's UTXOs and clones the Arc<ReservationSet>; both coin selection and the reserve() call happen inside build_signed_reserved (key-wallet's assemble_unsigned, where reservations.reserve(&outpoints, height) runs after selection). So dropping the guard before that call lets two concurrent finalizes each snapshot the same unreserved UTXO under their own lock hold, then both select and reserve it — two signed transactions spending one coin. It compiles and the single-threaded tests pass, which is exactly why I'm flagging it.

Your own wording is the correct shape — "assemble and reserve the unsigned transaction inside a scoped manager lock, drop the guard, then await signing" — and that's what finalize_transaction does. It isn't reachable from here: key-wallet's assemble_unsigned is private to its module, and there is no build_unsigned_reserved counterpart to build_signed_reserved. Splitting reserve-from-sign on this path needs a key-wallet API addition, which I don't think belongs in this PR.

What did change in 34c0894: the guard is now released immediately after the build rather than at end of scope. That doesn't shorten the signing hold, but it's what makes the new over-limit refusal able to call abandon_payment at all — that re-acquires the manager lock, so under the guard it would deadlock.

Happy to open the key-wallet-side issue for the build_unsigned_reserved split if you want this tracked rather than deferred.

Comment on lines +116 to +140
#[derive(Debug, Clone)]
pub struct FinalizedCorePayment {
/// The signed transaction.
pub transaction: Transaction,
/// The fee paid, in duffs — derived from the transaction itself
/// (`inputs − outputs`), the same ground truth [`SignedCorePayment::fee`]
/// uses.
pub fee: u64,
/// Duffs returned to the wallet's BIP44 change address (0 when the build
/// produced no change output).
pub change_amount: u64,
/// The ONE account the inputs were selected from and reserved in, as its
/// RESOLVED account-level derivation path — never the caller's `None`. A
/// later release must name this account, not the default BIP44 one.
pub funding: FundingAccountRef,
/// The wallet's `last_processed_height` captured in the funding critical
/// section, i.e. the exact clock `set_current_height` stamped the
/// reservation with. The registry's age guard must baseline off this — see
/// [`SignedCoreTransaction::reservation_height`](crate::SignedCoreTransaction::reservation_height).
pub reservation_height: u32,
/// The key-wallet [`ReservationToken`](key_wallet::ReservationToken) stamped
/// onto the selected inputs, so a later release is *owner-guarded* and frees
/// only inputs this build still owns (`dashpay/platform#4185`). `None` only
/// if the build reserved nothing, which the funded path never does.
pub reservation_token: Option<KeyWalletReservationToken>,

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: FinalizedCorePayment does not enforce its reservation lifecycle

This type represents a linear obligation: exactly one registration or abandonment should take responsibility for its held reservation. However, it derives Clone, exposes all coupled bookkeeping fields publicly, and abandon_payment only borrows it. Safe callers can abandon one clone and register another, register the same payment more than once, or pair the transaction with an unrelated funding path or owner token. That can expose a registered payment after its inputs have already become selectable or mint multiple registry tokens capable of broadcasting the same transaction. Make the finalized payment opaque and non-Clone, and expose consuming registration and abandonment operations that accept the payment as one inseparable value.

source: ['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.

Half done in 34c0894, half deferred with a reason.

Done: FinalizedCorePayment no longer derives Clone, and abandon_payment now takes it by value. Those two together close the cases you listed that are expressible today — abandoning one copy and registering another, abandoning twice, or minting two registry tokens able to broadcast the same transaction, all now fail to compile rather than being caught in review. Only two call sites existed (the FFI's CString::new failure arm and the new over-limit refusal), so the change was contained.

Deferred: making the type opaque with consuming registration. The blocker is that register_funded_by is #4185's API, not this PR's, and it takes the pieces separately:

register_funded_by(core, tx, funding, registered_height, funding_reservation_token)

That signature is precisely what allows pairing a transaction with an unrelated funding path or owner token — so the real fix is folding those four parameters into one consuming register(payment). Doing that from this branch would fork a sibling PR's public surface while both are in review, and privatising the fields without it would only add accessors and close nothing (the FFI would still hand the four pieces over individually).

I've documented the reasoning on the type itself so the pub fields don't read as an oversight. If you'd rather it land as one change, the natural home is #4185 — happy to take it there.

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 34c0894FinalizedCorePayment does not enforce its reservation lifecycle 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 +395 to +427
let Some(wallet) = PLATFORM_WALLET_STORAGE.remove(handle) else {
return PlatformWalletFFIResult::ok();
};

// `platform_wallet_manager_get_wallet` hands out an independent handle for
// each alias of the same wallet *generation* (they share the underlying
// `WalletManager` `Arc`, `wallet_id`, and the per-generation balance `Arc`).
// A deferred-payment token minted through one alias must NOT be invalidated
// when a *sibling* alias of the same generation is destroyed — the token is
// still live and broadcastable through the survivor.
//
// So only reconcile when THIS is the final live alias of the generation: no
// other stored handle is the same generation
// (`CoreWallet::is_same_generation`). While a sibling is live, the
// destructor just drops this handle.
//
// Once the last alias goes, RELEASE (not merely drop) each of this
// generation's deferred-payment reservations: destroying the last wrapper
// handle does NOT remove the logical wallet from its manager, so the wallet
// — and its accounts' still-live `ReservationSet`s — remain, and the same
// wallet can be handed out again. Dropping the tokens without releasing
// would leave those inputs reserved until key-wallet's TTL. Releasing here
// also frees the registry's `CoreWallet` pin on the shared `WalletManager`.
// (Actual generation teardown — `remove_wallet` — instead drops the tokens,
// since the reservation ceases to exist with the generation.)
let core = wallet.core();
let sibling_alias_alive =
PLATFORM_WALLET_STORAGE.any(|other| other.core().is_same_generation(core));
if !sibling_alias_alive {
runtime().block_on(
crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY
.release_entries_for_wallet(core),
);

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: Wallet cleanup is not atomic with deferred-payment registration

Handle removal, the final-alias check, and the registry sweep use separate synchronization boundaries. A concurrent finalize can clone the wallet before destruction, then insert its registry entry after release_entries_for_wallet has collected matching entries, leaving a token and reservation behind after the final alias is gone. The opposite ordering can let cleanup remove a newly registered entry while the finalize call is still returning its token, so the caller receives an already-stale token. platform_wallet_manager_remove_wallet has the analogous remove-then-sweep window. Coordinate generation closing state, registration, alias counts, and cleanup under one lifecycle boundary, or make registration atomically reject a generation that has begun closing.

source: ['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.

Not changed — this one I don't think I can fix correctly from this PR. The race described is between platform_wallet_destroy's alias/generation bookkeeping and the registry sweep, both of which are #4185's lifecycle machinery; closing it properly means coordinating generation-closing state, registration, alias counts and cleanup under one boundary, which is a change to that PR's design rather than an addition to this one. This PR only adds a new producer of registry entries, so it widens an existing window rather than opening it. Flagging rather than silently deferring — if you'd like it tracked, #4185 is the right home.

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 — Wallet cleanup is not atomic with deferred-payment registration 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 +246 to +254
check_ptr!(outputs_blob);
check_ptr!(core_signer_handle);
check_ptr!(out_token);
check_ptr!(out_txid);
check_ptr!(out_tx_bytes);
check_ptr!(out_tx_len);
check_ptr!(out_fee);
check_ptr!(out_change);
*out_token = 0;

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: Initialize all owned C out-parameters before fallible work

Only out_token receives a sentinel value before parsing and wallet operations. Failures from funding-path parsing, handle lookup, recipient decoding, or signing leave out_txid, out_tx_bytes, out_tx_len, out_fee, and out_change unchanged. The JNI caller happens to pass zero-initialized locals, but this extern "C" API is independently public; a generated C or Swift caller that performs cleanup on an error can observe a stale pointer/length pair and free or consume old output state. After validating all out-pointers, initialize the owned pointers to null and all scalar outputs to zero before any fallible operation.

source: ['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.

Not changed in this round — agreed in principle, deferring on scope. The finding is accurate that only out_token gets a sentinel and that the in-repo JNI caller zero-initialises its locals, so there is no live defect. It's a real hardening gap for an independently-public extern "C" surface though. Grouping it with the sibling transaction_builder.rs / signed_payment.rs instances CodeRabbit raised, since they're the same one-line convention (core_wallet_broadcast_signed_transaction_v2 already does it) and are better fixed together than piecemeal across three PRs. Say the word if you'd rather it land here.

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 34c0894Initialize all owned C out-parameters before fallible work 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 +293 to +310
internal fun fromPaymentRegisterBlob(blob: ByteArray): SignedCoreTransaction {
val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default
val token = buffer.long
val feeDuffs = buffer.long
val changeDuffs = buffer.long
val txidLen = buffer.int
val txidBytes = ByteArray(txidLen)
buffer.get(txidBytes)
val txBytesLen = buffer.int
val rawTxBytes = ByteArray(txBytesLen)
buffer.get(rawTxBytes)
return SignedCoreTransaction(
txidHex = String(txidBytes, Charsets.UTF_8),
rawTxBytes = rawTxBytes,
feeDuffs = feeDuffs,
reservationToken = token,
changeDuffs = changeDuffs,
)

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: Add coverage for the change-bearing registration blob

The new funding-path API uses a distinct wire layout with changeDuffs inserted before the txid, and decoding it with the existing registration decoder silently mis-frames every subsequent field. SignedCoreTransactionTest exercises only fromRegisterBlob; it never calls fromPaymentRegisterBlob or verifies changeDuffs. Add a focused decoder test using the JNI packing order and assert the token, fee, change, txid, and raw transaction bytes.

source: ['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.

Not added in this round. Agreed the gap is real — SignedCoreTransactionTest only exercises fromRegisterBlob, and the payment blob's distinct layout (changeDuffs before the txid) would mis-frame every subsequent field if the wrong decoder were used. Deferring only because the Rust-side changes in this round were the funds-critical ones and I did not want to mix a Kotlin decoder test into the same commit. Noting it explicitly so it isn't lost: the test wants to assert token, fee, change, txid and raw bytes against the JNI packing order.

Comment on lines +74 to +80
/// A general Core L1 payment build (`CoreWallet::build_signed_payment`)
/// could not cover the requested outputs plus fee from the union of the
/// wallet's *signable* funds accounts (BIP44 + BIP32 + CoinJoin + DashPay
/// receiving; watch-only DashPay external accounts are excluded). `available`
/// is the total selectable value across those accounts, `required` the
/// outputs-plus-fee target — carried as exact duff amounts (instead of being
/// flattened into a string) so callers can render a precise shortfall.

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: Insufficient-funds error still documents union funding

The implementation deliberately confines selection to one caller-selected account, and map_send_builder_error reports that account's selectable value. This public error documentation still states that available is a union across all signable accounts, which gives downstream callers the opposite contract and may cause UI or retry logic to imply that privacy domains will be combined automatically.

Suggested change
/// A general Core L1 payment build (`CoreWallet::build_signed_payment`)
/// could not cover the requested outputs plus fee from the union of the
/// wallet's *signable* funds accounts (BIP44 + BIP32 + CoinJoin + DashPay
/// receiving; watch-only DashPay external accounts are excluded). `available`
/// is the total selectable value across those accounts, `required` the
/// outputs-plus-fee target — carried as exact duff amounts (instead of being
/// flattened into a string) so callers can render a precise shortfall.
/// A general Core L1 payment build (`CoreWallet::build_signed_payment`)
/// could not cover the requested outputs plus fee from the single funding
/// account selected by the caller. `available` is the total selectable value
/// in that account, and `required` is the outputs-plus-fee target — carried
/// as exact duff amounts so callers can render a precise shortfall without
/// implying that another funding domain will be used automatically.

source: ['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.

Same item as the CodeRabbit thread on error.rs — fixed in 34c0894. The variant doc now states single-account available/required and drops the union language.

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 34c0894Insufficient-funds error still documents union funding 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.

@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: 4

🧹 Nitpick comments (8)
packages/rs-platform-wallet/src/test_support.rs (1)

265-334: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated wallet-assembly tail into a shared helper.

funded_coinjoin_wallet_manager, split_funded_wallet_manager, and split_funded_wallet_manager_dashpay each end with the same sequence: build WalletSigner, wrap WalletBalance, assemble PlatformWalletInfo, create WalletManager::<PlatformWalletInfo>::new, and insert_wallet. This mirrors the pre-existing tail in funded_wallet_manager_with_outputs (lines 247-263). As this fixture module keeps growing (four near-identical tails now), extracting a small private helper that takes the funded TestWalletContext and returns the assembled manager/wallet-id/balance/signer tuple would reduce duplication and keep future fixtures consistent.

♻️ Proposed helper extraction
fn finalize_test_wallet(
    ctx: TestWalletContext,
) -> (
    Arc<RwLock<WalletManager<PlatformWalletInfo>>>,
    WalletId,
    Arc<WalletBalance>,
    WalletSigner,
) {
    let signer = WalletSigner {
        wallet: ctx.wallet.clone(),
    };
    let balance = Arc::new(WalletBalance::new());
    let info = PlatformWalletInfo {
        core_wallet: ctx.managed_wallet,
        balance: Arc::clone(&balance),
        identity_manager: IdentityManager::new(),
        tracked_asset_locks: BTreeMap::new(),
    };
    let mut wm = WalletManager::<PlatformWalletInfo>::new(Network::Testnet);
    let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet");
    (Arc::new(RwLock::new(wm)), wallet_id, balance, signer)
}

Callers that only need a 3-tuple (split_funded_wallet_manager, split_funded_wallet_manager_dashpay) can drop the balance element.

Also applies to: 336-422, 467-642

🤖 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/test_support.rs` around lines 265 - 334,
Extract the repeated wallet-assembly logic from the tail of
funded_coinjoin_wallet_manager, split_funded_wallet_manager, and
split_funded_wallet_manager_dashpay (which all build WalletSigner, wrap
WalletBalance, assemble PlatformWalletInfo, create WalletManager::new, and call
insert_wallet) into a private helper function that accepts a TestWalletContext
and returns the tuple (Arc<RwLock<WalletManager<PlatformWalletInfo>>>, WalletId,
Arc<WalletBalance>, WalletSigner). Replace the duplicate code in each of these
functions with a single call to this helper, and apply the same refactoring to
the existing tail in funded_wallet_manager_with_outputs to keep all fixtures
consistent.
packages/rs-platform-wallet/src/wallet/core/send.rs (2)

211-226: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that build_signed_payment intentionally drops the reservation bookkeeping.

This method discards funding, reservation_height, and reservation_token from the FinalizedCorePayment. The FinalizedCorePayment doc (lines 108-112) states that dropping one without registering or abandoning it strands the reservation until key-wallet's TTL backstop. The module docs (lines 29-44) explain that the stranding is intentional here, because the host broadcasts and a later sync reconciles the spend. A reader arriving at this projection sees the drop without that context. Add one sentence so the two contracts do not read as contradictory.

📝 Proposed doc note
     /// * `funding_path` — the account-level derivation path of the SINGLE funds
     ///   account whose UTXOs fund the payment. `None` (the default) funds from
     ///   the unmixed BIP44 account (dashpay/platform#4184).
+    ///
+    /// ## Reservation bookkeeping is intentionally dropped
+    ///
+    /// This projection discards the resolved [`FundingAccountRef`], the
+    /// reservation height, and key-wallet's owner token. The selected inputs
+    /// STAY reserved, and no token exists to release them early — the
+    /// build-only contract in the module docs: the host broadcasts, and a sync
+    /// (or the reservation TTL) reconciles the reservation. Callers that may
+    /// abandon the payment must use
+    /// [`finalize_signed_payment_from_funding_path`](Self::finalize_signed_payment_from_funding_path)
+    /// and [`abandon_payment`](Self::abandon_payment) instead.
🤖 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` around lines 211 - 226,
Add a concise documentation sentence to build_signed_payment explaining that it
intentionally omits FinalizedCorePayment reservation bookkeeping because the
host broadcasts the transaction and a later sync reconciles the spend. Reference
the discarded funding, reservation_height, and reservation_token fields without
changing the payment-building logic.

1176-1294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for abandon_payment.

The tests cover the registry release path (registry.release(token)) and the rejected-broadcast release path. They do not cover abandon_payment, which the finalize_signed_payment_from_funding_path doc names as one of the two required ways to discharge the reservation. It is the path a host takes when marshalling fails between the build and a successful register_funded_by — see the abandon_payment call in packages/rs-platform-wallet-ffi/src/core_wallet/send.rs.

A test in the shape of receival_reservation_is_held_and_released_against_the_receival_account would cover it: build from the receival path on the wallet's own generation, confirm a second build is blocked, call core.abandon_payment(&payment), then confirm the rebuild succeeds. That also pins the by-path release for a non-Standard account without the registry in the loop.

Do you want me to generate that test?

🤖 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` around lines 1176 -
1294, Add a dedicated test alongside
receival_reservation_is_held_and_released_against_the_receival_account covering
CoreWallet::abandon_payment: build a receival-funded payment using the wallet’s
own generation, verify a second build is blocked by the reservation, call
core.abandon_payment with the original payment, and verify rebuilding from the
same receival path succeeds without involving SignedPaymentRegistry.
packages/rs-platform-wallet-ffi/src/core_wallet/send.rs (2)

246-254: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Initialize every out-parameter before the first fallible step.

core_wallet_build_signed_payment_with_token only zeroes *out_token. On every error return, *out_txid, *out_tx_bytes, *out_tx_len, *out_fee, and *out_change keep whatever the caller passed in. The in-repo JNI caller pre-initializes its locals, so it is safe today. Any other C caller can read uninitialized memory. core_wallet_broadcast_signed_transaction_v2 already nulls its out-pointer up front; follow that convention here.

🛡️ Proposed fix
     check_ptr!(out_change);
     *out_token = 0;
+    *out_txid = std::ptr::null_mut();
+    *out_tx_bytes = std::ptr::null_mut();
+    *out_tx_len = 0;
+    *out_fee = 0;
+    *out_change = 0;
🤖 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 246 -
254, Update core_wallet_build_signed_payment_with_token to initialize every
validated out-parameter immediately after the check_ptr! calls and before any
fallible operation, setting pointer outputs to null and scalar outputs to zero
as appropriate. Preserve the existing out_token initialization and follow the
initialization convention used by core_wallet_broadcast_signed_transaction_v2.

329-333: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that the token variant uses the same free function.

core_wallet_build_signed_payment_with_token also writes out_tx_bytes/out_tx_len, and the JNI layer frees them with core_wallet_free_payment_bytes. Name both producers in the doc comment so the ownership contract is unambiguous.

🤖 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 329 -
333, Update the safety documentation for the payment-byte free function
associated with core_wallet_free_payment_bytes to name both
core_wallet_build_signed_payment and core_wallet_build_signed_payment_with_token
as valid producers of the bytes/length pair, preserving the existing null/zero
ownership contract.
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt (1)

450-455: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two buildSignedPayment overloads return different types with different reservation semantics.

buildSignedPayment(recipients, network, coreSignerHandle, accountType, accountIndex) returns a SignedCoreTransaction that owns a reservation token. This overload returns a SignedCorePayment with no token. Overload resolution works, because the positional types differ. The hazard is at the call site: both names are identical, both move money, and only one produces a broadcastable, reservation-owning result.

The KDoc at Line 479-484 already has to explain which buildSignedPayment is which, and the KDoc at Line 546 and Line 595 refer to "[buildSignedPayment]" without saying which overload.

Rename the tokenless variant to state what it returns, for example buildSignedPaymentBytes. Then update the KDoc cross-references in broadcastSigned and releaseReservation to name one specific method.

🤖 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt`
around lines 450 - 455, Rename the tokenless `buildSignedPayment` overload to
`buildSignedPaymentBytes` while preserving its existing parameters and
`SignedCorePayment` result. Update all references and KDoc, including the
cross-references in `broadcastSigned` and `releaseReservation`, to distinguish
this method from the reservation-owning `buildSignedPayment` overload.
packages/rs-platform-wallet-ffi/src/wallet.rs (1)

471-508: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The baseline-delta assertions depend on a process-global registry and can flake.

SIGNED_PAYMENT_REGISTRY is process-global and cargo test runs tests in parallel threads inside one process. Another test in this crate that registers or releases a token between the baseline capture and each assert_eq! shifts outstanding() and fails this test for an unrelated reason. The comment already acknowledges the registry is shared.

Assert on the specific token instead of the global count, or serialize the registry-touching tests behind a shared Mutex.

♻️ Sketch: assert on the token, not the count
-            assert_eq!(SIGNED_PAYMENT_REGISTRY.outstanding(), baseline + 1);
-            (manager, handle_a, handle_b, baseline)
+            (manager, handle_a, handle_b, _token)
         });
 
         let result = unsafe { platform_wallet_destroy(handle_a) };
         assert_eq!(result.code, PlatformWalletFFIResultCode::Success);
-        assert_eq!(
-            SIGNED_PAYMENT_REGISTRY.outstanding(),
-            baseline + 1,
-            "a sibling alias's token must survive destroying another alias"
-        );
+        assert!(
+            SIGNED_PAYMENT_REGISTRY.contains(token),
+            "a sibling alias's token must survive destroying another alias"
+        );

This needs a token-presence predicate on SignedPaymentRegistry; add one if it does not exist.

🤖 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/wallet.rs` around lines 471 - 508,
Replace the process-global count assertions in the wallet alias-destruction test
with assertions targeting the specific token returned by
SIGNED_PAYMENT_REGISTRY.register. Add or reuse a SignedPaymentRegistry
token-presence predicate, then verify that token remains after destroying
handle_a and is absent after destroying handle_b; remove the
baseline/outstanding-based checks.
packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs (1)

183-191: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

The new deferred-payment entry points rely on the caller to pre-zero out-parameters. Both functions validate their out-pointers with check_ptr! but do not write a default value to all of them before the first fallible step. The current JNI caller zeroes its FFICoreTransaction box and its out_txid local, so no defect exists today. The guarantee belongs in the FFI, as the existing core_wallet_broadcast_signed_transaction_v2 already does with *out_txid = std::ptr::null_mut();.

  • packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs#L183-L191: write *out_txid = std::ptr::null_mut(); and zero *out_tx (null tx_bytes, zero tx_len/fee) next to the existing *out_token = 0;, so the error returns at Line 198 and Line 206 leave defined values.
  • packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs#L58-L63: add *out_txid = std::ptr::null_mut(); immediately after check_ptr!(out_txid);.
🤖 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/transaction_builder.rs`
around lines 183 - 191, Initialize every deferred-payment out-parameter before
fallible operations: in
packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs#L183-L191,
update the function containing the shown check_ptr! calls to null out_txid and
zero out_tx fields (tx_bytes, tx_len, and fee) alongside *out_token = 0; in
packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs#L58-L63,
update the corresponding function to null out_txid immediately after
check_ptr!(out_txid);.
🤖 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/src/error.rs`:
- Around line 74-86: Update the PaymentInsufficientFunds doc comment to state
that available is the selected account’s spendable total, and that required is
the outputs-plus-fee target for that account. Remove references to the union of
signable funding accounts and total selectable value across domains; preserve
the exact-duff and recovery semantics.

In `@packages/rs-platform-wallet/src/wallet/core/send.rs`:
- Around line 522-535: The comment describing the fee safety behavior around the
selected_input_value calculation is inverted. Update the comment to correctly
state that if an input somehow cannot be priced and defaults to 0, this lowers
the selected_input_value sum, which in turn makes the fee calculated via
saturating_sub smaller and therefore UNDER-reported (not over-reported). Keep
the explanation that the scenario is impossible because every spendable UTXO is
priced beforehand, but fix the direction of the safety claim to match the actual
mathematical consequence.
- Around line 406-421: Update the funding-account resolution in the send flow to
fail closed when no wallet-level account matching funding_path is found. Remove
the unwrap_or(&bip44_acc) fallback from the funding_wallet_acc lookup, and
propagate an appropriate error before calling set_funding so a different
account’s BIP44 xpub cannot be used.

In `@packages/rs-unified-sdk-jni/src/wallet_manager.rs`:
- Around line 1622-1629: Update the KDoc for
core_wallet_signed_payment_broadcast to document ErrorStaleReservationToken as
27, ErrorReservationTokenConsumed as 28, and ErrorReservationWalletMismatch as
29, matching the assignments in error.rs.

---

Nitpick comments:
In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt`:
- Around line 450-455: Rename the tokenless `buildSignedPayment` overload to
`buildSignedPaymentBytes` while preserving its existing parameters and
`SignedCorePayment` result. Update all references and KDoc, including the
cross-references in `broadcastSigned` and `releaseReservation`, to distinguish
this method from the reservation-owning `buildSignedPayment` overload.

In `@packages/rs-platform-wallet-ffi/src/core_wallet/send.rs`:
- Around line 246-254: Update core_wallet_build_signed_payment_with_token to
initialize every validated out-parameter immediately after the check_ptr! calls
and before any fallible operation, setting pointer outputs to null and scalar
outputs to zero as appropriate. Preserve the existing out_token initialization
and follow the initialization convention used by
core_wallet_broadcast_signed_transaction_v2.
- Around line 329-333: Update the safety documentation for the payment-byte free
function associated with core_wallet_free_payment_bytes to name both
core_wallet_build_signed_payment and core_wallet_build_signed_payment_with_token
as valid producers of the bytes/length pair, preserving the existing null/zero
ownership contract.

In `@packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs`:
- Around line 183-191: Initialize every deferred-payment out-parameter before
fallible operations: in
packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs#L183-L191,
update the function containing the shown check_ptr! calls to null out_txid and
zero out_tx fields (tx_bytes, tx_len, and fee) alongside *out_token = 0; in
packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs#L58-L63,
update the corresponding function to null out_txid immediately after
check_ptr!(out_txid);.

In `@packages/rs-platform-wallet-ffi/src/wallet.rs`:
- Around line 471-508: Replace the process-global count assertions in the wallet
alias-destruction test with assertions targeting the specific token returned by
SIGNED_PAYMENT_REGISTRY.register. Add or reuse a SignedPaymentRegistry
token-presence predicate, then verify that token remains after destroying
handle_a and is absent after destroying handle_b; remove the
baseline/outstanding-based checks.

In `@packages/rs-platform-wallet/src/test_support.rs`:
- Around line 265-334: Extract the repeated wallet-assembly logic from the tail
of funded_coinjoin_wallet_manager, split_funded_wallet_manager, and
split_funded_wallet_manager_dashpay (which all build WalletSigner, wrap
WalletBalance, assemble PlatformWalletInfo, create WalletManager::new, and call
insert_wallet) into a private helper function that accepts a TestWalletContext
and returns the tuple (Arc<RwLock<WalletManager<PlatformWalletInfo>>>, WalletId,
Arc<WalletBalance>, WalletSigner). Replace the duplicate code in each of these
functions with a single call to this helper, and apply the same refactoring to
the existing tail in funded_wallet_manager_with_outputs to keep all fixtures
consistent.

In `@packages/rs-platform-wallet/src/wallet/core/send.rs`:
- Around line 211-226: Add a concise documentation sentence to
build_signed_payment explaining that it intentionally omits FinalizedCorePayment
reservation bookkeeping because the host broadcasts the transaction and a later
sync reconciles the spend. Reference the discarded funding, reservation_height,
and reservation_token fields without changing the payment-building logic.
- Around line 1176-1294: Add a dedicated test alongside
receival_reservation_is_held_and_released_against_the_receival_account covering
CoreWallet::abandon_payment: build a receival-funded payment using the wallet’s
own generation, verify a second build is blocked by the reservation, call
core.abandon_payment with the original payment, and verify rebuilding from the
same receival path succeeds without involving SignedPaymentRegistry.
🪄 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: 9903f29f-5993-444c-9324-0009848c7b5a

📥 Commits

Reviewing files that changed from the base of the PR and between ed4116b and 727db77.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (32)
  • Cargo.toml
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.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/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt
  • packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/mod.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/send.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs
  • packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/handle.rs
  • packages/rs-platform-wallet-ffi/src/manager.rs
  • packages/rs-platform-wallet-ffi/src/utils.rs
  • packages/rs-platform-wallet-ffi/src/wallet.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/test_support.rs
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs
  • packages/rs-platform-wallet/src/wallet/core/mod.rs
  • packages/rs-platform-wallet/src/wallet/core/send.rs
  • packages/rs-platform-wallet/src/wallet/core/transaction.rs
  • packages/rs-platform-wallet/src/wallet/core/wallet.rs
  • packages/rs-platform-wallet/src/wallet/funding_privacy.rs
  • packages/rs-platform-wallet/src/wallet/mod.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs
  • packages/rs-unified-sdk-jni/src/funding.rs
  • packages/rs-unified-sdk-jni/src/wallet_manager.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift

Comment thread packages/rs-platform-wallet/src/error.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/send.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/core/send.rs
Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs
@bfoss765

Copy link
Copy Markdown
Contributor Author

Re the AccountBalanceEntryFFI stride concern (field appended for derivationPath, inherited here from #4247's branch): the only shipping consumer of this struct today is the Android JNI, which marshals it to JSON inside the same Rust build — producer and consumer always share one struct definition, so there is no cross-version stride exposure on Android. The iOS/Swift side does consume the C ABI directly, so any Swift binding regeneration must happen against the updated header before adopting these revs — called out in #4255 (the iOS adoption issue). If versioned structs are preferred as policy we're happy to follow up, but for this stack the append-only change plus the #4255 regeneration note covers the practical risk. 🤖 Generated with Claude Code

bfoss765 and others added 2 commits July 31, 2026 19:12
… finalized payment linear

Addresses the review findings on dashpay#4256.

Fee-cap overflow (BLOCKING). `MAX_FEE_PER_KB = MAX_MONEY / 100` was only safe if
the transaction stayed under Dash's 100_000-byte standard limit, which this
method never enforced. key-wallet's `FeeRate::calculate_fee` then evaluates
`sat_per_kb * size_bytes` with unchecked `u64` multiplication, overflowing at
~878 kB — reachable by a ~25.8k-recipient list that fits in a practical JNI
blob. Three-part fix:

* the recipient count is bounded at build time against `MAX_STANDARD_TX_SIZE`
  (derived from `dashcore::policy::MAX_STANDARD_TX_WEIGHT / 4`) using checked
  arithmetic that mirrors key-wallet's own base-size formula, before the wallet
  lock and before anything is reserved;
* `MAX_FEE_PER_KB` is re-derived as `u64::MAX / u32::MAX`, making the product
  representable for ANY size a `u32` can express. The previous bound depended on
  a size limit that was never enforced; this one does not depend on the input
  count at all, which also closes the CoinJoin-account case (a few thousand
  small denominations reach ~878 kB with no oversized recipient list);
* the signed transaction is re-measured and refused if it exceeds the limit,
  since the input count is unknowable until coin selection has run. That refusal
  discharges the reservation it had already taken via `abandon_payment` rather
  than stranding the account's coins until the TTL backstop.

`FinalizedCorePayment` linearity (partial). The type no longer derives `Clone`
and `abandon_payment` now consumes it, so abandoning-then-registering, or
minting two registry tokens able to broadcast the same transaction, no longer
compiles. The remaining half — folding the four separate `register_funded_by`
parameters into one consuming `register(payment)` — belongs on dashpay#4185, which owns
that API; doing it here would fork a sibling PR's surface.

Wallet-manager write lock: NOT dropped before signing (deferred, see the PR
thread). `set_funding` only snapshots UTXOs and clones the `Arc<ReservationSet>`
— selection and `reserve()` both run inside `build_signed_reserved`, so dropping
the guard first would let two concurrent finalizes reserve the same outpoint and
produce two signed transactions spending one coin. The narrower "reserve under
the lock, sign outside" split needs a key-wallet API that does not exist
(`assemble_unsigned` is private; there is no `build_unsigned_reserved`). The
guard IS now released immediately after the build, before the release paths,
which is what makes the in-function `abandon_payment` non-deadlocking.

Docs: corrects the JNI `core_wallet_signed_payment_broadcast` result codes
(27/28/29, was 26/27/28 — the Kotlin mirror was already right) and an inverted
fee-direction comment.

Tests: 537 passed (528 baseline + 9), platform-wallet-ffi 223 passed, Kotlin
`:sdk:testDebugUnitTest` green, `cargo check -p platform-wallet-ffi
-p rs-unified-sdk-jni` clean, `cargo fmt --check` clean. New coverage for the
over-limit refusal on both the output side and the post-build side (the latter
also pinning that the refusal releases its reservation), `abandon_payment`
without the registry, dust rejection, MAX_MONEY aggregation and the fee-rate
bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s the funding path

`build_signed_payment` / `finalize_signed_payment_from_funding_path` resolve the
funding account twice: once in `wallet.all_accounts()` for the xpub `set_funding`
derives change from, and once in the managed-account collection for the UTXOs and
the reservation ledger. The first lookup fell back to the BIP44 account when it
found no match, while the second can still resolve a CoinJoin or DashPay receival
account — so a disagreement between them silently handed `set_funding` another
account's xpub and recorded a change entry derived from it into the funding
account's address pool.

That is the same silent-fallback shape dashpay#4184 removed from the selector itself.
Refusing is the only safe answer: the two lookups disagreeing is a wallet-state
bug, not something to paper over with BIP44.

Verified not to narrow any real path before changing it: `all_accounts()` does
enumerate CoinJoin and DashPay receiving-funds accounts, so the whole send suite
— including the receival and explicit-CoinJoin tests — passes with the fallback
removed. It was dead code on every exercised path.

Raised by shumkov (dashpay#4247) and CodeRabbit (dashpay#4256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 31, 2026
…s the funding path

`build_signed_payment` / `finalize_signed_payment_from_funding_path` resolve the
funding account twice: once in `wallet.all_accounts()` for the xpub `set_funding`
derives change from, and once in the managed-account collection for the UTXOs and
the reservation ledger. The first lookup fell back to the BIP44 account when it
found no match, while the second can still resolve a CoinJoin or DashPay receival
account — so a disagreement between them silently handed `set_funding` another
account's xpub and recorded a change entry derived from it into the funding
account's address pool.

That is the same silent-fallback shape dashpay#4184 removed from the selector itself.
Refusing is the only safe answer: the two lookups disagreeing is a wallet-state
bug, not something to paper over with BIP44.

Verified not to narrow any real path before changing it: `all_accounts()` does
enumerate CoinJoin and DashPay receiving-funds accounts, so the whole send suite
— including the receival and explicit-CoinJoin tests — passes with the fallback
removed. It was dead code on every exercised path.

Raised by shumkov (dashpay#4247) and CodeRabbit (dashpay#4256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Contributor Author

Pushed 1a7a4b60c6.

The blocker — fee cap vs. unenforced size limit — is fixed. The finding was right that MAX_FEE_PER_KB was only sound under a 100 kB assumption the method never enforced. Three parts:

  1. MAX_STANDARD_TX_SIZE is now enforced at build time, derived from dashcore::policy::MAX_STANDARD_TX_WEIGHT / 4 rather than hard-coded. The recipient count is bounded against it with checked arithmetic mirroring key-wallet's own base-size formula, before the wallet lock and before anything is reserved.
  2. MAX_FEE_PER_KB is re-derived as u64::MAX / u32::MAX, so sat_per_kb * size_bytes is representable for any size a u32 can express. This is deliberately independent of the input count — the size limit bounds the output side, but a funding account holding a few thousand small denominations reaches the ~878 kB overflow point through the input side with an entirely ordinary recipient list. ~43 DASH/kB is three orders of magnitude above the 1_000 default.
  3. The signed transaction is re-measured against the limit, since the input count isn't knowable until selection has run — and that refusal releases the reservation it had already taken instead of stranding the coins until the TTL backstop.

One thing I want to flag rather than bury. I implemented the write-lock suggestion, then backed it out. Dropping wm once the builder is constructed is not safe here: set_funding only snapshots UTXOs and clones the Arc<ReservationSet> — selection and reserve() both happen inside build_signed_reserved. Dropping the guard first lets two concurrent finalizes reserve the same outpoint and hand back two signed transactions spending one coin. It compiles and the single-threaded tests pass, which is exactly why it's worth saying out loud. The correct split (reserve under the lock, sign outside, as finalize_transaction does) needs a key-wallet API that doesn't exist — assemble_unsigned is private and there's no build_unsigned_reserved. Detail on the thread. The guard is now released right after the build, which is what makes the new over-limit refusal able to call abandon_payment without deadlocking.

FinalizedCorePayment linearity — half done. No longer derives Clone, and abandon_payment consumes it, so abandon-then-register and double-abandon no longer compile. The other half — folding register_funded_by's four separate parameters into one consuming register(payment), which is what actually prevents pairing a transaction with an unrelated funding path or token — is #4185's API, and forking a sibling PR's surface mid-review seemed worse than deferring. Reasoning documented on the type.

Also fixed: the fail-closed lookup (verified empirically first — all_accounts() does enumerate CoinJoin and receival accounts, so the fallback was dead code and removing it breaks nothing, including the verified receival spend); the JNI result-code off-by-one (26/27/28 → 27/28/29; the Kotlin mirror was already right); the inverted fee-direction comment; and the PaymentInsufficientFunds doc, which still described pre-#4184 union semantics.

Deferred with reasons on their threads: FFI out-parameter initialisation (grouped with the two sibling instances — same one-line convention, better fixed together); the wallet-cleanup/registration atomicity window (#4185's lifecycle machinery — this PR widens it, doesn't open it); and the fromPaymentRegisterBlob decoder test.

Suites: cargo test -p platform-wallet --lib 537 passed / 0 failed (528 baseline + 9), -p platform-wallet-ffi --lib 223 passed, cargo check -p platform-wallet-ffi -p rs-unified-sdk-jni clean, Kotlin :sdk:testDebugUnitTest green, cargo fmt --check clean.

New tests: over-limit refusal on the output side (the 25,835-recipient case at max fee rate) and on the post-build side (900 recipients × 500 UTXOs → ~496 inputs, just over 100 kB — the only test that reaches the post-build check, and the one that would deadlock rather than fail if the guard weren't released before abandon_payment), abandon_payment without the registry, dust rejection, MAX_MONEY aggregation, and the fee-rate bound including a regression guard that fails if MAX_MONEY / 100 is ever restored.

🤖 Generated with Claude Code

@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 latest delta fixes the prior fee-overflow blocker and the stale insufficient-funds documentation. It introduces one new blocking error-contract issue: signer failures are now reported as request-invalid transaction builds; two carried-forward suggestions remain valid, while three broader lifecycle or C-ABI hardening items are explicitly deferred to their owning follow-up work.

Source: reviewers codex/general=gpt-5.6-sol(completed); codex/rust-quality=gpt-5.6-sol(completed); codex/ffi-engineer=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only).

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)

🔴 1 blocking

2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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/error.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/error.rs:407-408: Do not classify signing failures as request-invalid transaction builds
  `map_send_builder_error` converts every non-shortfall `BuilderError` into `PlatformWalletError::TransactionBuild`, including `BuilderError::SigningFailed`. The new blanket mapping here consequently returns native code 32, whose Rust and Kotlin contracts state that the request itself is invalid and that retrying it unchanged cannot succeed. That is false for the production `MnemonicResolverCoreSigner`: a missing Keychain mnemonic or a resolver callback failure becomes `SigningFailed`, and key-wallet releases the owner-stamped input reservation on this path. Restoring or unlocking the signer can therefore make the same recipients, amount, fee, and funding path succeed. Keep signing failures out of `ErrorTransactionBuild`; map them to a signer-specific platform error and FFI result code, such as the code-31 signing-key contract reserved by the sibling stack.

Note: GitHub does not allow PastaClaw to approve or request changes on their own PR, so the canonical verifier result is transported as a COMMENT review.

Comment on lines +407 to +408
PlatformWalletError::TransactionBuild(..) => {
PlatformWalletFFIResultCode::ErrorTransactionBuild

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: Do not classify signing failures as request-invalid transaction builds

map_send_builder_error converts every non-shortfall BuilderError into PlatformWalletError::TransactionBuild, including BuilderError::SigningFailed. The new blanket mapping here consequently returns native code 32, whose Rust and Kotlin contracts state that the request itself is invalid and that retrying it unchanged cannot succeed. That is false for the production MnemonicResolverCoreSigner: a missing Keychain mnemonic or a resolver callback failure becomes SigningFailed, and key-wallet releases the owner-stamped input reservation on this path. Restoring or unlocking the signer can therefore make the same recipients, amount, fee, and funding path succeed. Keep signing failures out of ErrorTransactionBuild; map them to a signer-specific platform error and FFI result code, such as the code-31 signing-key contract reserved by the sibling stack.

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