Skip to content

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

Open
bfoss765 wants to merge 31 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 31 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 27 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.
    • Added typed reservation and wallet-mismatch errors across supported SDKs.
  • Bug Fixes
    • Improved cleanup when wallets are closed while deferred payments remain active.
    • Prevented reservations from being used with the wrong wallet or after expiration.

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; the single stale-token code is split into typed siblings 27 ErrorStaleReservationToken / 28 ErrorReservationTokenConsumed / 29 ErrorReservationWalletMismatch (code 26 is not used by this PR — upstream now owns it as ErrorTransactionBroadcastRejected; both the Kotlin and Swift enums on this branch map 27/28/29 explicitly); the stale buildSignedPayment KDoc is fixed.

Error-code allocation note. These 27 / 28 / 29 allocations are being reconciled repo-wide in the error-code registry PR #4261. That registry records that ErrorReservationWalletMismatch = 29 on this branch currently collides with ErrorAssetLockInsufficientFunds = 29 on the asset-lock PR #4184, and that code 30 is free after #4184's re-scope (the variant previously reserved at 30 is not defined anywhere). The resolution of record is that #4184 keeps 29 and this PR moves its mismatch code to 30; that renumber has not yet landed on this head.

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 with reservation tokens. The change adds generation validation, lifecycle cleanup, ownership handling, and typed error mappings.

Changes

Deferred signed payment lifecycle

