Skip to content

feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission#4185

Open
bfoss765 wants to merge 25 commits into
dashpay:v4.2-devfrom
bfoss765:port/v4.1/split-build-broadcast
Open

feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission#4185
bfoss765 wants to merge 25 commits into
dashpay:v4.2-devfrom
bfoss765:port/v4.1/split-build-broadcast

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Splits transaction build from broadcast for BIP70-style deferred submission: a signed-payment registry with reservation release, a deferred-payment token bounded to the reservation's lifetime, native code 26 for stale reservation tokens, token sweeping only when the final wallet write wins, and routing of deferred builds through the atomic finalize-and-register path — across rs-platform-wallet, platform-wallet-ffi, rs-unified-sdk-jni, and the Kotlin SDK surface.

Re-opens #4090 which was auto-closed when the #3999 base branch was deleted; rebased onto v4.1-dev. All seven original commits replayed cleanly — no hunks needed to be dropped as already-absorbed.

Verified: cargo test -p platform-wallet -p platform-wallet-ffi -p rs-unified-sdk-jni all pass (504 / 229 / 10); :sdk:assembleRelease + sdk unit tests pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added support for deferred signed payments, allowing transactions to be built and signed, then broadcast or released later.
    • Added payment details including transaction ID, raw transaction data, fees, and reservation tokens.
    • Added idempotent reservation release for abandoned payments.
  • Bug Fixes
    • Added clear, non-retryable handling for expired, invalid, already-used, or wallet-mismatched reservation tokens.
    • Improved cleanup when wallets are closed while deferred payments remain active.

Why the token registry instead of the V2 handle surface

The deferred BIP70/BIP270 flow uses the reservation-token registry rather than the V2 finalized-transaction handle for two concrete reasons. First, ownership and cleanup: the token is wrapped in an owning, AutoCloseable Kotlin object with a GC/Cleaner backstop, so a payment that is signed but then abandoned — the merchant server never acks, the user backs out, or the coroutine is cancelled after the native registration returned — always releases its funding reservation, for free, without the caller having to remember to abandon a handle. Second, the token path carries a lifetime bound the V2 handle does not: it stamps each token with the reservation's own pre-signing height and refuses to act once that reservation could have aged into key-wallet's TTL sweep, so a slow external signer can never let a stale token spend against an outpoint the wallet already swept and re-selected. A pinned V2 CoreWallet handle has no such age guard and would keep the old wallet actionable indefinitely. Both paths now share one wallet-generation identity and one teardown policy, so the V2 surface stays correct for the immediate send it was built for while the deferred flow gets the GC-safe, age-bounded ownership it needs. A follow-up adds the age guard to the V2 handle path itself (it becomes live the moment iOS does deferred sends).

Review-response summary (2026-07-21)

All five lifetime findings addressed as merge blockers, one commit each, with regression tests:

  1. Destroy vs teardown: final-alias platform_wallet_destroy releases the generation's reservations against the still-live wallet; actual generation removal drops tokens and V2 handles — token cleanup is now tied to wallet-generation removal.
  2. Height carry: the pre-signing reservation height travels on SignedCoreTransaction and register uses it — no post-signing resample; boundary test pins the TTL margin.
  3. One generation identity: CoreWallet::is_same_generation (per-generation identity) is checked by BOTH the V2-handle and registry-token paths, with one teardown policy.
  4. Cancellation-safe ownership: SignedCoreTransaction is an owning AutoCloseable with a NativeCleaner backstop; round-2 adds object-owning broadcastSigned/releaseReservation overloads that hold the object reachable across the native call and disarm the backstop on consumption (the bare-token forms remain but document the reachability requirement).
  5. Validate-under-lock: broadcast peeks and consumes atomically under one lock hold (network I/O outside the lock); a wrong-wallet caller leaves the owner's token untouched, pinned by test.

Also per review: the dead core_wallet_signed_payment_register four-layer chain is deleted; error code 26 is split into typed siblings 26 StaleReservationToken / 27 ReservationTokenConsumed / 28 ReservationWalletMismatch (Kotlin-only host impact — Swift's enum never mapped 26 and falls through to unknown safely; codes 29/30 are used by the asset-lock PR #4184, coordinated to avoid collision); the stale buildSignedPayment KDoc is fixed.

Local test evidence (fork PRs skip the Rust CI suite): platform-wallet --lib 508 passed, platform-wallet-ffi --lib 197 passed, clippy/fmt clean, Kotlin :sdk:testDebugUnitTest green.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds deferred Core signed-payment flows across the Rust wallet, FFI, JNI, and Kotlin SDK layers. Payments can be built and reserved, then broadcast or released using opaque reservation tokens, with lifecycle validation and stale-token error mapping.

Changes

Deferred payment registry and wallet lifecycle

Layer / File(s) Summary
Reservation registry and wallet lifecycle
packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs, packages/rs-platform-wallet/src/wallet/core/*, packages/rs-platform-wallet/src/test_support.rs
Adds token-backed signed-payment registration, wallet identity checks, reservation age handling, broadcast/release operations, wallet cleanup, and registry tests.

Native signed-payment FFI

Layer / File(s) Summary
Native registration and finalization
packages/rs-platform-wallet-ffi/src/core_wallet/*, packages/rs-platform-wallet-ffi/src/error.rs
Adds native registration, atomic fund/reserve/sign/finalize, broadcast, release, and stale-token error handling.
Wallet alias cleanup
packages/rs-platform-wallet-ffi/src/wallet.rs, packages/rs-platform-wallet-ffi/src/handle.rs
Sweeps deferred-payment entries only when the final wallet alias is destroyed and tests alias behavior.

JNI and Kotlin integration

Layer / File(s) Summary
JNI bridge operations
packages/rs-unified-sdk-jni/src/wallet_manager.rs, packages/kotlin-sdk/sdk/src/main/kotlin/.../ffi/WalletManagerNative.kt
Connects native operations to Kotlin and marshals signed-payment blobs, transaction bytes, fees, tokens, and txids.
Kotlin deferred payment API
packages/kotlin-sdk/sdk/src/main/kotlin/.../wallet/*, packages/kotlin-sdk/sdk/src/main/kotlin/.../errors/DashSdkError.kt, packages/kotlin-sdk/sdk/src/test/kotlin/.../DashSdkErrorTest.kt
Adds signed transaction decoding, deferred build/broadcast/release methods, builder ownership fencing, and stale-token error mapping coverage.

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

Possibly related issues

  • dashpay/platform 4089: Covers the split build/broadcast API and reservation lifecycle implemented by this change.
  • dashpay/platform 4079: Relates to exposing signed Core transaction details, including raw bytes and fees, through ManagedPlatformWallet.

Possibly related PRs

  • dashpay/platform#4181: Directly overlaps the platform-wallet error-code 26 changes in the FFI error mapping.

Suggested reviewers: shumkov, lklimek, llbartekll, zocolini, quantumexplorer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: splitting build and broadcast for deferred BIP70-style submission with reservation release support.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

thepastaclaw commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 440897c)
Canonical validated blockers: 2

@shumkov

shumkov commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

The core design is right (atomic finalize-and-register closes the double-selection race; reservation lifecycle is leak-free and the test matrix is strong). But two structural asks before merge:

  • core_wallet_signed_payment_registercoreWalletRegisterSignedPaymentWalletManagerNativeManagedCoreWallet.registerSignedPayment is a dead four-layer chain with zero callers after the finalize routing — and it's the unsafe variant (its age guard baselines at registration time, so the TTL protection is structurally defeated). Please delete it (or state explicitly why it stays), especially given refactor(sdk): dedup shared wallet code + remove dead FFI/JNI chains #4106 just removed this class of dead chains.
  • The FFI now has two parallel deferred-tx lifecycles: the V2 handle surface Swift uses (core_wallet_tx_builder_finalize/broadcast/abandon_v2) and this token registry. There are real reasons to prefer the token here (V2's GC-backstop free releases the reservation; V2 has no age guard) — but they're stated nowhere, and V2 retains exactly the stale-release hazard this PR defends against. Please add the why-not-V2 rationale to the PR body and file a follow-up for the V2 age guard (it becomes live the moment iOS does deferred sends).

Minor: error code 26 conflates already-consumed (possibly paid!) / wallet-mismatch / aged-out — a payment UX can't tell "maybe paid" from "definitely not"; at minimum fix the doc, ideally split. Stale KDoc on buildSignedPayment still describes the pre-finalize shape. No Swift bindings for the new surface — fine, but track it.

@bfoss765

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt (1)

58-75: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Deprecate or remove the legacy registerSignedPayment bridge. It has no Kotlin callers in this repo, so keeping it unmarked only leaves a dead ABI surface in place. If it must remain for compatibility, add @Deprecated and point docs to the atomic finalizeSignedPayment flow.

🤖 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/ManagedCoreWallet.kt`
around lines 58 - 75, Deprecate the internal registerSignedPayment bridge
because it has no Kotlin callers and exposes a legacy ABI surface; if
compatibility requires retaining it, add `@Deprecated` and update its KDoc to
direct callers to the atomic finalizeSignedPayment flow, otherwise remove the
method.
🤖 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt`:
- Around line 249-257: Update the KDoc paragraph for the method containing
finalizeSignedPayment to describe the atomic finalizeSignedPayment flow instead
of the deprecated new/addOutput*/setFunding/buildSigned sequence. State that
finalizeSignedPayment atomically selects, reserves, signs, and registers the
inputs, while preserving the existing explanation that broadcastSigned and
releaseReservation use the resulting token.

In `@packages/rs-platform-wallet-ffi/src/error.rs`:
- Around line 171-179: Update the documentation for ErrorStaleReservationToken
to explicitly include SignedPaymentError::StaleReservationToken alongside
StaleToken and WalletMismatch, and distinguish the unknown/consumed-token,
wrong-wallet-instance, and aged-out reservation cases with their respective host
semantics. Review the broadcast handler’s mapping and error details so hosts can
determine whether the reservation expired versus was consumed or belongs to
another wallet, without changing the shared error code.

---

Nitpick comments:
In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt`:
- Around line 58-75: Deprecate the internal registerSignedPayment bridge because
it has no Kotlin callers and exposes a legacy ABI surface; if compatibility
requires retaining it, add `@Deprecated` and update its KDoc to direct callers to
the atomic finalizeSignedPayment flow, otherwise remove the method.
🪄 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

Run ID: 038310fd-6fae-4081-961e-4fe849c78f63

📥 Commits

Reviewing files that changed from the base of the PR and between 8b466ab and 32cd702.

📒 Files selected for processing (19)
  • 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/rs-platform-wallet-ffi/src/core_wallet/mod.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/wallet.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/wallet.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/wallet_manager.rs

Comment thread packages/rs-platform-wallet-ffi/src/error.rs Outdated
@shumkov

shumkov commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Additional/strengthened lifetime findings after checking the existing threads:

  1. Final-alias destruction consumes registry tokens without releasing live reservations. platform_wallet_destroy calls remove_entries_for_wallet, which only drops entries. Destroying the last wrapper alias does not necessarily remove the logical wallet from its manager, so the same wallet can be handed out again while those inputs remain reserved until TTL. Token cleanup should be tied to actual wallet-generation removal, or release while the original generation is still live.
  2. Reservation age and token age start on opposite sides of external signing. The reservation height is captured before sign_tx(...).await, while register samples a fresh height afterward. A slow external signer can let the reservation be swept/reselected while the newly minted token still appears fresh. Carry the original reservation height in SignedCoreTransaction and register with it.
  3. The V2 handle and registry-token paths have incompatible wallet-generation rules. V2 storage pins an old CoreWallet and validates only wallet ID, while registry tokens use manager identity plus wallet ID. After wallet recreation, an old V2 handle can act through the old manager while the new manager selects the same inputs. Both paths need one generation identity and one teardown policy.
  4. Kotlin cancellation can orphan a token. buildSignedPayment returns a plain value through cancellable withContext(IO). If cancellation is observed after the blocking JNI registration returns, the token is discarded without a Cleaner/release path. Return an owning closeable object or make publication/release cancellation-safe.
  5. Wrong-wallet broadcast consumes the token before checking its binding. SignedPaymentRegistry::broadcast removes first and validates second, so a mismatched caller destroys the original wallet's token and leaves its reservation until TTL. Validate under the lock, then atomically consume only a matching entry.

The existing age and dual-lifecycle comments point in the right direction; I would treat them as merge blockers rather than follow-ups because they can produce conflicting spends or stranded reservations.

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 21, 2026
Cross-PR collision: the split-build-broadcast branch (dashpay#4185) already
allocates 26-28 for the reservation-token errors on the same base, and
both PRs would merge without textual conflict, silently misclassifying
errors on whichever lands second. Codes 26-28 are now documented as
reserved for dashpay#4185.

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

Copy link
Copy Markdown
Contributor Author

All five lifetime findings are fixed as merge blockers and pushed, one commit each with regression tests: destroy releases the generation's reservations while teardown drops tokens and V2 handles; the pre-signing reservation height travels on SignedCoreTransaction into register; one generation identity across both the V2-handle and token paths; cancellation-safe ownership (an owning AutoCloseable with a Cleaner backstop, plus object-owning broadcastSigned/releaseReservation overloads that keep the payment reachable across the native call); and validate-then-consume under a single lock hold with network I/O outside it. The dead register chain is deleted, code 26 is split into typed 26/27/28 siblings (Kotlin-only host impact; Swift falls through safely), and the why-token-not-V2 rationale is in the PR body.

The V2 age guard you asked to file as a follow-up is implemented as a stacked PR: the V2 broadcast refuses at the same shared threshold off the same pre-signing height stamp (abandon works at any age), with exact-boundary tests on both account types.

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 21, 2026
…the signer wire

Replace end-to-end message sniffing for the signer's "missing key" failure
with a typed discriminator (dashpay#4060 finding 7):

- rs-sdk-ffi: DashSDKSignerErrorCode { Generic = 0, SigningKeyUnavailable =
  1, AuthenticationFailed = 2 (reserved) }; SignCompletionCallback and
  dash_sdk_sign_async_completion gain error_code: i32 (before
  error_message). SignResult stays Result<Vec<u8>, ProtocolError> (a new
  rs-dpp ProtocolError variant would carry serialization blast radius), so
  code 1 rides the single Rust-owned machine prefix
  DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX through
  ProtocolError::Generic — typed at both ABI edges, one constant bridging
  the string segment. This is an internal coordinated ABI change: every
  piece versions together in this monorepo.
- rs-platform-wallet-ffi: PlatformWalletFFIResultCode::
  ErrorSigningKeyUnavailable = 31 (codes 26-28 are reserved for dashpay#4185's
  reservation-token errors and 29/30 for dashpay#4184's asset-lock errors on
  sibling branches — documented in the enum as dashpay#4184 does). The
  From<dpp::ProtocolError> conversion restores the typed code from the
  prefix FIRST (before the loose keyword sniffs), and the
  From<PlatformWalletError> blanket impl restores it on the catch-all only
  (dedicated retry-semantics codes are never overridden) — covering the
  Sdk(dash_sdk::Error::Protocol(..)) wrapping path.
- JNI/Kotlin: SignerNative.completeSign(token, signature, errorCode,
  errorMessage); KeystoreSigner passes SIGNER_ERROR_CODE_KEY_UNAVAILABLE on
  the null-key branch (keeping the MESSAGE_MARKER text for the transition
  window) and Generic everywhere else. DashSdkError maps 31 →
  PlatformWallet.SigningKeyUnavailable; the dashpay#4191 marker sniff on the
  catch-all codes remains as a deprecated old-native fallback with a
  removal note tied to the next minor release.
- Swift: KeychainSigner trampolines forward the code (missing-row /
  missing-scalar outcomes classify as 1); PlatformWalletResultCode gains
  errorSigningKeyUnavailable = 31 → PlatformWalletError
  .signingKeyUnavailable (Kotlin parity).
- Tests: rs-sdk-ffi completion-code tests (prefix present for code 1,
  absent for generic), platform-wallet-ffi prefix→31 tests on both
  conversion points, Kotlin code-31 + fallback-marker tests, Swift mapping
  and trampoline-classifier tests.

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

shumkov commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Consolidated re-verification (two independent passes). All five lifetime findings are genuinely fixed with discriminating regression tests, the dead register chain is fully deleted, the why-not-V2 rationale landed, and the error split into 26/27/28 (with txid carried on 27) is right. Two issues remain from the deeper pass:

  1. Generation validation and reservation mutation are not atomic. The registry validates is_same_generation and then mutates after releasing its lock (core/wallet.rs:70-98,321-338, core/broadcast.rs:111-124, core/transaction.rs:257-268) — a same-ID wallet recreation between validation and cleanup can make old cleanup release the new generation's reservation. Bind the cleanup to a generation-local handle, or validate-and-mutate under a single manager lock, and cover same-ID recreation in the tests.

  2. Deferred CoinJoin finalization can leak reservations until TTL. The FFI finalize path (transaction_builder.rs:170-293) reserves inputs, but registry rejection/abandon/free releases only when an account_type handle is present — CoinJoin-funded deferred payments left without one keep their funds reserved until the 24-block TTL. Retain a releasable account handle in the registry entry.

Minor: signed_payment.rs:40-45 still documents the pre-split error semantics (repeated-broadcast / re-created-wallet now yield 27/28, not 26), and the PR body should cite #4196 by number as the V2-side follow-up.

@shumkov

shumkov commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Addendum: the missing Swift mappings for the new codes belong to this PR too, not only #4196PlatformWalletResult.swift:68-70 jumps from code 25 straight to 98, so 26/27/28 (introduced here) all surface as .errorUnknown on iOS. Fine to fix in either PR, but one of the two must carry it before the pair lands.

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 22, 2026
…roadcast

A pinned V2 finalized-transaction handle (core_wallet_tx_builder_finalize →
broadcast_finalized_transaction) had no reservation age guard, so a
long-held handle could broadcast against funding inputs that key-wallet's
ReservationSet TTL sweep may already have released and re-selected for an
unrelated build — the same stale-release hazard the deferred registry-token
path already defends against. This becomes live the moment iOS starts
issuing deferred sends (follow-up requested on PR dashpay#4185).

Mirror the registry-token age policy on the V2 handle path:

- Hoist RESERVATION_MAX_AGE_BLOCKS (20) and reservation_expired() from
  signed_payment_registry into wallet::reservations so both the registry
  and the V2 handle path bound a reservation's lifetime against key-wallet's
  TTL with one shared number.
- broadcast_finalized_transaction now refuses, before touching the
  broadcaster, once current last_processed_height - the reservation's stamp
  height (already carried on SignedCoreTransaction::reservation_height)
  >= the shared bound, returning the new token-less
  PlatformWalletError::StaleReservation. The stale reservation is left for
  key-wallet's TTL to reclaim (never released by outpoint, which could free a
  newer build's reservation). The check runs after the FFI layer's
  generation-identity check, matching the registry ordering.
- The FFI reuses the existing ErrorStaleReservationToken (26) code for this
  variant (documented as shared between the registry-token and V2-handle
  surfaces); no new codes allocated.
- Abandon/free (abandon_transaction) remain allowed at any age — releasing an
  old reservation is always safe.

Tests: fresh handle broadcasts; aged handle refuses with StaleReservation yet
still abandons cleanly and frees its inputs; exact boundary at the threshold
(BIP44/BIP32); FFI mapping of StaleReservation to the shared code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 22, 2026
shumkov (PR dashpay#4185 follow-up) found the age guard covered only broadcast:
`abandon_transaction` — and therefore the `_v2_free` deinit/GC backstop and the
FFI broadcast/abandon failure paths that route their cleanup through it — still
released the funding reservation by outpoint unconditionally at any age. A
FinalizedCoreTransaction GC'd after ~1h whose outpoint was TTL-swept (24 blocks)
and re-reserved would free the newer build's reservation, letting its inputs be
re-selected into a third build (conflicting spends).

Honor `reservation_expired` in `abandon_transaction`, mirroring the registry's
`reconcile_removed_entry`: once aged past the shared `RESERVATION_MAX_AGE_BLOCKS`
bound, skip the by-outpoint release (leave the outpoint for key-wallet's TTL to
reclaim) while still tearing down the handle; below the bound, release as before.
This covers every consumer of `abandon_transaction`, including the `_v2_free`
GC-backstop and the FFI failure paths, off the same predicate/clock the
broadcast guard uses.

Also correct the reservation-policy docs that claimed releasing was always safe
(`reservations.rs`, `broadcast_finalized_transaction`), and the misleading
ManagedCoreWallet KDoc: after a stale-refused broadcast the handle is already
consumed, so `abandonTransaction` is an invalid-handle error, not a recovery —
the reservation waits out the TTL.

Tests: platform-wallet gains aged-skips-release / below-bound-releases pairs
(BIP44+BIP32); platform-wallet-ffi gains aged `_v2_free` and aged failure-path
skip-release tests via a new `age_core_past_reservation_guard` test helper.

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

Copy link
Copy Markdown
Contributor Author

Both remaining blockers fixed (510 platform-wallet tests):

  1. Atomic validate-and-mutate — reservation cleanup now re-validates the wallet generation (Arc::ptr_eq on the per-generation balance Arc) AND mutates the ReservationSet under a single manager read-lock hold. A same-id recreation needs the write lock and so can't interleave — provably atomic, covered by a new regression test that recreates between register and release and asserts the input stays reserved.
  2. CoinJoin releasable handle — the registry entry now retains the full AccountTypePreference (incl. CoinJoin), so a rejected/abandoned CoinJoin-funded deferred payment releases immediately instead of waiting out the 24-block TTL (tested).

Swift now maps 26/27/28 to typed StaleReservationToken/ReservationTokenConsumed/ReservationWalletMismatch (exhaustive init(result:), message parity with Kotlin); the stale-broadcast doc is corrected. The V2 handle path is the follow-up in #4196.

One conscious scoping note: the immediate-send reject-release path (reservations.rs) shares the same theoretical generation window but a far narrower one — synchronous build→broadcast, no persisted token surviving a restart, CoinJoin not used for immediate sends — so this PR scopes the guard to the deferred registry + V2 handle paths your finding named. Happy to extend it there too if you'd prefer.

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 23, 2026
…the signer wire

Replace end-to-end message sniffing for the signer's "missing key" failure
with a typed discriminator (dashpay#4060 finding 7):

- rs-sdk-ffi: DashSDKSignerErrorCode { Generic = 0, SigningKeyUnavailable =
  1, AuthenticationFailed = 2 (reserved) }; SignCompletionCallback and
  dash_sdk_sign_async_completion gain error_code: i32 (before
  error_message). SignResult stays Result<Vec<u8>, ProtocolError> (a new
  rs-dpp ProtocolError variant would carry serialization blast radius), so
  code 1 rides the single Rust-owned machine prefix
  DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX through
  ProtocolError::Generic — typed at both ABI edges, one constant bridging
  the string segment. This is an internal coordinated ABI change: every
  piece versions together in this monorepo.
- rs-platform-wallet-ffi: PlatformWalletFFIResultCode::
  ErrorSigningKeyUnavailable = 31 (codes 26-28 are reserved for dashpay#4185's
  reservation-token errors and 29/30 for dashpay#4184's asset-lock errors on
  sibling branches — documented in the enum as dashpay#4184 does). The
  From<dpp::ProtocolError> conversion restores the typed code from the
  prefix FIRST (before the loose keyword sniffs), and the
  From<PlatformWalletError> blanket impl restores it on the catch-all only
  (dedicated retry-semantics codes are never overridden) — covering the
  Sdk(dash_sdk::Error::Protocol(..)) wrapping path.
- JNI/Kotlin: SignerNative.completeSign(token, signature, errorCode,
  errorMessage); KeystoreSigner passes SIGNER_ERROR_CODE_KEY_UNAVAILABLE on
  the null-key branch (keeping the MESSAGE_MARKER text for the transition
  window) and Generic everywhere else. DashSdkError maps 31 →
  PlatformWallet.SigningKeyUnavailable; the dashpay#4191 marker sniff on the
  catch-all codes remains as a deprecated old-native fallback with a
  removal note tied to the next minor release.
- Swift: KeychainSigner trampolines forward the code (missing-row /
  missing-scalar outcomes classify as 1); PlatformWalletResultCode gains
  errorSigningKeyUnavailable = 31 → PlatformWalletError
  .signingKeyUnavailable (Kotlin parity).
- Tests: rs-sdk-ffi completion-code tests (prefix present for code 1,
  absent for generic), platform-wallet-ffi prefix→31 tests on both
  conversion points, Kotlin code-31 + fallback-marker tests, Swift mapping
  and trampoline-classifier tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 23, 2026
Cross-PR collision: the split-build-broadcast branch (dashpay#4185) already
allocates 26-28 for the reservation-token errors on the same base, and
both PRs would merge without textual conflict, silently misclassifying
errors on whichever lands second. Codes 26-28 are now documented as
reserved for dashpay#4185.

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

shumkov commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Round-3 verification (two independent passes, reconciled): both previous blockers are genuinely fixed — the generation-safe release validates and mutates under one manager read-lock hold (and recreation requires the write lock, so no interleave), and CoinJoin entries now retain a releasable AccountTypePreference handle with a production-path regression test.

One new P1 (shared with #4196) — freshness and release are still two separate decisions:

  • A reservation stamped at height 100 passes the age guard at 119; the broadcast await can span more than the 4-block margin; sync reaches 124, key-wallet's TTL sweeps the reservation and another build re-reserves the same outpoint; the rejected-broadcast cleanup then releases the new reservation by outpoint.
  • Fix: read the current height, validate generation, and mutate as one guarded operation under the release lock; re-check freshness after the broadcast await before the Rejected-release; cover the signing-failure release path too. The existing tests prove endpoint states — add a barrier-controlled interleaving test that forces check → sweep → re-reserve → release.

Also before merge: rebase (branch is CONFLICTING with v4.1-dev), and please cite #4196 by number in the body as the V2-side sibling. Nit: new comments carry fix-round narration — provenance belongs in the PR description.

bfoss765 and others added 2 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>
bfoss765 and others added 14 commits July 23, 2026 11:36
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>

@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 exact reservation height, generation-aware cleanup, typed host errors, and Kotlin token-owner cleanup are now implemented correctly. Three blocking lifecycle defects remain: registry registration can mint duplicate capabilities for one reservation, destroying the final wallet alias invalidates independently owned tokens, and wallet removal is not linearized with in-flight finalization or broadcast. The reservation token also remains an untyped Rust u64 capability.

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)

🔴 3 blocking | 💬 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/signed_payment_registry.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:233-251: Registration still does not preserve unique reservation ownership
  `register` accepts a freely clonable `Transaction` together with independently supplied wallet, account, and height metadata instead of consuming the non-`Clone` `SignedCoreTransaction` that owns those facts. The test at lines 1080-1115 registers sixteen clones of one reserved transaction, proving that multiple live tokens can name the same reservation. After one token releases the original outpoint and another payment reserves it, a second old token remains generation- and age-valid and can unconditionally release the newer reservation by outpoint. The current FFI finalizer calls this method once, but the registry is publicly re-exported, so the ownership invariant cannot depend on that caller's discipline. Make registration consume the finalized ownership object exactly once and derive its transaction, account, and mandatory reservation height internally.

In `packages/rs-platform-wallet-ffi/src/wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/wallet.rs:420-427: Destroying the final platform-wallet alias invalidates independently owned payment tokens
  `platform_wallet_destroy` releases every token for the generation when no other `PlatformWallet` wrapper is currently stored. Those wrappers do not own the logical wallet: the manager still owns it and can return another alias, `platform_wallet_get_core` creates independently owned core handles, each registry entry retains its own `CoreWallet`, and Kotlin documents `SignedCoreTransaction` as the token owner. Closing or collecting the last wrapper therefore consumes and releases a still-live payment token even though its owning object was neither closed nor used, so a later merchant acknowledgement cannot be broadcast through a retained core handle or reacquired alias. Token cleanup must follow the payment owner or actual wallet-generation removal, not transient wrapper-alias count.

In `packages/rs-platform-wallet-ffi/src/manager.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/manager.rs:452-471: Wallet removal is not linearized with finalization or broadcast
  Logical wallet removal and registry cleanup are separate operations. An existing token can be consumed after `manager.remove_wallet` has deleted its generation but before `remove_entries_for_wallet` runs: the retained old core handle still matches the entry, `last_processed_height()` returns `None`, `reservation_expired` treats that as valid, and the transaction reaches the broadcaster. Conversely, `finalize_transaction` releases the manager lock before awaiting the external signer; removal can delete the generation and complete its token sweep during that await, after which the finalizer registers a new token without revalidating that the generation still exists. Coordinate token registration and consumption with generation removal under a shared lifecycle lock or gate, reject an absent current generation, and ensure teardown waits for or sweeps in-flight finalizers.

Comment on lines +233 to +251
pub async fn register(
&self,
core: CoreWallet<B>,
tx: Transaction,
account_type: AccountTypePreference,
account_index: u32,
registered_height: Option<u32>,
) -> ReservationToken {
let token = self.next_token.fetch_add(1, Ordering::SeqCst);
self.lock().insert(
token,
RegisteredPayment {
core,
tx,
account_type,
account_index,
registered_height,
},
);

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: Registration still does not preserve unique reservation ownership

register accepts a freely clonable Transaction together with independently supplied wallet, account, and height metadata instead of consuming the non-Clone SignedCoreTransaction that owns those facts. The test at lines 1080-1115 registers sixteen clones of one reserved transaction, proving that multiple live tokens can name the same reservation. After one token releases the original outpoint and another payment reserves it, a second old token remains generation- and age-valid and can unconditionally release the newer reservation by outpoint. The current FFI finalizer calls this method once, but the registry is publicly re-exported, so the ownership invariant cannot depend on that caller's discipline. Make registration consume the finalized ownership object exactly once and derive its transaction, account, and mandatory reservation height internally.

source: ['codex']

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 440897cRegistration still does not preserve unique reservation ownership 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 +420 to +427
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.

🔴 Blocking: Destroying the final platform-wallet alias invalidates independently owned payment tokens

platform_wallet_destroy releases every token for the generation when no other PlatformWallet wrapper is currently stored. Those wrappers do not own the logical wallet: the manager still owns it and can return another alias, platform_wallet_get_core creates independently owned core handles, each registry entry retains its own CoreWallet, and Kotlin documents SignedCoreTransaction as the token owner. Closing or collecting the last wrapper therefore consumes and releases a still-live payment token even though its owning object was neither closed nor used, so a later merchant acknowledgement cannot be broadcast through a retained core handle or reacquired alias. Token cleanup must follow the payment owner or actual wallet-generation removal, not transient wrapper-alias count.

source: ['codex']

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 440897cDestroying the final platform-wallet alias invalidates independently owned payment tokens 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 454 to +471
});
let result = unwrap_option_or_return!(option);
match result {
Ok(_) => PlatformWalletFFIResult::ok(),
Ok(removed) => {
// Generation teardown: the wallet and its accounts' `ReservationSet`s
// are now gone from the manager, so the deferred-payment reservations
// cease to exist — there is nothing to reconcile. DROP (do not
// release) this generation's registry tokens and its finalized-tx V2
// handles. This is the teardown half of the single generation policy
// both deferred paths share: it makes any stale handle to the removed
// generation inert, so a later destroy/release of a lingering handle
// can never release-by-outpoint against a re-created generation's
// inputs.
let core = removed.core();
crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY
.remove_entries_for_wallet(core);
crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE
.remove_matching(|tx| tx.wallet.is_same_generation(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.

🔴 Blocking: Wallet removal is not linearized with finalization or broadcast

Logical wallet removal and registry cleanup are separate operations. An existing token can be consumed after manager.remove_wallet has deleted its generation but before remove_entries_for_wallet runs: the retained old core handle still matches the entry, last_processed_height() returns None, reservation_expired treats that as valid, and the transaction reaches the broadcaster. Conversely, finalize_transaction releases the manager lock before awaiting the external signer; removal can delete the generation and complete its token sweep during that await, after which the finalizer registers a new token without revalidating that the generation still exists. Coordinate token registration and consumption with generation removal under a shared lifecycle lock or gate, reject an absent current generation, and ensure teardown waits for or sweeps in-flight finalizers.

source: ['codex']

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 removal is not linearized with finalization or broadcast 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 +77 to +82
/// Opaque handle to a registered, signed-but-unsent payment. Minted by
/// [`SignedPaymentRegistry::register`]; consumed by
/// [`SignedPaymentRegistry::broadcast`] or
/// [`SignedPaymentRegistry::release`]. Values are unique for the process
/// lifetime and never reused, so a stale token can always be recognised.
pub type ReservationToken = u64;

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: The reservation capability remains an untyped u64 alias

ReservationToken has single-use capability and provenance semantics distinct from ordinary numeric identifiers, but its type alias provides no Rust-side domain separation. Registry and boundary code can accidentally interchange unrelated u64 handles with destructive reservation operations without a type error. A #[repr(transparent)] newtype would preserve straightforward C/JNI conversion while enforcing the distinction within Rust.

source: ['codex']

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 440897cThe reservation capability remains an untyped u64 alias 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.

bfoss765 and others added 3 commits July 23, 2026 12:14
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>
@bfoss765
bfoss765 force-pushed the port/v4.1/split-build-broadcast branch from 911c8f7 to 3f719d4 Compare July 23, 2026 16:17
@bfoss765

Copy link
Copy Markdown
Contributor Author

⚠️ Merge-time error-code coordination with #4184

After the rebase onto v4.1-dev, this PR's deferred-reservation errors are 27 = ErrorStaleReservationToken, 28 = ErrorReservationTokenConsumed, 29 = ErrorReservationWalletMismatch.

#4184 independently allocates 29 = ErrorAssetLockInsufficientFunds, so 29 collides.

Whichever of #4184 / #4185 merges SECOND must shift its 29. Recommended resolution (and what the 11.10.x QA integration build uses): keep #4184's 29 = ErrorAssetLockInsufficientFunds and move THIS PR's ErrorReservationWalletMismatch → 30. Propagate through the Rust enum + FFI From mapping, Kotlin DashSdkError (+ DashSdkErrorTest), and Swift PlatformWalletResult. 26 = ErrorTransactionBroadcastRejected (v4.1-dev) and 31 = ErrorSigningKeyUnavailable (#4183) are unaffected.

@shumkov

shumkov commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Round-4 verification (two independent passes, reconciled): the rebase preserved the atomicity work — atomic selection+reservation and owner-token-guarded cleanup verified (registry 20/20, core transaction 3/3), and the new owner-guard on the broadcast-reject release answers the post-await recheck ask. Three items:

  1. Move ErrorReservationWalletMismatch 29 → 30 now, deterministically — both review passes concur with keeping fix: shield asset-lock funding from all funds accounts incl. CoinJoin (#4073) #4184's established 29, but not with "whichever merges second shifts": that's a known public-ABI collision, so resolve it here across Rust, Swift, Kotlin, JNI docs, tests, and the PR body (30 is free after fix: shield asset-lock funding from all funds accounts incl. CoinJoin (#4073) #4184's re-scope dropped the consent code).
  2. New P1 — delayed-signer publication race. finalize_transaction drops the manager lock before awaiting the external signer; remove_wallet can complete its one-time registry/V2 sweep in that window, and the late signer return then publishes a token/handle for the already-removed generation (core/transaction.rs:250). Order generation-validation + registry/V2 publication atomically against wallet removal, and add a barrier-controlled delayed-signer remove/recreate test.
  3. P2: final-alias scanning/cleanup can race same-generation alias insertion at rs-platform-wallet-ffi/src/wallet.rs:421. Minors: stale 26/27/28 references in JNI docs; a few PR-owned rustfmt diffs remain.

@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

At exact head 3f719d4, the newest owner-token delta correctly carries key-wallet's reservation token through finalization and uses owner-guarded release; its regression test models a sweep, re-reservation, and rejected broadcast and verifies that the newer reservation survives. Three carried-forward blocking lifecycle issues remain: public registration does not enforce unique ownership, final wallet-alias destruction consumes independently owned payments, and wallet removal is not linearized with finalization or broadcast. The carried-forward untyped-token nitpick also remains, and the latest error-code renumbering left one JNI Rustdoc block on the old values.

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)

🔴 3 blocking | 💬 1 nitpick(s)

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

Comment on lines +1375 to +1378
/// throws one of three sibling codes: `ErrorStaleReservationToken` (26, aged
/// out), `ErrorReservationTokenConsumed` (27, unknown / already broadcast /
/// already released), or `ErrorReservationWalletMismatch` (28, different wallet
/// generation). `coreHandle` must resolve to the wallet the token was minted

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: JNI Rustdoc still lists the pre-renumbered deferred error codes

The native deferred-payment errors and Kotlin declarations now use codes 27, 28, and 29, but this exported JNI function still documents them as 26, 27, and 28. Rust-side consumers using this documentation to diagnose or mirror exceptions will associate each condition with the wrong native code.

Suggested change
/// throws one of three sibling codes: `ErrorStaleReservationToken` (26, aged
/// out), `ErrorReservationTokenConsumed` (27, unknown / already broadcast /
/// already released), or `ErrorReservationWalletMismatch` (28, different wallet
/// generation). `coreHandle` must resolve to the wallet the token was minted
/// throws one of three sibling codes: `ErrorStaleReservationToken` (27, aged
/// out), `ErrorReservationTokenConsumed` (28, unknown / already broadcast /
/// already released), or `ErrorReservationWalletMismatch` (29, different wallet
/// generation). `coreHandle` must resolve to the wallet the token was minted

source: ['codex']

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 440897cJNI Rustdoc still lists the pre-renumbered deferred error codes 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.

bfoss765 added a commit to bfoss765/rust-dashcore that referenced this pull request Jul 24, 2026
…tream failures

Addresses the CodeRabbit Major (PR dashpay#916, asset_lock_builder.rs :219-252 /
:293-357). Both asset-lock builders took a reservation via
build_signed_reserved and captured its reservation_token, but a failure in
the code that runs AFTER the successful signed build — credit-key derivation
in build_asset_lock, and the Phase 1-3 signer/bookkeeping loop in
build_asset_lock_with_signer — returned Err without releasing the reservation.
The already-signed transaction's inputs then stranded until the 24-block TTL
sweep, since the caller never received the token to release them itself.

Clone the funding account's ReservationSet (a shared Arc view) before each
loop re-borrows self.accounts, and on any failure release only THIS build's
reservation with the owner-guarded release_if_owner(&reserved, token) — never
the unconditional release, which could free a concurrent build's inputs that
the TTL sweep + re-reserve handed over during this build (the very TOCTOU of
dashpay/platform#4185 this PR closes). The success path is unchanged: a
successful build consumes the reservation as designed, and the signer-failure
release inside build_signed_reserved returns via `?` before this new code
runs, so there is no double-release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 24, 2026
 review blocker 2)

Reviewer (thepastaclaw) blocker 2 — build.rs:423-424:
build_asset_lock_tx_from_selected_account passed the BIP44 change
account (&bip44_acc) as set_funding's `acc`, but set_funding calls
funds_acc.next_change_address(Some(&acc.account_xpub)) on the SELECTED
funds account before the set_change_address override. For an explicitly
selected Standard BIP32 account with no pre-generated unused internal
address, that derived [1, index] from the wrong (BIP44) xpub and recorded
it under the BIP32 account's own path, poisoning that pool so a later
normal BIP32 send could use a change entry whose signer key does not match
the address. Now resolve the wallet-level Account whose account-level
derivation path equals funding_path and pass ITS xpub to set_funding,
while keeping the separate BIP44 set_change_address override. Default
BIP44 funding resolves to bip44_acc (unchanged); non-Standard
(CoinJoin/DashPay) accounts fail change derivation regardless, so the
xpub is immaterial and the bip44_acc fallback preserves prior behavior.

Also (thepastaclaw nitpick, test_support.rs): move the DashPay fixture
rustdoc so it attaches to split_funded_wallet_manager_dashpay rather than
foreign_contact_account_xpub.

Blocker 1 (build.rs:427-473, owner-guarded reservation rollback on the
pre-broadcast abandonment path) is NOT addressed here: the pinned
key-wallet rev 70d4bf8 exposes no owner-guarded release primitive
(ReservationSet has only reserve/reserved/release keyed by outpoint;
release_reservation is unconditional) and no reservation token, so the
required release_if_owner(token) mechanism (rust-dashcore#916 / dashpay#4185)
cannot be implemented against this pin without an upstream dependency
change — the very atomic-reservation fix this PR is held for. Deferred
pending that adoption rather than substituting an unconditional release
on the money path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…per-destroy from consuming payments, type the token

Addresses two of the three carried-forward lifecycle blockers on dashpay#4185 plus
the two smaller review items. The wallet-removal/finalize linearization
blocker is intentionally NOT included here (see PR discussion) — it needs a
shared lifecycle gate that is a design change on a money path.

Blocker 1 — unique reservation ownership:
`SignedPaymentRegistry::register` now CONSUMES the non-`Clone`
`SignedCoreTransaction` and derives the transaction, funding account, mandatory
reservation height, and owner-guard token from it (new
`SignedCoreTransaction::into_registered_parts`). Because the ownership object
is moved exactly once, a single finalize can no longer mint two live tokens
naming the same held reservation. The FFI finalizer passes the finalized object
straight in; the former duplicate-registration test (16 clones of one reserved
tx) is removed as it modelled the now-impossible pattern.

Blocker 2 — final wallet-alias destroy no longer consumes independently-owned
payments: `platform_wallet_destroy` no longer releases the generation's tokens
when the last wrapper alias is dropped. A wrapper handle does not own the
logical wallet or the registered payment (the manager still owns the wallet;
each registry entry pins its own `CoreWallet`). Token cleanup now follows the
payment owner (broadcast/release) or actual generation teardown
(`remove_wallet` → `remove_entries_for_wallet`), never a transient alias count.
The unused `release_entries_for_wallet` method and its test are removed; the
destroy test now asserts tokens survive destroying every alias.

Nit — typed token: `ReservationToken` is now a `#[repr(transparent)]` newtype
instead of a bare `u64` alias, converted to/from `u64` only at the FFI
boundary, so a payment handle can't be silently confused with another numeric
id.

Docs — JNI Rustdoc: the `coreWalletBroadcastSignedPayment` block referenced the
pre-renumber codes (26/27/28); updated to the current enum values
(27 StaleReservationToken / 28 ReservationTokenConsumed / 29
ReservationWalletMismatch).

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

Copy link
Copy Markdown
Contributor Author

@thepastaclaw pushed fixes for 2 of the 3 blockers + both smaller items in 440897c9 (signed_payment 18 tests / destroy 5 tests pass):

  • Unique ownershipSignedPaymentRegistry::register now consumes the non-Clone SignedCoreTransaction (via into_registered_parts) and derives tx/account/mandatory height/token internally, instead of taking them as separate args. A single finalize can no longer mint two live tokens for one reservation; the concurrent_registers_yield_distinct_tokens test (which modelled the now-impossible clone pattern) is removed, distinctness guaranteed by the AtomicU64 allocator.
  • Wrapper-destroy no longer consumes paymentsplatform_wallet_destroy just drops the handle; it no longer scans for a "final alias" or releases the generation's tokens. Cleanup happens only via the payment owner (broadcast/release) or actual generation teardown (remove_walletremove_entries_for_wallet). The destroy test now asserts a token survives destroying every alias and stays releasable.
  • NitReservationToken is now a #[repr(transparent)] struct(u64) (ABI unchanged, still u64 on the wire).
  • JNI Rustdoc numbering — updated wallet_manager.rs:1375 to the current codes. Heads-up: the branch actually has ReservationWalletMismatch = 29 consistently (error.rs / Swift / Kotlin all 29); there is no 30 in the tree, so I matched the code (27 StaleReservationToken / 28 ReservationTokenConsumed / 29 ReservationWalletMismatch). If a renumber to 30 is planned to deconflict with fix: shield asset-lock funding from all funds accounts incl. CoinJoin (#4073) #4184's 29 = AssetLockInsufficientFunds, it hasn't landed on this head — that collision still needs resolving at merge time between fix: shield asset-lock funding from all funds accounts incl. CoinJoin (#4073) #4184 and feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission #4185.

Blocker 3 (wallet removal not linearized with finalize/broadcast) — holding for a design decision, not a minimal patch. Both windows you describe are real: (a) remove_wallet + remove_entries_for_wallet are separate steps, so a broadcast in between still passes is_same_generation/reservation_expired; (b) finalize_transaction drops the manager write lock before awaiting the external signer, so removal can sweep during that await. A partial "reject absent current generation" check only closes the fully-ordered case and leaves the interleaving window open while looking fixed — unsafe to claim on a money path. The correct fix is the per-wallet teardown gate the Kotlin side already references, at the Rust layer: a per-generation async RwLock where finalize+register and broadcast+release take a shared guard across their whole section, remove_wallet takes the exclusive guard (drains in-flight ops, then removes+sweeps as one unit), and register/broadcast revalidate generation liveness under the gate. That spans manager/registry/CoreWallet + new race tests — I'd rather scope it deliberately than land something partial. Happy to take it as a focused follow-up.

@QuantumExplorer
QuantumExplorer changed the base branch from v4.1-dev to v4.2-dev July 24, 2026 20:10
@github-actions github-actions Bot modified the milestones: v4.1.0, v4.2.0 Jul 24, 2026

@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

Carried forward, prior-1 remains partially valid: consuming SignedCoreTransaction prevents duplicate registration, but the independently supplied CoreWallet can still bind the reservation to the wrong wallet generation; prior-3 remains real but was explicitly deferred to a lifecycle-gate follow-up. The latest delta fixes prior-2, prior-4, and prior-5, but introduces another blocking ownership hole because the now-consuming registration API is still async and can lose the reservation owner before its first poll. The full PR also needs defensive Kotlin cleanup if token-owner construction fails, while the obsolete public alias-scan helper is a minor cleanup.

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)
    Source: reviewers gpt-5.6-sol; verifier gpt-5.6-sol.

🔴 2 blocking | 🟡 1 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/signed_payment_registry.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:283-300: Registration can bind a reservation to the wrong wallet generation
  `register` consumes the finalized transaction and derives its transaction, account, height, and funding token, but it still accepts an independent `CoreWallet`. `SignedCoreTransaction` carries no originating wallet-generation marker, so safe public Rust code can finalize through wallet A and call `register(core_b, signed_from_a)`. The registry then treats B as the owner: broadcasting through B passes the generation check and submits A's transaction through B's broadcaster, while release or definitive-rejection cleanup runs against B and leaves A's actual reservation held until its TTL. This violates the documented guarantee that a token is bound to the generation whose `ReservationSet` owns the inputs. Store an unforgeable originating-generation marker in `SignedCoreTransaction` and derive or validate the registry's core from it instead of trusting a separate argument.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:283-302: Async registration can drop the reservation owner before insertion
  `register` now consumes the non-`Clone` `SignedCoreTransaction`, but it remains an `async fn` even though its body contains no await. Calling an async function only moves its arguments into the returned future; the body does not execute until that future is polled. If the future is dropped or cancelled before its first poll, the consumed `SignedCoreTransaction` is dropped without being inserted, no payment token is returned, and the type has no `Drop` implementation that releases its funding reservation. The reservation is therefore unreachable to explicit cleanup until key-wallet's TTL. Make registration synchronous and update callers to invoke it directly.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt:184-191: Release the native token if Kotlin owner construction fails
  Native finalization has already inserted the payment and committed its reservation when this byte array returns. The token does not gain its owning `NativeCleaner` until `SignedCoreTransaction.fromRegisterBlob` finishes, so an allocation failure, malformed-data exception from an ABI mismatch, or Cleaner-registration failure leaves the native token registered without any JVM owner capable of releasing it. Parse the token first and release it defensively if construction of the ownership object throws.

Comment on lines +283 to +300
pub async fn register(
&self,
core: CoreWallet<B>,
signed: SignedCoreTransaction,
) -> ReservationToken {
let parts = signed.into_registered_parts();
let token = ReservationToken(self.next_token.fetch_add(1, Ordering::SeqCst));
self.lock().insert(
token,
RegisteredPayment {
core,
tx: parts.transaction,
account_type: parts.funding_account_type,
account_index: parts.funding_account_index,
registered_height: parts.reservation_height,
funding_reservation_token: parts.reservation_token,
},
);

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: Registration can bind a reservation to the wrong wallet generation

register consumes the finalized transaction and derives its transaction, account, height, and funding token, but it still accepts an independent CoreWallet. SignedCoreTransaction carries no originating wallet-generation marker, so safe public Rust code can finalize through wallet A and call register(core_b, signed_from_a). The registry then treats B as the owner: broadcasting through B passes the generation check and submits A's transaction through B's broadcaster, while release or definitive-rejection cleanup runs against B and leaves A's actual reservation held until its TTL. This violates the documented guarantee that a token is bound to the generation whose ReservationSet owns the inputs. Store an unforgeable originating-generation marker in SignedCoreTransaction and derive or validate the registry's core from it instead of trusting a separate argument.

source: ['codex']

Comment on lines +283 to +302
pub async fn register(
&self,
core: CoreWallet<B>,
signed: SignedCoreTransaction,
) -> ReservationToken {
let parts = signed.into_registered_parts();
let token = ReservationToken(self.next_token.fetch_add(1, Ordering::SeqCst));
self.lock().insert(
token,
RegisteredPayment {
core,
tx: parts.transaction,
account_type: parts.funding_account_type,
account_index: parts.funding_account_index,
registered_height: parts.reservation_height,
funding_reservation_token: parts.reservation_token,
},
);
token
}

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: Async registration can drop the reservation owner before insertion

register now consumes the non-Clone SignedCoreTransaction, but it remains an async fn even though its body contains no await. Calling an async function only moves its arguments into the returned future; the body does not execute until that future is polled. If the future is dropped or cancelled before its first poll, the consumed SignedCoreTransaction is dropped without being inserted, no payment token is returned, and the type has no Drop implementation that releases its funding reservation. The reservation is therefore unreachable to explicit cleanup until key-wallet's TTL. Make registration synchronous and update callers to invoke it directly.

source: ['codex']

Comment on lines +184 to +191
val blob = WalletManagerNative.coreWalletFinalizeSignedPayment(
builderPtr,
walletHandle,
accountType.ffiValue,
accountIndex,
coreSignerHandle,
)
return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob)

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: Release the native token if Kotlin owner construction fails

Native finalization has already inserted the payment and committed its reservation when this byte array returns. The token does not gain its owning NativeCleaner until SignedCoreTransaction.fromRegisterBlob finishes, so an allocation failure, malformed-data exception from an ABI mismatch, or Cleaner-registration failure leaves the native token registered without any JVM owner capable of releasing it. Parse the token first and release it defensively if construction of the ownership object throws.

Suggested change
val blob = WalletManagerNative.coreWalletFinalizeSignedPayment(
builderPtr,
walletHandle,
accountType.ffiValue,
accountIndex,
coreSignerHandle,
)
return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob)
val blob = WalletManagerNative.coreWalletFinalizeSignedPayment(
builderPtr,
walletHandle,
accountType.ffiValue,
accountIndex,
coreSignerHandle,
)
var token: Long? = null
return try {
token = java.nio.ByteBuffer.wrap(blob).long
ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob)
} catch (error: Throwable) {
token?.let { value ->
runCatching { WalletManagerNative.coreWalletReleaseSignedPayment(value) }
}
throw error
}

source: ['codex']

Comment on lines +74 to +83
/// Whether any currently-stored item satisfies `predicate`. Used to detect
/// whether a logical resource still has a live handle after one of its
/// aliases is removed (e.g. the final-alias check in
/// `platform_wallet_destroy`).
pub fn any<F>(&self, predicate: F) -> bool
where
F: Fn(&T) -> bool,
{
self.items.read().values().any(predicate)
}

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: The removed final-alias policy left a stale public handle API

The latest delta removed the final-alias scan from platform_wallet_destroy, leaving HandleStorage::any with no call sites. Its Rustdoc still says it exists for that removed policy, and handle is a public module, so the helper unnecessarily remains part of the crate's public Rust surface. Remove the method and obsolete explanation.

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.

3 participants