Layer / File(s) Summary
Wallet generation and reservation registry
packages/rs-platform-wallet/src/wallet/core/*, packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs, packages/rs-platform-wallet/src/manager/*
Adds generation-bound reservations, atomic registration, broadcast, release, expiry handling, teardown synchronization, and wallet recreation checks.
Native FFI lifecycle
packages/rs-platform-wallet-ffi/src/core_wallet/*, packages/rs-platform-wallet-ffi/src/manager.rs, packages/rs-platform-wallet-ffi/src/error.rs, packages/rs-platform-wallet-ffi/src/wallet.rs
Adds deferred finalization, token broadcast and release, generation validation, error codes, handle cleanup, and lifecycle tests.
JNI and Kotlin API
packages/rs-unified-sdk-jni/src/wallet_manager.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/*, packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/*
Adds signed-payment construction, ownership cleanup, broadcast and release operations, native bindings, typed errors, and JVM tests.
Supporting workspace changes
Cargo.toml, packages/rs-platform-wallet/src/test_support.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
Updates Rust dependency sources, adds wallet test fixtures, and maps reservation errors in Swift.

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

Sequence Diagram(s)

sequenceDiagram
  participant KotlinSDK
  participant JNI
  participant NativeFFI
  participant SignedPaymentRegistry
  participant CoreWallet
  KotlinSDK->>JNI: finalize signed payment
  JNI->>NativeFFI: fund, reserve, sign, and register
  NativeFFI->>SignedPaymentRegistry: store payment and token
  KotlinSDK->>JNI: broadcast or release token
  JNI->>NativeFFI: execute token operation
  NativeFFI->>SignedPaymentRegistry: broadcast or release reservation
  SignedPaymentRegistry->>CoreWallet: update reservation state
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: lklimek, quantumexplorer, 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 describes the main Kotlin SDK change: separate deferred-payment building and broadcasting with reservation release.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

@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

🔍 Review in progress — actively reviewing now (commit 6be6748)

@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>
@github-actions github-actions Bot added this to the v4.2.0 milestone 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']

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

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 7d85953c2a. SignedCoreTransaction carries origin_generation (transaction.rs:98) — the per-generation balance Arc captured inside finalize_transaction. register pointer-compares it against the supplied core (signed_payment_registry.rs:450-452) and refuses with RegisterWrongGeneration, handing the finalized payment back so its reservation is released rather than stranded (transaction_builder.rs:297-305). Covered by register_rejects_a_different_wallet_generation.

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']

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

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 7d85953c2a. register (signed_payment_registry.rs:438) is synchronous with no .await in its body, so the reservation-owning insert runs inline and there is no future that could be dropped before first poll. Callers invoke it directly (transaction_builder.rs:294).

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']

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted as suggested — CoreTransactionBuilder.kt:201-210 parses the token from the blob's leading 8 big-endian bytes first, then releases it under runCatching { coreWalletReleaseSignedPayment(value) } if owner construction throws, and rethrows. Offset verified against ManagedPlatformWallet.kt:257-259.

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']

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

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.

Confirmed live and now fixed — the auto-resolve on 2026-07-25 was incorrect; HandleStorage::any was still present at head d854debb33 (packages/rs-platform-wallet-ffi/src/handle.rs:74-83), untouched since the policy removal.

Your read was right on all three counts:

  • platform_wallet_destroy no longer scans — wallet.rs:392-414 is just PLATFORM_WALLET_STORAGE.remove(handle) (line 412).
  • any had zero call sites workspace-wide, and its Rustdoc still cited "the final-alias check in platform_wallet_destroy".
  • It stayed on the public surface silently because handle is a pub mod, so no dead-code lint fires.

It was introduced by ade399913c for that sweep and is not on v4.2-dev, so removing it restores the base surface rather than dropping an API a consumer could have depended on. Method and doc are gone.

Kept remove_matching — still used for the generation sweep at manager.rs:494.

Also corrected one stale cross-reference to the same removed policy: test_support.rs:391-392 described test_platform_wallet_manager as backing "final-alias registry-sweep gating", but its only FFI consumer (destroy_tests::destroying_wrapper_aliases_never_sweeps_tokens) now asserts the opposite.

No behavior change. cargo fmt --check clean; cargo test -p platform-wallet-ffi -p platform-wallet → 762 passed / 0 failed, including the three manager::remove_wallet_lifecycle_tests regressions.

…+ retain reservation owner through insertion (dashpay#4185 review)

Addresses the two new thepastaclaw blockers plus the Kotlin owner-construction
suggestion on PR dashpay#4185:

1. Registration could bind a reservation to the wrong wallet generation.
   SignedCoreTransaction now carries the unforgeable per-generation balance Arc
   (origin_generation) captured from the finalizing CoreWallet.
   SignedPaymentRegistry::register validates the supplied core against it and
   refuses a mismatch with the new typed RegisterWrongGeneration error, handing
   the rejected SignedCoreTransaction back so its reservation is not stranded.

2. Async registration could drop the reservation owner before insertion.
   register is now synchronous (its body has no await), so the consumed
   SignedCoreTransaction cannot be lost to a future dropped before its first
   poll. The FFI finalizer and all callers invoke it directly.

3. Kotlin: CoreTransactionBuilder.finalizeSignedPayment parses the native token
   first and releases it (owner-guarded) if SignedCoreTransaction construction
   throws, so an ABI/allocation/Cleaner failure never leaves the native token
   without a JVM owner.

Adds a register_rejects_a_different_wallet_generation regression test.

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

Copy link
Copy Markdown
Contributor Author

@thepastaclaw both fixed in 7d85953c (513 platform-wallet + 6 FFI tests pass):

  • Wrong wallet-generation binding: SignedCoreTransaction now carries an unforgeable origin_generation: Arc<WalletBalance> (the same per-generation Arc is_same_generation pointer-compares, captured in finalize_transaction). register validates Arc::ptr_eq(core.generation(), signed.origin_generation()) before consuming signed; a mismatch returns a typed RegisterWrongGeneration that hands signed back (A's reservation never stranded), and the FFI releases it owner-guarded → ErrorReservationWalletMismatch.
  • Async register drops the owner: register is now pub fn (its body had no await), so a dropped/cancelled future can no longer drop the consumed SignedCoreTransaction before insertion; callers invoke it directly (removed the block_on).
  • Suggestion: CoreTransactionBuilder.finalizeSignedPayment now releases the native token (owner-guarded) if the Kotlin owner construction throws.
    Success path + owner-tagged semantics unchanged; new test register_rejects_a_different_wallet_generation proves the mismatch is refused with no token minted and the reservation still reachable.

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 25, 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>

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

Final validation — Codex + Sonnet

The substantive ownership issues are fixed at the exact reviewed head: registration now validates the originating wallet generation, executes synchronously, and Kotlin releases the native token if owner construction fails. One non-functional cleanup remains: HandleStorage::any is unused public API whose documentation still describes the removed final-alias policy.

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 (fallback)
  • Sonnet reviewers: claude-sonnet-5 — general (completed), claude-sonnet-5 — rust-quality (failed), claude-sonnet-5 — ffi-engineer (failed), claude-sonnet-5 — rust-quality (completed), claude-sonnet-5 — ffi-engineer (failed), claude-sonnet-5 — ffi-engineer (completed)
    Source: reviewers gpt-5.6-sol + claude-sonnet-5; final verifier gpt-5.6-sol (fallback after unparseable Sonnet verifier).

💬 1 nitpick(s)

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

@bfoss765

Copy link
Copy Markdown
Contributor Author

@shumkov — at the current head CI is green and the review gate reads no blockers. You're one of the CODEOWNERS on the rs-platform-wallet packages this PR touches (with @QuantumExplorer, @lklimek, @llbartekll, @ZocoLini), and you've already run several verification passes. Could you confirm who owns the next steps to get this approved? Happy to address anything still outstanding on my end.

@bfoss765

Copy link
Copy Markdown
Contributor Author

Friendly re-ping: this PR has been code-complete with CI green and no outstanding findings for several days, but is stalled awaiting a human CODEOWNER approval (my 07-26 question went unanswered). It now gates the receival-spend stack: #4256 stacks directly on it and #4247's final shape follows it. @shumkov — or if ownership sits elsewhere, @QuantumExplorer / @lklimek, could one of you take the approval? Relatedly, #4186 is merge-ready per its final review and only needs its stale PastaClaw check re-triggered. 🤖 Generated with Claude Code

bfoss765 added a commit to bfoss765/platform that referenced this pull request Jul 31, 2026
… 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>
bfoss765 added a commit to bfoss765/platform that referenced this pull request Aug 1, 2026
…ode (33)

`map_send_builder_error` folded `BuilderError::SigningFailed` into
`TransactionBuild` → native code 32, whose documented contract is "the
request itself is at fault; a verbatim retry fails identically". That is
false for the production `MnemonicResolverCoreSigner`: a locked or missing
Keychain mnemonic surfaces as `SigningFailed`, and key-wallet
`release_if_owner`-releases the build's owner-stamped input reservation
before returning, so the identical recipients/amount/fee/funding path
succeed once the signer is usable. Hosts were being told to make the user
edit a payment that was never wrong.

Adds `PlatformWalletError::TransactionSigning` →
`ErrorTransactionSigning = 33` → `DashSdkError.PlatformWallet.TransactionSigning`
(isRetryable = true, matching the ShieldedNoRecordedAnchor convention for
"nothing committed, reservations released, retry once the precondition is
met").

Code choice — 33, not the reviewer's suggested 31. 27-32 are all claimed
across the sibling v4.1 stack (27/28 dashpay#4185, 29 dashpay#4184, 30 reserved for
dashpay#4184's cross-domain-consent, 31 dashpay#4183, 32 dashpay#4247), so 33 is the first slot
free on every branch. 31 IS reserved, but for a different contract:
dashpay#4183's `ErrorSigningKeyUnavailable` is a state-transition failure
asserting the signer holds no usable private key for a requested public
key, restored from the typed `DashSDKSignerErrorCode::SigningKeyUnavailable`.
This is a Core L1 input signing failure with no such provenance —
`BuilderError::SigningFailed` also covers an unresolved input derivation
path, a sighash computation failure and a malformed signature encoding — so
reusing 31 would assert "the key is unavailable" for failures that are
nothing of the kind. Kept separate so neither contract is weakened; the
rationale is recorded on the variant for maintainers reconciling the range.

Tests: an end-to-end locked-signer build asserting TransactionSigning (not
TransactionBuild), a retry-after-recovery test proving the inputs really
were released, FFI code/mapping tests, and the Kotlin decode + retry-contract
test. platform-wallet 539 passed, platform-wallet-ffi 225 passed,
kotlin-sdk 190 passed; fmt clean, no new clippy warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bfoss765 added a commit to bfoss765/platform that referenced this pull request Aug 1, 2026
…ilds

`build_signed_payment` reserves its selected inputs and leaves them
reserved on success, expecting a broadcast to follow. Nothing across
Rust/FFI/JNI/Kotlin let a caller that declines to commit give those
coins back, so an abandoned build stranded them for
RESERVATION_TTL_BLOCKS (24, ~1h) — and indefinitely while the wallet
has no processed height, since `ReservationSet::sweep` early-returns at
height 0 and therefore never reclaims a pre-sync reservation. A single
abandoned build on a freshly restored wallet could strand the whole
balance for the life of the process.

Adds a standalone release across all four layers:

  CoreWallet::release_payment_reservation(&Transaction, Option<DerivationPath>)
  core_wallet_release_payment_reservation (FFI)
  coreWalletReleasePaymentReservation (JNI)
  ManagedPlatformWallet.releasePaymentReservation (Kotlin)

The transaction is the ownership signal: a reserved outpoint is skipped
by every other build's coin selection, so no concurrent build can hold a
reservation on any input of the transaction being released — releasing
its inputs releases precisely this build's own reservation and can never
free a competing build's coins. Same signal the internal
`release_reservation_after_rejected_broadcast` cleanup already uses.

The release consults no height, so it works pre-sync where the TTL
backstop cannot. It is idempotent (per-outpoint map removal) and a
silent no-op after a successful broadcast — it cannot resurrect a spent
coin, since selection reads the UTXO set that sync already updated — so
callers can wire it into an unconditional cleanup path.

Also routes the build's default-funding-account resolution through the
shared `bip44_account_path` helper, so a release and its build can never
disagree about what `funding_path: None` means.

Tests: release-then-reselect (with the second-build failure pinned as a
precondition so it can't pass vacuously), release twice, release after a
processed broadcast, release against the wrong account frees nothing,
unknown funding path is refused, and the height-0 case — 30 build
attempts prove the TTL never fires there, then the explicit release
frees the inputs.

Addresses review item 3 on dashpay#4247. The reviewer's
suggestion to unify this with dashpay#4256's reservation-token finalize is
tracked separately rather than done here, to avoid reshaping an API the
Android app already calls and hard-coupling this PR to dashpay#4185.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t + finalize (dashpay#4185 review)

Wallet removal was not linearized with the deferred-payment registry, so a
retained handle could push a removed wallet's payment onto the network.

Two independent windows:

1. remove-then-sweep. `platform_wallet_manager_remove_wallet` called
   `manager.remove_wallet` and only afterwards swept the registry, with no
   shared lock spanning the two — and the removal's own awaits (shielded
   coordinator + identity-sync unregistration) sat in the gap. A concurrent
   `core_wallet_signed_payment_broadcast` in that window passed every guard:
   `is_same_generation` compares two handles, so a removed generation matches
   itself; `last_processed_height` is `None` once the wallet is gone and
   `reservation_expired` maps `None` to "not expired"; and
   `broadcast_payment_releasing_reservation` has no wallet-existence gate.

2. in-flight finalizer. `finalize_transaction` drops the manager write lock
   before awaiting the signer, and `register` only validates the payment
   against its finalizing generation — never that the generation still exists.
   A removal during the signer await swept the registry, then the finalizer
   inserted a fresh token no later sweep would catch, contradicting the
   documented teardown invariant that dropping tokens makes stale handles inert.

Remedies:

* `SignedPaymentRegistry` gains a lifecycle gate (`tokio::RwLock`). Teardown
  takes the exclusive side across BOTH the manager removal and the sweep, making
  them one linearization point; broadcast and release take the shared side for
  their whole duration. The existing `entries` mutex cannot do this — it is
  dropped before every await by design. Lock order is always gate then manager.
* Broadcast rejects an absent current generation via the new
  `CoreWallet::is_current_generation`, returning `SignedPaymentError::
  WalletRemoved` instead of silently proceeding to the broadcaster.
* `core_wallet_signed_payment_finalize` holds the shared gate across its
  liveness check and the synchronous `register`, abandoning the payment
  (reconciling its reservation) if the wallet went away during signing. The gate
  is taken after the signer await, not around it, so an open signing prompt
  cannot stall teardown.

No new FFI error code: the wallet-removed case is reported as the existing
`NotFound` (98), which both hosts already map. Deliberately avoids the 29/30
renumbering contested in dashpay#4261. Swift/Kotlin/Rust docs updated to record that
98 now also carries this case, and how it differs from
`ErrorReservationWalletMismatch` (29).

Adds three FFI regression tests. All three fail against the pre-fix code — the
race test reports a payment reaching the broadcaster after teardown completed.
Also serializes the registry-count-asserting tests, which the new tests would
otherwise race in the shared process-global registry.

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

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

🤖 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 `@Cargo.toml`:
- Around line 55-62: Replace the personal-fork git sources for all eight
rust-dashcore dependencies—dashcore, dash-network-seeds, dash-spv, key-wallet,
key-wallet-ffi, key-wallet-manager, dash-network, and dashcore-rpc—with the
corresponding dashpay/rust-dashcore upstream source, pinned to the required
upstream revision after confirming it exists there. Do not merge the bfoss765
fork revision; if its changes are required, use an upstream revision containing
them.

In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt`:
- Around line 299-301: Update ErrorReservationWalletMismatch to discriminant 30
everywhere: change the mapping and trailing comment in DashSdkError.kt lines
299-301, revise its class KDoc at line 210 and code-98 comment at line 286,
update the KDoc reference in WalletManagerNative.kt lines 269-279, and change
the offset assertion in DashSdkErrorTest.kt lines 106-132 from 29 to 30. Keep
all Kotlin documentation, mapping, and test expectations consistent with the
native discriminant.

In
`@packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt`:
- Around line 41-52: Explicitly close the SignedCoreTransaction instance in the
test after the assertions, using signed.close() so its cleaner backstop is
disarmed before the object becomes unreachable. Preserve the existing ownership
compile-time check and transaction assertions.
🪄 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: aa0a1c5a-6af0-45f8-a802-affd47b0ef72

📥 Commits

Reviewing files that changed from the base of the PR and between 32cd702 and 0b0d5c7.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • 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
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.kt

Comment thread Cargo.toml
Comment on lines +55 to +62
dashcore = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dash-network-seeds = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dash-spv = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
key-wallet = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
key-wallet-ffi = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
key-wallet-manager = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dash-network = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dashcore-rpc = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }

@coderabbitai coderabbitai Bot Aug 1, 2026

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.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not merge workspace dependencies pinned to a personal fork.

All eight rust-dashcore crates now resolve from https://github.com/bfoss765/rust-dashcore at rev e99959ced0062159d629930f488374e29f63c42b. A personal fork is outside the organization's control: the owner can force-push, rename, or delete the repository, and the build then fails or silently resolves unaudited code. This also makes the whole wallet, SPV, and key-derivation stack unreviewed relative to dashpay/rust-dashcore.

This change is also unrelated to the deferred signed-payment work in this PR. If the fork carries a required upstream change, land that change in dashpay/rust-dashcore first, then pin this workspace to the resulting upstream revision before merge.

🔒 Proposed change once the upstream revision exists
-dashcore = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-dash-network-seeds = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-dash-spv = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-key-wallet = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-key-wallet-ffi = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-key-wallet-manager = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-dash-network = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
-dashcore-rpc = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
+dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }
+dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "<upstream-rev>" }

Run the following script to check whether the pinned revision exists upstream:

#!/bin/bash
# Check whether the pinned rev is reachable in the upstream org repository.
REV=e99959ced0062159d629930f488374e29f63c42b
echo "--- upstream dashpay/rust-dashcore ---"
gh api "repos/dashpay/rust-dashcore/commits/$REV" --jq '.sha, .commit.message' 2>&1 | head -5
echo "--- fork bfoss765/rust-dashcore ---"
gh api "repos/bfoss765/rust-dashcore" --jq '.full_name, .fork, .private, .parent.full_name' 2>&1 | head -5
echo "--- other fork/personal git deps in the workspace ---"
fd -t f 'Cargo.toml' --exec rg -n 'git = "https://github.com/(?!dashpay/)' -P {} \; 2>/dev/null | head -40
🤖 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 `@Cargo.toml` around lines 55 - 62, Replace the personal-fork git sources for
all eight rust-dashcore dependencies—dashcore, dash-network-seeds, dash-spv,
key-wallet, key-wallet-ffi, key-wallet-manager, dash-network, and
dashcore-rpc—with the corresponding dashpay/rust-dashcore upstream source,
pinned to the required upstream revision after confirming it exists there. Do
not merge the bfoss765 fork revision; if its changes are required, use an
upstream revision containing them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — this is a genuine merge blocker, and it can't be fixed inside this PR.

The eight crates are pinned to bfoss765/rust-dashcore @ e99959ce because this
PR depends on an unreleased key-wallet change: owner-tagged reservations, which
close the broadcast/release TOCTOU that the deferred build → broadcast lifecycle
in this PR is built on. Without it the reservation set can't tell which owner a
release belongs to, and the split-build feature has no safe primitive to sit on.

That change is upstream as dashpay/rust-dashcore#916
("feat(key-wallet): owner-tagged reservations to close the broadcast-release
TOCTOU (platform#4185)"). It is open and currently conflicting; we're working it now. This PR is blocked on #916 merging and should not go in before it.

On the "does the rev exist upstream" check in your script — it needs one
correction, because the naive form gives a false pass. gh api repos/dashpay/rust-dashcore/commits/e99959ce does return the commit, but only
because forks share object storage with the parent; resolvability is not
reachability. The real check is against a branch:

$ gh api repos/dashpay/rust-dashcore/compare/dev...e99959ce --jq .status
diverged        # ahead_by=1, behind_by=6
$ gh api repos/dashpay/rust-dashcore/commits/e99959ce/branches-where-head
                # empty

So the pin is fork-only, exactly as you say: one commit ahead of a point on
dev and six behind, on no upstream branch.

Plan of record: once #916 lands, this workspace repins all eight crates to
https://github.com/dashpay/rust-dashcore at the resulting upstream revision —
no bfoss765 remotes in the merged tree. I'll leave this thread open as the
merge gate.

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.

@bfoss765, acknowledged. The explanation confirms that the personal-fork pin is a required temporary dependency for owner-tagged reservations in #916.

You are correct about the validation detail. A successful commits/<sha> response only proves object availability. It does not prove that an upstream branch reaches the revision. The branch reachability checks are the correct validation.

I will keep this thread open. Before this PR merges, it must repin all eight workspace dependencies to https://github.com/dashpay/rust-dashcore at an upstream revision that contains #916.

You are interacting with an AI system.

bfoss765 and others added 2 commits August 1, 2026 15:18
…30 (dashpay#4185 review)

Code 29 collided with `ErrorAssetLockInsufficientFunds` on dashpay#4184. Per the
resolution of record in dashpay#4261's ERROR_CODE_REGISTRY.md, dashpay#4184 keeps 29 and this
PR moves to 30.

Verified 30 was genuinely free by reading `rs-platform-wallet-ffi/src/error.rs`
at the head of all 62 open PRs: no PR defines a code 30. The
`ErrorAssetLockCrossDomainConsentRequired` that in-tree comments name as 30's
holder does not exist anywhere after dashpay#4184's re-scope.

The discriminant is public ABI, so every mirror moves together:
  - Rust enum + its three rustdoc cross-references (error.rs)
  - two doc references in core_wallet/signed_payment.rs
  - JNI rustdoc (rs-unified-sdk-jni/src/wallet_manager.rs)
  - Swift PlatformWalletResultCode raw value + doc
  - Kotlin fromPlatformWalletNative branch, class KDoc, code-98 comment,
    WalletManagerNative KDoc, and the DashSdkErrorTest offset assertion

Both Swift switches are symbolic (cbindgen `PLATFORM_WALLET_FFI_RESULT_CODE_*`
constants), so only the enum raw value carried the number.

Also disarms the NativeCleaner backstop in SignedCoreTransactionTest by closing
the SignedCoreTransaction, so the armed native release cannot fire from the
cleaner thread in a pure-JVM test.

Note: dashpay#4256 is stacked downstream and still carries the pre-renumber 29; it must
adopt 30 on rebase.
…the final-alias policy removal (dashpay#4185 review)

Blocker 2 removed the final-alias sweep from `platform_wallet_destroy`
(wallet.rs:392-414 is now just a storage `remove`), but the helper that
policy was introduced for survived it.

`HandleStorage::any` was added by ade3999 for that sweep and has had
zero call sites since the policy was dropped. Because `handle` is a
`pub mod` and the method is `pub`, no dead-code lint fires and it stayed
in the crate's public Rust surface, with Rustdoc still pointing at "the
final-alias check in `platform_wallet_destroy`" — a policy that no longer
exists. It is not on the base branch, so removing it restores the base
surface rather than breaking an existing consumer.

`HandleStorage::remove_matching` is retained: it still backs the
generation sweep at manager.rs:494.

Also corrects a doc cross-reference to the same removed policy in
`test_support::test_platform_wallet_manager`, which described the helper
as backing "final-alias registry-sweep gating" when its only FFI consumer
now asserts the opposite (destroying wrapper aliases must NOT sweep).

No behavior change.

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

Carried forward, prior-1 is fixed at the exact reviewed head: commit 6c37e8679e removed the obsolete HandleStorage::any helper and its stale final-alias documentation. In the new lifecycle delta, the registry-token FFI path is substantially improved, but the exclusion remains attached to one process-global registry rather than shared wallet-generation state, so V2 FFI operations and direct Rust manager removal can still publish or broadcast payments after teardown. The lifecycle comments also incorrectly state that finalizers hold the gate while awaiting an external signature.

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.

🔴 1 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-ffi/src/manager.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/manager.rs:476-494: Move lifecycle exclusion into shared wallet-generation state
  The write guard taken here belongs to the FFI singleton `SIGNED_PAYMENT_REGISTRY`, so it excludes only registry-token operations rather than every operation acting on the generation being removed. The live V2 path bypasses it: `core_wallet_tx_builder_finalize` awaits the external signer and inserts into `CORE_SIGNED_TRANSACTION_V2_STORAGE` without a lifecycle guard or current-generation revalidation (`core_wallet/transaction_builder.rs:126-137`), allowing teardown to sweep while signing is pending and a late finalizer to publish a fresh handle afterward. `core_wallet_broadcast_signed_transaction_v2` likewise consumes a handle and reaches the embedded wallet's broadcaster without a guard or liveness check (`core_wallet/broadcast.rs:41-65`); two retained handles to the removed generation still pass `is_same_generation`, so the transaction can reach the network after removal and conflict with inputs selected by a re-created generation. The same coordination can be bypassed through the newly public Rust surface: `SignedPaymentRegistry` is re-exported from `platform-wallet`, while public `PlatformWalletManager::remove_wallet` never takes the registry's write side, so direct Rust removal can interleave between a token operation's `is_current_generation` check and its register or network action despite the documented read guard. Store the lifecycle gate in shared per-generation or manager state, acquire it from public removal and from both token and V2 publication/network paths, and add deterministic V2 and direct-manager removal race tests. This also prevents a 30-second SPV broadcast for one wallet from blocking lifecycle operations for every unrelated wallet through the current process-global lock. V2 abandon/free cleanup is generation-bound and does not itself require exclusion; the unsafe gaps are late handle publication and network access.

Comment on lines +476 to +494
let _teardown = crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY
.lifecycle_write()
.await;

let removed = manager.remove_wallet(wallet_id).await?;

// 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: Move lifecycle exclusion into shared wallet-generation state

The write guard taken here belongs to the FFI singleton SIGNED_PAYMENT_REGISTRY, so it excludes only registry-token operations rather than every operation acting on the generation being removed. The live V2 path bypasses it: core_wallet_tx_builder_finalize awaits the external signer and inserts into CORE_SIGNED_TRANSACTION_V2_STORAGE without a lifecycle guard or current-generation revalidation (core_wallet/transaction_builder.rs:126-137), allowing teardown to sweep while signing is pending and a late finalizer to publish a fresh handle afterward. core_wallet_broadcast_signed_transaction_v2 likewise consumes a handle and reaches the embedded wallet's broadcaster without a guard or liveness check (core_wallet/broadcast.rs:41-65); two retained handles to the removed generation still pass is_same_generation, so the transaction can reach the network after removal and conflict with inputs selected by a re-created generation. The same coordination can be bypassed through the newly public Rust surface: SignedPaymentRegistry is re-exported from platform-wallet, while public PlatformWalletManager::remove_wallet never takes the registry's write side, so direct Rust removal can interleave between a token operation's is_current_generation check and its register or network action despite the documented read guard. Store the lifecycle gate in shared per-generation or manager state, acquire it from public removal and from both token and V2 publication/network paths, and add deterministic V2 and direct-manager removal race tests. This also prevents a 30-second SPV broadcast for one wallet from blocking lifecycle operations for every unrelated wallet through the current process-global lock. V2 abandon/free cleanup is generation-bound and does not itself require exclusion; the unsafe gaps are late handle publication and network access.

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 d1bed4dMove lifecycle exclusion into shared wallet-generation state 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 +470 to +475
// * Acquiring it WAITS for in-flight payment operations. A finalize that is
// mid-signature holds the shared side (`finalize_transaction` drops the
// manager write lock before awaiting the signer, so nothing else stops
// it), so it runs to its liveness check and either registers before we
// start — and is swept below — or observes the removal and abandons.
// Either way it can no longer insert a token AFTER the sweep has run.

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: Correct the finalizer lifecycle-gate comments

A finalizer does not hold the shared lifecycle guard while awaiting the external signer. core_wallet_signed_payment_finalize acquires it only after finalize_transaction and the signer await complete (core_wallet/transaction_builder.rs:220-240), deliberately allowing teardown to finish during an open signing prompt; the subsequent liveness check then rejects registration. This comment and the equivalent Rustdoc at signed_payment_registry.rs:353-356 describe the opposite behavior and could cause future tests or changes to assume that removal waits for signing.

Suggested change
// * Acquiring it WAITS for in-flight payment operations. A finalize that is
// mid-signature holds the shared side (`finalize_transaction` drops the
// manager write lock before awaiting the signer, so nothing else stops
// it), so it runs to its liveness check and either registers before we
// start — and is swept below — or observes the removal and abandons.
// Either way it can no longer insert a token AFTER the sweep has run.
// * Acquiring it waits for payment operations that have entered their
// post-signing liveness-check/register section. A finalizer still awaiting
// the external signer holds no gate; if teardown completes first, its later
// liveness check observes the missing generation and abandons. If the
// finalizer reaches the shared gate first, it registers before teardown and
// the sweep below removes its token.

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 d1bed4dCorrect the finalizer lifecycle-gate comments 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/platform that referenced this pull request Aug 2, 2026
…30 (dashpay#4256)

dashpay#4256 is stacked on dashpay#4185 and still carried the pre-renumber `29`, which now
collides with `ErrorAssetLockInsufficientFunds = 29` on dashpay#4184. Per the
resolution of record in dashpay#4261's ERROR_CODE_REGISTRY.md, dashpay#4184 keeps 29 and the
`ErrorReservationWalletMismatch` family moves to 30; dashpay#4185 already made that
move in `d854debb`. This brings dashpay#4256 in line.

CI could not have caught this: dashpay#4256 and dashpay#4184 are both MERGEABLE with green
checks, because two branches assigning the same discriminant produce no textual
conflict. It surfaces only as an E0081 after a textual merge, or silently as a
wrong error code on the host.

The discriminant is public ABI, so every mirror moves together:
  - Rust enum + its rustdoc cross-reference (error.rs)
  - doc reference in core_wallet/signed_payment.rs
  - JNI rustdoc (rs-unified-sdk-jni/src/wallet_manager.rs)
  - Swift PlatformWalletResultCode raw value
  - Kotlin fromPlatformWalletNative branch, class KDoc, WalletManagerNative
    KDoc, and the DashSdkErrorTest offset assertion

Both Swift switches are symbolic (cbindgen `PLATFORM_WALLET_FFI_RESULT_CODE_*`
constants), so only the enum raw value carried the number.

Also corrects this PR's own numbering rationale on `ErrorTransactionSigning`
(33), which claimed 30 was "reserved for dashpay#4184's
ErrorAssetLockCrossDomainConsentRequired". That code does not exist on any
branch — dashpay#4184 dropped it in a re-scope — and 30 is now
ErrorReservationWalletMismatch. dashpay#4256's codes are unchanged otherwise: it keeps
32 (ErrorTransactionBuild, shared with dashpay#4247) and 33.

Verified: cargo fmt --all -- --check clean; cargo test -p platform-wallet-ffi
-p platform-wallet = 805 passed / 0 failed; :sdk:test BUILD SUCCESSFUL with
DashSdkErrorTest 9/9. Swift is unverified — it cannot be compiled here.
bfoss765 added a commit to bfoss765/platform that referenced this pull request Aug 2, 2026
…ollisions

The 29 collision is resolved and the renumber has now landed on dashpay#4185's branch:
dashpay#4184 keeps 29 (ErrorAssetLockInsufficientFunds), dashpay#4185 takes 30
(ErrorReservationWalletMismatch). Table rows updated to match the code.

Fixes the "30 is both free and assigned" inconsistency: the next-free line
claimed 27-33 were claimed while the table showed 30 unallocated. 30 is now
genuinely allocated to dashpay#4185, so the two agree.

Adds allocations the survey had omitted, verified 2026-08-01 by reading
error.rs at the head of all 62 open PRs:
  - dashpay#3968 numbers 26/27/28 (Persister* + a pre-merge TransactionBroadcastRejected)
    -> contradicts merged ABI at 26 and collides with dashpay#4185 at 27 and 28
  - dashpay#3954 numbers ErrorShutdownIncomplete = 27 -> collides with dashpay#4185 at 27
  - dashpay#4259 carries ErrorSigningKeyUnavailable = 31, inherited from dashpay#4183 rather
    than a new allocation

The same sweep confirms no open PR anywhere defines a code 30.
bfoss765 added a commit to bfoss765/platform that referenced this pull request Aug 2, 2026
…cord dashpay#4196 scope

Clears the two review blockers on dashpay#4261 and re-syncs the registry with what the
code on each branch actually does, re-read at every head rather than trusted
from this file.

Blocker (a) — dashpay#3968 / dashpay#3954 / dashpay#4259 were described in prose but had no rows,
which is exactly what rule 2 forbids. They now have them:

  - A "Non-conforming allocations" table for dashpay#3968 (26/27/28) and dashpay#3954 (27).
    These are deliberately kept out of the proposed table: each row is a claim
    to be withdrawn and reissued, not an allocation of record.
  - An inherited-code table for the 31 that dashpay#4204 and dashpay#4259 carry but did not
    allocate (dashpay#4183 owns it), so it is not double-counted.
  - dashpay#4196 is recorded as claiming no integer at all: it routes a new token-less
    `StaleReservation` variant through the existing `ErrorStaleReservationToken`.

The dashpay#3968 half is the serious one and is called out as such. Its 28 is not a new
claim — it *moves the already-shipped* `ErrorTransactionBroadcastRejected` off 26
to make room for its own persister code. Rule 3 forbids that: a host compiled
against merged ABI returns 26 for a broadcast rejection, and after dashpay#3968 the same
condition returns 28 while 26 means a transient persister failure. Neither
branch's diff shows the contradiction.

Blocker (b) — 30 marked both free and assigned was already resolved by the
preceding commit; verified consistent here (30 is allocated to dashpay#4185 throughout,
frontier is 34, and the one remaining "genuinely free" is past tense explaining
why dashpay#4185 could take it).

Also corrected, all verified against the branches:

  - Survey provenance had dashpay#4185 at `0b0d5c76d6` labelled "(post-renumber)". Wrong
    twice: that commit is the *parent* of the renumber `d854debb`, and the head
    has since moved to `6c37e8679e`. dashpay#4184, dashpay#4247 and dashpay#4256 SHAs refreshed too.
  - dashpay#4256 has now taken 30 (`9481e5783b`) and dropped its stale "30 is reserved
    for the consent code" rationale; the equivalent comments on dashpay#4183 and dashpay#4204
    are flagged as still present.
  - dashpay#4184 has a comment-only drift: it reserves "Codes 27-28" but names three
    codes. Correct when the trio was 27/28/29; it is now 27/28/30. Its
    discriminant is right and is the resolution of record — only the prose is
    stale, and dashpay#4184 is left untouched.
  - The dashpay#4196 section now records why the restack has not happened: its three
    own commits conflict in 3 files / 10 hunks against dashpay#4185's head, and the
    registry redesign underneath it (mandatory `registered_height`, new
    `WalletRemoved` variant, owner-stamped funding token) makes it author work
    rather than conflict resolution. Its trio numbers come from the dashpay#4185 copy
    it carries, so the restack fixes 28 -> 30 for free; the number dashpay#4196 itself
    must chase is 27, not 30.

Verified: cargo fmt --all -- --check clean; cargo test -p platform-wallet-ffi
-p platform-wallet = 738 passed / 0 failed. Docs-only change.
…on (dashpay#4185 review)

The gate added in 0b0d5c7 lived on the FFI's process-global
`SIGNED_PAYMENT_REGISTRY`, so it excluded only registry-token operations and did
so across every wallet at once. Two consequences, both real:

1. Under-coverage. The V2 finalized-transaction-handle path bypassed it
   entirely. `core_wallet_tx_builder_finalize` awaited the external signer and
   then inserted into `CORE_SIGNED_TRANSACTION_V2_STORAGE` with no gate and no
   liveness re-check, so a teardown could sweep while signing was pending and
   the late finalizer published a handle no sweep would ever catch.
   `core_wallet_broadcast_signed_transaction_v2` then consumed such a handle and
   reached the broadcaster with no gate either — its `is_same_generation` check
   compares two HANDLES, and a removed generation matches itself. The same hole
   existed on the public Rust surface: `PlatformWalletManager::remove_wallet`
   never took the write side at all, so a direct embedder (the manager is public
   and `SignedPaymentRegistry` is re-exported) removed wallets with no exclusion.

2. Cross-wallet contention. A deferred broadcast holds the shared side across an
   SPV send; on one process-global write-preferring lock that send blocked
   teardown — and every payment operation queued behind the waiting writer — for
   every unrelated wallet in the process.

Remedy: move the gate into shared per-generation state.

* New `WalletGeneration` owns the lock-free `WalletBalance` AND that
  generation's `RwLock` lifecycle gate, and replaces `Arc<WalletBalance>` as the
  generation-identity marker. Folding them into one `Arc` is deliberate: the
  identity and the gate cannot diverge, so two handles can never compare as the
  same generation while excluding each other through different locks. `Deref`
  keeps every existing balance read unchanged.
* `PlatformWalletManager::remove_wallet_with_teardown` takes that generation's
  exclusive gate across BOTH the removal and a caller-supplied teardown hook,
  and `remove_wallet` routes through it. The gate is no longer optional for any
  caller, FFI or not. The FFI passes its registry + V2-handle sweep as the hook.
  Lock order stays gate-then-manager: the lookup that resolves the gate drops
  `wallets` before awaiting it, then re-validates under the gate.
* Every publication/network path now takes the generation's shared gate across
  its liveness check and the action: the registry `broadcast`/`release`, the
  token `core_wallet_signed_payment_finalize`, and — newly — both
  `core_wallet_tx_builder_finalize` and
  `core_wallet_broadcast_signed_transaction_v2`, which report a dead generation
  as the existing `NotFound` (98) after reconciling the build's reservation.
  V2 abandon/free stay ungated: their release is already generation-bound.

The gate is still NOT held across an external signer await — finalizers acquire
it only after the signature returns, so an open signing prompt cannot stall
teardown, and a late finalizer instead fails its liveness check and abandons.
The lifecycle comments that claimed the opposite were wrong about the code and
are corrected.

Adds four deterministic FFI regression tests alongside the existing three:
a V2 broadcast-after-removal refusal, a teardown that waits for an in-flight V2
operation and then sweeps its handle, a public
`PlatformWalletManager::remove_wallet` that waits for an in-flight payment, and
a cross-wallet isolation test pinning the per-generation scoping. With the three
guards removed the first three fail; with the gate re-pointed at a single
process-global lock the fourth fails.

No error-code changes: `ErrorReservationWalletMismatch` stays 30.

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

bfoss765 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Verified all three findings at 6c37e8679e before changing anything — all three hold. Fixed in d1bed4dc78.

The V2 path was ungated on both ends: core_wallet_tx_builder_finalize inserted into CORE_SIGNED_TRANSACTION_V2_STORAGE with no gate and no liveness re-check after the signer await, and core_wallet_broadcast_signed_transaction_v2 reached broadcast_finalized_transaction with neither — its is_same_generation check compares two handles, and a removed generation matches itself. Public removal took no exclusion at all; the gate lived only in the FFI wrapper, so a direct Rust embedder bypassed the linearization entirely. Contention was as described: one Lazy static, the shared side held across the SPV send, write-preferring lock — so one wallet's send blocked every other wallet's teardown, and their payments behind the queued writer.

On the nitpick — agreed, and the comments were the thing that was wrong. Finalizers deliberately take the gate after the signature returns, because holding it across an open signing prompt would stall teardown for every wallet. Corrected in manager.rs and in the gate's Rustdoc.

Design. The gate now lives in a new WalletGeneration that is the generation-identity marker — it owns the lock-free WalletBalance and that generation's RwLock, replacing Arc<WalletBalance> wherever identity was compared. One Arc rather than a parallel gate Arc is deliberate: threaded separately through ~15 construction sites, a future site could clone the identity but mint a fresh gate, and two handles would then compare as the same generation while excluding each other through different locks — a silent loss of exclusion with nothing to fail on. Deref<Target = WalletBalance> keeps every existing balance read unchanged.

PlatformWalletManager::remove_wallet_with_teardown holds the generation's exclusive gate across both the removal and a caller-supplied synchronous teardown hook, and remove_wallet routes through it, so the gate cannot be skipped by any caller; the FFI passes its registry + V2-handle sweep as that hook. Lock order stays gate -> manager: the lookup drops wallets before awaiting the gate, then re-validates under it and retries if the generation changed.

Paths gated (swept rather than taken from the finding's list): registry broadcast and release — the latter has no wallet argument, so it peeks the entry's generation under the map mutex, drops it, takes that gate, then consumes — plus core_wallet_signed_payment_finalize, and newly core_wallet_tx_builder_finalize and core_wallet_broadcast_signed_transaction_v2. Both new ones report a dead generation as the existing NotFound (98) after reconciling the reservation, so nothing is stranded.

Also swept: the third CORE_SIGNED_TRANSACTION_V2_STORAGE.insert is inside #[cfg(test)]; V2 abandon/free stay ungated since their release is already generation-bound under a single manager-lock hold. One judgment call flagged rather than skipped silently — the immediate-send path (core_wallet_broadcast_transaction) retains no handle across teardown, so it sits outside the deferred-lifecycle contract and I did not gate it. Say if you'd rather it were covered.

Tests: four new deterministic ones. With the three guards removed, broadcasting_a_v2_handle_for_a_removed_wallet_is_refused_before_the_network, teardown_waits_for_an_in_flight_v2_operation_and_then_sweeps_its_handle and public_remove_wallet_waits_for_an_in_flight_payment_on_that_generation all fail; with the gate re-pointed at a single process-global lock, an_in_flight_payment_does_not_block_an_unrelated_wallets_teardown fails with Elapsed.

Worth flagging: I first wrote two of these in the completion-order style of the existing a_broadcast_never_reaches_the_broadcaster_after_teardown_completed, and they passed against the broken code on some runs. That style is timing-dependent and isn't a reliable gate; they were replaced with the deterministic forms above.

ErrorReservationWalletMismatch stays 30; HandleStorage::any stays removed with remove_matching retained at its live call site. cargo fmt --check clean; cargo test -p platform-wallet-ffi -p platform-wallet green — 513 + 212 lib tests, 0 failures across all 11 result lines. Dependent crates all still compile (the PlatformWalletInfo.balance -> .generation rename is source-breaking).

@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

Both carried-forward lifecycle findings are fixed at d1bed4d: lifecycle exclusion is now shared per wallet generation across direct removal, registry-token operations, and V2 operations, and the finalizer comments match the post-signing gate acquisition. The verifier ran the lifecycle and registry suites successfully (7/7 and 19/19), but the current head still has two blockers: same-ID registration can race the manager's two-map removal sequence, and all rust-dashcore dependencies remain pinned to an unmerged contributor fork. Three additional in-scope suggestions cover generation-state mutability, deterministic Kotlin cancellation cleanup, and typed Kotlin handling for definitive broadcast rejection.

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)

🔴 2 blocking | 🟡 3 suggestion(s)

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

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

In `packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs:655-687: Remove the same wallet generation from both maps atomically
  The retry loop acquires generation G1's teardown gate and verifies that `self.wallets` still names G1, but removal then happens in two independently locked stages. After G1 is removed from the inner `wallet_manager`, that lock is released before `self.wallets` is acquired. A concurrent same-ID registration can now insert generation G2 into the inner manager and publish G2 into `self.wallets`; if that publication wins on a multithreaded executor, this remover takes G2 from the outer map and invokes `tear_down` for G2 while holding only G1's gate. G2 remains registered in the inner manager but disappears from the public map, and its registry tokens or V2 handles can be swept without excluding G2's payment operations. Give registration and removal a single per-ID ordering, or retain the verified G1 `Arc<PlatformWallet>` and remove the outer entry only when it still pointer-matches G1. Add a deterministic remove-versus-recreate regression test.

In `Cargo.toml`:
- [BLOCKING] Cargo.toml:55-62: Do not merge workspace-wide dependencies from a personal fork
  All eight rust-dashcore workspace dependencies are redirected from `dashpay/rust-dashcore` to `bfoss765/rust-dashcore` at e99959ce. That revision is only the first commit of dashpay/rust-dashcore#916, which is currently OPEN with `mergeStateStatus=BLOCKED` and `reviewDecision=REVIEW_REQUIRED`; the upstream PR has since added further fixes, including owner-guarded cleanup for asset-lock downstream failures. Merging this head would therefore make the entire workspace depend on a contributor-controlled repository and a stale, unmerged implementation. Land the reservation API and its follow-up fixes in the Dash-owned repository, then repin these entries and `Cargo.lock` to the corresponding Dash-owned revision.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:52: Keep the generation identity immutable outside the crate
  `PlatformWalletInfo` is exposed mutably through `PlatformWallet::state_mut()` and `state_mut_blocking()`, so downstream safe Rust can replace this `Arc<WalletGeneration>`. `PlatformWallet` and `CoreWallet` retain the original Arc, causing the inner manager's generation identity and lifecycle gate to diverge from every existing handle. `is_current_generation()` then rejects the otherwise live wallet, generation-bound reservation cleanup becomes a no-op, and teardown can exclude through a different gate than payment operations. This synchronization identity must not be publicly assignable.

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:316: Release the registered token when cancellation discards the result
  Native finalization registers the payment and transfers reservation ownership to a token before the blocking JNI call returns. `gate.op` uses `withContext(Dispatchers.IO)`, whose prompt-cancellation handoff can discard the newly constructed `SignedCoreTransaction` when the caller is cancelled while JNI is running. The only remaining cleanup is then the GC Cleaner, so the token and funding reservation can remain held until an unpredictable GC cycle or the reservation TTL. Use the existing `opWithCleanupOnCancellation` helper to close a completed result that cancellation prevents from reaching the caller.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt:296-309: Preserve definitive broadcast rejection as a typed Kotlin error
  A definitive deferred-payment broadcast rejection is preserved as native code 26 through the C ABI and JNI, but this switch falls through to `PlatformWallet.Generic`. Callers can inspect `Generic.nativeCode` manually, but the public typed hierarchy otherwise distinguishes ambiguous broadcast outcomes and all deferred-token failures. The new `broadcastSigned` API should likewise expose definitive rejection as a dedicated subtype so callers can distinguish a consumed-and-released rejected payment, which must be rebuilt or corrected, from unrelated generic wallet failures. Add the subtype, map code 26, and pin its non-retry-in-place/rebuild semantics in `DashSdkErrorTest`.

Comment thread Cargo.toml
Comment on lines +55 to +62
dashcore = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dash-network-seeds = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dash-spv = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
key-wallet = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
key-wallet-ffi = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
key-wallet-manager = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dash-network = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }
dashcore-rpc = { git = "https://github.com/bfoss765/rust-dashcore", rev = "e99959ced0062159d629930f488374e29f63c42b" }

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 merge workspace-wide dependencies from a personal fork

All eight rust-dashcore workspace dependencies are redirected from dashpay/rust-dashcore to bfoss765/rust-dashcore at e99959ce. That revision is only the first commit of dashpay/rust-dashcore#916, which is currently OPEN with mergeStateStatus=BLOCKED and reviewDecision=REVIEW_REQUIRED; the upstream PR has since added further fixes, including owner-guarded cleanup for asset-lock downstream failures. Merging this head would therefore make the entire workspace depend on a contributor-controlled repository and a stale, unmerged implementation. Land the reservation API and its follow-up fixes in the Dash-owned repository, then repin these entries and Cargo.lock to the corresponding Dash-owned revision.

source: ['codex']

/// This wallet generation's shared state: the lock-free balance for UI reads
/// (updated from `ManagedWalletInfo` after each SPV block/mempool processing
/// and RPC refresh) and the generation's lifecycle gate.
pub generation: Arc<WalletGeneration>,

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: Keep the generation identity immutable outside the crate

PlatformWalletInfo is exposed mutably through PlatformWallet::state_mut() and state_mut_blocking(), so downstream safe Rust can replace this Arc<WalletGeneration>. PlatformWallet and CoreWallet retain the original Arc, causing the inner manager's generation identity and lifecycle gate to diverge from every existing handle. is_current_generation() then rejects the otherwise live wallet, generation-bound reservation cleanup becomes a no-op, and teardown can exclude through a different gate than payment operations. This synchronization identity must not be publicly assignable.

Suggested change
pub generation: Arc<WalletGeneration>,
pub(crate) generation: Arc<WalletGeneration>,

source: ['codex']

coreSignerHandle: Long,
accountType: AccountType = AccountType.BIP44,
accountIndex: Int = 0,
): SignedCoreTransaction = gate.op {

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 registered token when cancellation discards the result

Native finalization registers the payment and transfers reservation ownership to a token before the blocking JNI call returns. gate.op uses withContext(Dispatchers.IO), whose prompt-cancellation handoff can discard the newly constructed SignedCoreTransaction when the caller is cancelled while JNI is running. The only remaining cleanup is then the GC Cleaner, so the token and funding reservation can remain held until an unpredictable GC cycle or the reservation TTL. Use the existing opWithCleanupOnCancellation helper to close a completed result that cancellation prevents from reaching the caller.

Suggested change
): SignedCoreTransaction = gate.op {
): SignedCoreTransaction = gate.opWithCleanupOnCancellation(
cleanup = { payment: SignedCoreTransaction -> payment.close() },
) {

source: ['codex']

Comment on lines 296 to 309
@@ -245,6 +301,11 @@ sealed class DashSdkError(
23 -> PlatformWallet.AssetLockNotTracked(message, cause) // ErrorAssetLockNotTracked
24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed
25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch
27 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken
28 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed
// 29 is ErrorAssetLockInsufficientFunds (dashpay/platform#4184); this
// code is 30. See packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md.
30 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch
else -> PlatformWallet.Generic(code, message, cause)

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: Preserve definitive broadcast rejection as a typed Kotlin error

A definitive deferred-payment broadcast rejection is preserved as native code 26 through the C ABI and JNI, but this switch falls through to PlatformWallet.Generic. Callers can inspect Generic.nativeCode manually, but the public typed hierarchy otherwise distinguishes ambiguous broadcast outcomes and all deferred-token failures. The new broadcastSigned API should likewise expose definitive rejection as a dedicated subtype so callers can distinguish a consumed-and-released rejected payment, which must be rebuilt or corrected, from unrelated generic wallet failures. Add the subtype, map code 26, and pin its non-retry-in-place/rebuild semantics in DashSdkErrorTest.

source: ['codex']

…y key

`remove_wallet_with_teardown` validated generation G1 under G1's lifecycle
gate, then removed it from the two manager maps in two independently locked
stages. Registration takes no gate at all — `register_wallet` mints its own
`WalletGeneration` — so from the moment the inner-manager removal frees the
id, a concurrent same-id registration can publish a different generation G2
into `wallet_manager` and then into `self.wallets`, with no happens-before
edge to the remover's own `self.wallets` acquisition.

A remover descheduled in that gap resumed into a map naming G2 and removed
the entry BY KEY: it evicted a live wallet (still registered in the inner
manager, so invisible and unremovable through the public map), returned it
to the caller, and handed it to `tear_down` — which sweeps that generation's
registry tokens and V2 finalized-transaction handles while holding only G1's
gate, i.e. with G2's payment operations not excluded. That exclusion is the
one property the gate exists to provide.

Retain the `Arc<PlatformWallet>` validated under the gate and remove the
public-map entry only while it still pointer-matches that generation, so the
removed handle, the returned handle and the `tear_down` argument are all the
one generation this call validated. The inner-manager removal needs no such
check: G1 can only leave `wallet_manager` through this method (which requires
G1's gate) or through a rollback for an insert that could not have happened
while G1 occupied the id.

Regression test `removal_leaves_a_generation_registered_during_it_intact`
drives the real `create_wallet_from_seed_bytes` -> `register_wallet` path
from a `cfg(test)` rendezvous fired in the exact window, so the interleaving
is pinned with no sleep and no completion-order race. Against the previous
code it fails on all three load-bearing assertions: the returned generation,
the `tear_down` argument, and the survival of the re-registered wallet in the
public map.

Refs dashpay#4185

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

bfoss765 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Blocker 1 — wallet_lifecycle.rs:655-687, same-ID registration racing the two-map removal: confirmed and fixed in 6be67488.

The finding is correct, and the mechanism is worth naming precisely: registration is deliberately ungated — register_wallet mints its own WalletGeneration (:186) and never takes a teardown gate, so a create never queues behind an unrelated wallet's removal. The consequence is that from the moment wm.remove_wallet (:672) frees the id, nothing orders a concurrent same-id register_wallet against this remover's own self.wallets acquisition (:683) — the two are separated only by a guard drop, with no lock and no await between them.

A remover descheduled there (OS preemption on a multi-threaded executor, or tokio's cooperative budget making RwLock::write() reschedule even on a free permit) resumes into a map that already names G2, removes it by key, returns it, and hands it to tear_down — which sweeps G2's registry tokens and V2 handles while holding only G1's gate. G2 then survives in wallet_manager but is gone from self.wallets: get_wallet returns None and a re-issued remove_wallet returns WalletNotFound, yet is_current_generation() reads the inner manager and still reports live, so G2's retained handles stay broadcastable. load_from_persistor is a second ungated publisher with the same wm-then-wallets shape.

I took the second option from the finding: the retry loop now carries the validated Arc<PlatformWallet> out, and the public-map entry is removed only while it still Arc::ptr_eq-matches that generation (:696-719, :762-780), with a warn! on the mismatch branch. The removed handle, the returned handle and the tear_down argument are now all the one generation this call validated. This reuses the existing generation-identity primitive rather than adding a parallel mechanism, adds no lock, and leaves lock order (gate -> manager) and the "gate not held across a signer await" property untouched.

I did not take the "single per-ID ordering" option by holding self.wallets.write() across the wallet_manager.write() acquisition. It would work, but wallet_manager.write() is SPV-contended and tokio's RwLock is write-preferring, so a remover queued on wm while holding wallets would stall BalanceUpdateHandler and the sync managers on the SPV event path.

I also did not add an identity check to the inner-manager removal. While this call holds G1's gate and G1 occupies the id, G1 can only leave wallet_manager through this same method or through a registration/load rollback for an insert that could not have happened while G1 was present — I checked all five wm.remove_wallet call sites and both insert_wallet sites. That branch would be unreachable and untestable. The reasoning is recorded in the method doc.

One thing left as-is and now documented: the shielded coordinator detach is still id-keyed and has no generation concept. A just-registered generation has not run bind_shielded and so holds no coordinator entry to detach — but that is an argument from timing, not from structure, and generation-scoping the coordinator is a separate change.

Regression test. removal_leaves_a_generation_registered_during_it_intact drives the real create_wallet_from_seed_bytes -> register_wallet path from a #[cfg(test)] rendezvous fired in the exact window.

On why a rendezvous rather than a lock-timing test: the window is bounded by two different locks, and the only lock a test could hold to park the remover inside it is self.wallets — the same lock the registration needs in order to publish. Parking the remover therefore also blocks the registration, and tokio's FIFO writer queue then puts the remover first. No sleep, barrier, or completion-order ticket can pin this ordering. With the rendezvous there is no concurrency in the test at all — one task, one fixed interleaving. That matters here: two earlier tests on this PR were written in a completion-order style and passed against provably broken code on some runs.

Against the previous code it fails deterministically (10/10) on all three load-bearing assertions: the returned generation is G2, the tear_down argument is G2, and the re-registered wallet is gone from the public map. Neither the static nor its call site exists in a non-test build.

cargo fmt --check clean; cargo test -p platform-wallet-ffi -p platform-wallet green — 514 + 212 unit, 9 + 26 + 6 integration, 0 failed. All 7 remove_wallet_lifecycle_tests pass, ErrorReservationWalletMismatch = 30 unchanged, HandleStorage::any stays removed with remove_matching retained.

Blocker 2 — Cargo.toml:55-62 fork pin: acknowledged, not addressed here. It clears when dashpay/rust-dashcore#916 merges and the entries plus Cargo.lock are repinned to the Dash-owned revision. #916 is approved, 35/36 green and mergeable, waiting only on a human CODEOWNER.

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

🧹 Nitpick comments (1)
packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs (1)

154-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared generation-gate block into one helper.

Lines 154-173 and lines 276-295 are the same gate: acquire generation_payment_guard, read is_current_generation, abandon on a dead generation, return NotFound. Only the message text differs. Both blocks must stay identical for the teardown invariant to hold on both the V2-handle path and the deferred-token path. A single helper removes the risk that a future change lands on one path only.

♻️ Suggested helper
/// Acquire this generation's payment gate and verify the generation is still
/// current. Returns `Err(result)` after reconciling the build's reservation
/// when the wallet was removed or re-created during signing.
fn enter_live_generation<'a>(
    wallet: &'a platform_wallet::PlatformWallet,
    finalized: &platform_wallet::SignedCoreTransaction,
    not_found_message: &str,
) -> Result<tokio::sync::RwLockReadGuard<'a, ()>, PlatformWalletFFIResult> {
    let (lifecycle, live) = runtime().block_on(async {
        let gate = wallet.core().generation_payment_guard().await;
        let live = wallet.core().is_current_generation().await;
        (gate, live)
    });
    if live {
        return Ok(lifecycle);
    }
    drop(lifecycle);
    runtime().block_on(wallet.core().abandon_transaction(finalized));
    Err(PlatformWalletFFIResult::err(
        PlatformWalletFFIResultCode::NotFound,
        not_found_message.to_string(),
    ))
}

Then each call site becomes:

-    let (_lifecycle, wallet_is_live) = runtime().block_on(async {
-        let gate = wallet.core().generation_payment_guard().await;
-        let live = wallet.core().is_current_generation().await;
-        (gate, live)
-    });
-    if !wallet_is_live {
-        runtime().block_on(wallet.core().abandon_transaction(&finalized));
-        return PlatformWalletFFIResult::err(
-            PlatformWalletFFIResultCode::NotFound,
-            "wallet is no longer registered in the manager (removed or re-created while the \
-             transaction was being signed); no transaction handle was published and its \
-             reservation was reconciled"
-                .to_string(),
-        );
-    }
+    let _lifecycle = match enter_live_generation(
+        &wallet,
+        &finalized,
+        "wallet is no longer registered in the manager (removed or re-created while the \
+         transaction was being signed); no transaction handle was published and its \
+         reservation was reconciled",
+    ) {
+        Ok(guard) => guard,
+        Err(result) => return result,
+    };
🤖 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 154 - 173, Extract the duplicated generation gate and
dead-generation reconciliation from the V2-handle and deferred-token paths into
a shared enter_live_generation helper. Have it acquire and return the payment
guard for live generations, otherwise release the guard, abandon the finalized
transaction, and return NotFound using the caller-provided message. Replace both
existing blocks with this helper while preserving each path’s distinct error
message and guard lifetime.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet-ffi/src/error.rs`:
- Around line 205-209: Record the reserved error-code assignments in a durable
registry by creating or updating ERROR_CODE_REGISTRY.md to map
ErrorAssetLockInsufficientFunds to 29 and ErrorReservationWalletMismatch to 30,
or remove the stale registry reference from the ErrorReservationWalletMismatch
documentation.

---

Nitpick comments:
In `@packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs`:
- Around line 154-173: Extract the duplicated generation gate and
dead-generation reconciliation from the V2-handle and deferred-token paths into
a shared enter_live_generation helper. Have it acquire and return the payment
guard for live generations, otherwise release the guard, abandon the finalized
transaction, and return NotFound using the caller-provided message. Replace both
existing blocks with this helper while preserving each path’s distinct error
message and guard lifetime.
🪄 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: fcb68778-c02c-41fa-9170-df3e834cd62f

📥 Commits

Reviewing files that changed from the base of the PR and between 0b0d5c7 and 6be6748.

📒 Files selected for processing (25)
  • 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/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/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/src/manager/load.rs
  • packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
  • packages/rs-platform-wallet/src/test_support.rs
  • packages/rs-platform-wallet/src/wallet/apply.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
  • packages/rs-platform-wallet/src/wallet/core/generation.rs
  • packages/rs-platform-wallet/src/wallet/core/mod.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/identity/network/contact_requests.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs
  • packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs
  • packages/rs-unified-sdk-jni/src/wallet_manager.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
💤 Files with no reviewable changes (1)
  • packages/rs-platform-wallet-ffi/src/handle.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt

Comment on lines +205 to +209
///
/// Note: 29 is taken by `ErrorAssetLockInsufficientFunds`
/// (`dashpay/platform#4184`); this code is 30. See
/// `packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md`.
ErrorReservationWalletMismatch = 30,

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the registry file exists and records the 29 / 30 reservation.
fd -t f 'ERROR_CODE_REGISTRY.md'
fd -t f 'ERROR_CODE_REGISTRY.md' --exec rg -n '\b(27|28|29|30)\b' {} \;

# Confirm no other in-tree variant already claims 29 or 30 in this enum.
fd -t f 'error.rs' -p 'rs-platform-wallet-ffi' --exec rg -n '= (27|28|29|30),' {} \;

Repository: dashpay/platform

Length of output: 264


🏁 Script executed:

#!/bin/bash
set -e

echo "Files named ERROR_CODE_REGISTRY.md:"
fd -t f 'ERROR_CODE_REGISTRY\.md$' . || true

echo
echo "Error files under packages/rs-platform-wallet-ffi:"
git ls-files 'packages/rs-platform-wallet-ffi/**' | grep -E '(^|/)error\.rs$|ERROR_CODE_REGISTRY\.md$' || true

echo
echo "Relevant enum lines:"
if [ -f packages/rs-platform-wallet-ffi/src/error.rs ]; then
  nl -ba packages/rs-platform-wallet-ffi/src/error.rs | sed -n '180,220p'
fi

echo
echo "All error.rs files under packages/rs-platform-wallet-ffi:"
git ls-files 'packages/rs-platform-wallet-ffi/**/error.rs' | while read -r f; do
  echo "--- $f"
  rg -n '= (27|28|29|30),' "$f" || true
done

echo
echo "Registry mentions of 29/30 if file exists:"
if [ -f packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md ]; then
  rg -n '\b(27|28|29|30)\b' packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md || true
else
  echo "packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md not found"
fi

echo
echo "Repo-wide ERROR_CODE_REGISTRY occurrences:"
rg -n 'ErrorAssetLockInsufficientFunds|ErrorReservationWalletMismatch|code registry|29|30' .github packages/rs-platform-wallet-ffi 2>/dev/null | head -200 || true

Repository: dashpay/platform

Length of output: 8171


🏁 Script executed:

#!/bin/bash
set -ed

echo "Files named ERROR_CODE_REGISTRY.md:"
fd -t f 'ERROR_CODE_REGISTRY\.md$' . || true

echo
echo "Error files under packages/rs-platform-wallet-ffi:"
git ls-files 'packages/rs-platform-wallet-ffi/**' | grep -E '(^|/)error\.rs$|ERROR_CODE_REGISTRY\.md$' || true

echo
echo "Relevant enum lines:"
if [ -f packages/rs-platform-wallet-ffi/src/error.rs ]; then
  nl -ba packages/rs-platform-wallet-ffi/src/error.rs | sed -n '180,220p'
fi

echo
echo "All error.rs files under packages/rs-platform-wallet-ffi:"
git ls-files 'packages/rs-platform-wallet-ffi/**/error.rs' | while read -r f; do
  echo "--- $f"
  rg -n '= (27|28|29|30),' "$f" || true
done

echo
echo "Registry mentions of 29/30 if file exists:"
if [ -f packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md ]; then
  rg -n '\b(27|28|29|30)\b' packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md || true
else
  echo "packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md not found"
fi

echo
echo "Repo-wide ERROR_CODE_REGISTRY occurrences:"
rg -n 'ErrorAssetLockInsufficientFunds|ErrorReservationWalletMismatch|code registry|29|30' .github packages/rs-platform-wallet-ffi 2>/dev/null | head -200 || true

Repository: dashpay/platform

Length of output: 8284


Document the reserved error codes in a durable source of truth.

packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md is not present, so the 29/30 split remains unrecorded outside of code comments. Create or link the registry to record ErrorAssetLockInsufficientFunds as 29 and ErrorReservationWalletMismatch as 30, or remove the referenced registry link.

🤖 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/error.rs` around lines 205 - 209, Record
the reserved error-code assignments in a durable registry by creating or
updating ERROR_CODE_REGISTRY.md to map ErrorAssetLockInsufficientFunds to 29 and
ErrorReservationWalletMismatch to 30, or remove the stale registry reference
from the ErrorReservationWalletMismatch documentation.

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