feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission - #4185
feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission#4185bfoss765 wants to merge 34 commits into
Conversation
|
Warning Review limit reached
Next review available in: 37 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (12)
📝 WalkthroughWalkthroughAdds deferred Core signed-payment flows across the Rust wallet, FFI, JNI, Kotlin SDK, and Swift SDK. Payments can be built, reserved, broadcast, or released with generation-bound reservation tokens and typed errors. ChangesDeferred signed payment lifecycle
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
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
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. Comment |
|
🕓 Ready for review — 12 ahead in queue (commit 8813e98) |
|
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:
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 |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
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 valueDeprecate or remove the legacy
registerSignedPaymentbridge. 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@Deprecatedand point docs to the atomicfinalizeSignedPaymentflow.🤖 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
📒 Files selected for processing (19)
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/CoreTransactionBuilder.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.ktpackages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.ktpackages/rs-platform-wallet-ffi/src/core_wallet/mod.rspackages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rspackages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rspackages/rs-platform-wallet-ffi/src/error.rspackages/rs-platform-wallet-ffi/src/handle.rspackages/rs-platform-wallet-ffi/src/wallet.rspackages/rs-platform-wallet/src/lib.rspackages/rs-platform-wallet/src/test_support.rspackages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-platform-wallet/src/wallet/core/wallet.rspackages/rs-platform-wallet/src/wallet/mod.rspackages/rs-platform-wallet/src/wallet/signed_payment_registry.rspackages/rs-unified-sdk-jni/src/wallet_manager.rs
|
Additional/strengthened lifetime findings after checking the existing threads:
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. |
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>
|
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 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. |
…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>
|
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:
Minor: |
|
Addendum: the missing Swift mappings for the new codes belong to this PR too, not only #4196 — |
…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>
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>
|
Both remaining blockers fixed (510 platform-wallet tests):
Swift now maps 26/27/28 to typed One conscious scoping note: the immediate-send reject-release path ( |
…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>
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>
|
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 One new P1 (shared with #4196) — freshness and release are still two separate decisions:
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. |
…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>
|
Verified all three findings at The V2 path was ungated on both ends: 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 Design. The gate now lives in a new
Paths gated (swept rather than taken from the finding's list): registry Also swept: the third Tests: four new deterministic ones. With the three guards removed, Worth flagging: I first wrote two of these in the completion-order style of the existing
|
thepastaclaw
left a comment
There was a problem hiding this comment.
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`.
| 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" } |
There was a problem hiding this comment.
🔴 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']
There was a problem hiding this comment.
Acknowledged — this is the one finding that cannot be resolved inside this PR. The pin exists because this PR's reservation lifecycle requires the key-wallet reservation API introduced in dashpay/rust-dashcore#916, which is not yet in a Dash-owned revision. The resolution path is fixed: once #916 merges, the eight Cargo.toml entries and Cargo.lock get repinned to the corresponding dashpay/rust-dashcore revision before this PR lands.
Current #916 status (verified 2026-08-02): OPEN, CI green (35 checks passing), CodeRabbit-approved, blocked only on its required human CODEOWNER review. The note that the upstream PR has since added follow-up fixes beyond e99959ce is also part of the repin — the final pin will be to the merged head, not the current commit.
Keeping this thread open until the repin lands is correct; nothing else in this PR depends on which revision the pin points at.
| /// 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>, |
There was a problem hiding this comment.
🟡 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.
| pub generation: Arc<WalletGeneration>, | |
| pub(crate) generation: Arc<WalletGeneration>, |
source: ['codex']
There was a problem hiding this comment.
Applied as suggested — generation is now pub(crate) (3dec774929).
Confirmed the reachability first: state_mut() and state_mut_blocking() are both pub and hand out a WalletStateWriteGuard that derefs mutably to PlatformWalletInfo, so with a pub field downstream safe code really could assign a fresh Arc here while PlatformWallet and CoreWallet keep the original — and the consequences are as described: is_current_generation() rejects the still-live wallet, generation-bound reservation cleanup no-ops, and teardown ends up excluding through a different gate than the payment operations it has to fence.
No downstream break: every construction site (manager/wallet_lifecycle.rs, manager/load.rs, wallet/apply.rs, test_support.rs, and the test helpers in contact_requests.rs / asset_lock/sync/recovery.rs) and the one reassignment (signed_payment_registry.rs, a test) are inside rs-platform-wallet. Nothing outside the crate touches the field — the public read path is the existing PlatformWallet::generation() accessor, which is unchanged.
Added a doc comment on the field recording why it is not pub, so it does not get widened back by someone who needs read access and reaches for the field instead of the accessor.
| coreSignerHandle: Long, | ||
| accountType: AccountType = AccountType.BIP44, | ||
| accountIndex: Int = 0, | ||
| ): SignedCoreTransaction = gate.op { |
There was a problem hiding this comment.
🟡 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.
| ): SignedCoreTransaction = gate.op { | |
| ): SignedCoreTransaction = gate.opWithCleanupOnCancellation( | |
| cleanup = { payment: SignedCoreTransaction -> payment.close() }, | |
| ) { |
source: ['codex']
There was a problem hiding this comment.
Applied as suggested (3dec774929) — buildSignedPayment now goes through gate.opWithCleanupOnCancellation(cleanup = { payment -> payment.close() }).
The window is real: coreWalletFinalizeSignedPayment mints the token and transfers reservation ownership natively before the blocking JNI call returns, and the SignedCoreTransaction only becomes the JVM owner after it is constructed on the way back out. op is withContext(Dispatchers.IO), whose prompt-cancellation handoff can discard that completed value, leaving the NativeCleaner as the only path to a release — i.e. an unpredictable GC cycle or the reservation TTL, with the coins held in the meantime.
opWithCleanupOnCancellation is the right existing primitive: it captures the completed value inside the op block and clears it only after the successful handoff, with no suspension point in between, so cleanup runs exactly on the discard path. Consuming the token later via broadcastSigned / releaseReservation makes the eventual close() a native no-op, so the extra ownership does not double-release.
Also fixed the KDoc on buildSignedPayment, which claimed the GC backstop was what covered a cancellation discarding the result — it now describes the deterministic close and leaves the Cleaner as the backstop it actually is.
| @@ -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) | |||
There was a problem hiding this comment.
🟡 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']
There was a problem hiding this comment.
Applied (3dec774929) — added DashSdkError.PlatformWallet.TransactionBroadcastRejected and mapped native 26 to it, so it no longer falls through to Generic.
Confirmed the gap first: fromPlatformWalletNative had arms for 25 and then 27, with nothing for 26, so a definitive rejection arrived as Generic(26, …) even though the C ABI and JNI preserve it faithfully all the way up. That was the odd one out — 20 (TransactionBroadcastUnconfirmed) and all three deferred-token failures already had dedicated types.
Semantics as you framed them: isRetryable stays false. Core definitively rejected the transaction, and on the deferred path the reservation was released and the token consumed at the same time, so there is nothing left to retry against — the payment has to be rebuilt after addressing the rejection reason, which still travels in the message. The KDoc states that explicitly and contrasts it with TransactionBroadcastUnconfirmed (20), whose ambiguity is precisely why that one keeps its inputs reserved.
Pinned in DashSdkErrorTest with the type assertion carrying the message "code 26 must not fall through to Generic", plus the non-retryable and message-passthrough assertions.
One thing to note when reading the diff: this landed alongside the renumber for #4268, so the arms around it moved — the deferred-token trio is now 34/35/36 and 26 sits just above 25. Code 26 itself is merged ABI and did not move.
…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>
|
Blocker 1 — The finding is correct, and the mechanism is worth naming precisely: registration is deliberately ungated — A remover descheduled there (OS preemption on a multi-threaded executor, or tokio's cooperative budget making I took the second option from the finding: the retry loop now carries the validated I did not take the "single per-ID ordering" option by holding 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 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 Regression test. 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 Against the previous code it fails deterministically (10/10) on all three load-bearing assertions: the returned generation is G2, the
Blocker 2 — |
There was a problem hiding this comment.
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 winExtract the shared generation-gate block into one helper.
Lines 154-173 and lines 276-295 are the same gate: acquire
generation_payment_guard, readis_current_generation, abandon on a dead generation, returnNotFound. 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
📒 Files selected for processing (25)
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.ktpackages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.ktpackages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/SignedCoreTransactionTest.ktpackages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rspackages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rspackages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rspackages/rs-platform-wallet-ffi/src/error.rspackages/rs-platform-wallet-ffi/src/handle.rspackages/rs-platform-wallet-ffi/src/manager.rspackages/rs-platform-wallet/src/manager/load.rspackages/rs-platform-wallet/src/manager/wallet_lifecycle.rspackages/rs-platform-wallet/src/test_support.rspackages/rs-platform-wallet/src/wallet/apply.rspackages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rspackages/rs-platform-wallet/src/wallet/core/generation.rspackages/rs-platform-wallet/src/wallet/core/mod.rspackages/rs-platform-wallet/src/wallet/core/transaction.rspackages/rs-platform-wallet/src/wallet/core/wallet.rspackages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-platform-wallet/src/wallet/platform_wallet_traits.rspackages/rs-platform-wallet/src/wallet/signed_payment_registry.rspackages/rs-unified-sdk-jni/src/wallet_manager.rspackages/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
…ence (dashpay#4185 review) The doc comment on ErrorReservationWalletMismatch pointed at packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md, which does not exist in the tree (nor in dashpay#4184, which owns code 29). The inline note that 29 is reserved by ErrorAssetLockInsufficientFunds (dashpay#4184) already records the split, so remove the stale link rather than minting a new registry file. Flagged by CodeRabbit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@thepastaclaw The 03:57 preliminary review is answered — requesting revalidation at the current head
|
…pay#4268 claimed dashpay#4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev FFI ABI, colliding with this PR's `ErrorStaleReservationToken = 27`. Renumber the deferred build/broadcast trio to the contiguous block 34-36, which sits above every code currently claimed by a merged commit or an open PR: 27 ErrorShutdownIncomplete MERGED, dashpay#4268 29 ErrorAssetLockInsufficientFunds dashpay#4184 31 ErrorSigningKeyUnavailable dashpay#4183, dashpay#4259 32 ErrorTransactionBuild dashpay#4247, dashpay#4256 33 ErrorTransactionSigning dashpay#4256 28 and 30 are vacated and return to the free pool. Applied across the Rust enum, the FFI/JNI rustdoc, the Kotlin mapping + KDoc + tests, and the Swift mirror (which has no compile-time cross-ABI check, so it was verified by grep). Also addresses three review suggestions: * `PlatformWalletInfo::generation` is now `pub(crate)`. It was publicly assignable through `state_mut()` / `state_mut_blocking()`, so downstream safe code could swap the `Arc` while `PlatformWallet` and `CoreWallet` kept the original — splitting the generation identity `Arc::ptr_eq` compares, which would make `is_current_generation()` reject a live wallet, turn generation-bound reservation cleanup into a no-op, and let teardown exclude through a different lifecycle gate than the payments it must fence. All construction and mutation sites are already inside the crate. * `buildSignedPayment` now runs under `opWithCleanupOnCancellation`. Native finalization mints the token before the blocking JNI call returns, so `withContext`'s prompt-cancellation handoff could discard the completed `SignedCoreTransaction` and leave the reservation to the GC Cleaner or the TTL. The discarded result is now closed deterministically. * Native code 26 (`ErrorTransactionBroadcastRejected`) no longer falls through to `PlatformWallet.Generic`. It maps to a dedicated `TransactionBroadcastRejected` subtype so callers can tell a definitively rejected, consumed-and-released payment (rebuild it) from an unrelated generic wallet failure, with its non-retry-in-place semantics pinned in `DashSdkErrorTest`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pay#4268 claimed dashpay#4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev FFI ABI, colliding with the `ErrorStaleReservationToken = 27` this branch carries alongside dashpay#4185. Renumber the deferred build/broadcast trio to the contiguous block 34-36, matching dashpay#4185: ErrorStaleReservationToken 27 -> 34 ErrorReservationTokenConsumed 28 -> 35 ErrorReservationWalletMismatch 30 -> 36 34-36 sits above every code claimed by a merged commit or an open PR (27 dashpay#4268 merged, 29 dashpay#4184, 31 dashpay#4183/dashpay#4259, 32 dashpay#4247/dashpay#4256, 33 dashpay#4256), so it ends the renumbering churn. 28 and 30 are vacated and return to the free pool. This branch's own `ErrorTransactionBuild` (32) and `ErrorTransactionSigning` (33) are unaffected; their numbering-rationale rustdoc is updated to name dashpay#4268 as the owner of 27 and to record where the trio went. Applied across the Rust enum, the FFI/JNI rustdoc, the Kotlin mapping + KDoc + tests, and the Swift mirror (no compile-time cross-ABI check — verified by grep). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dashpay#4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev ABI on 2026-08-02, taking the number dashpay#4185 had held. dashpay#4185 and dashpay#4256 moved the deferred-token trio to the contiguous block 34-36 in response. Registry changes: * 27 enters the merged table, owned by dashpay#4268. * The proposed table moves the trio to 34/35/36 and marks 28 and 30 free but deliberately not reissued. Next free integer is now 37. * New "Collision history" section records all three numberings of the trio (26/27/28 -> 27/28/30 -> 34/35/36) and, more usefully, corrects this file's own reasoning: on 2026-08-01 it recorded dashpay#3954's `ErrorShutdownIncomplete = 27` as a non-conforming claim that had to be withdrawn because dashpay#4185's claim was older. Seniority among open PRs does not decide an ABI number — merging does. dashpay#3954 was closed, its work landed as dashpay#4268, and 27 is now merged ABI. The trio therefore moved above every claimed number rather than into the next free gap, so nothing currently in flight can hit it again. * dashpay#3968's 27 is re-characterised: it was a proposed-vs-proposed collision, and is now a contradiction of merged ABI. Its frontier is 37+. * dashpay#4196 is now two moves behind at 26/27/28; the doc reference it owns has to chase 34, not 27. * Records a mirror gap found while grepping for this move: dashpay#4256 declares `ErrorTransactionBuild` (32) and `ErrorTransactionSigning` (33) in Rust and maps both in Kotlin, but declares neither in Swift, so both reach Swift hosts as `.errorUnknown`. Rule 5's Swift clause; left for that PR's author. * Provenance re-verified against v4.2-dev `5d68612a45`, including the check that 32 and 33 were already taken — which is why the trio went to 34-36 and not 32-34. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renumbered the deferred-token trio: 27/28/30 → 34/35/36#4268 merged into I moved all three rather than just the colliding one, and moved them above every number currently claimed by anything:
So 32/33/34 was not available; the trio is now the contiguous block 34–36. 28 and 30 are vacated and left free rather than back-filled, so a reviewer who saw the old numbering can't find a familiar number attached to an unfamiliar meaning. #4256 got the identical move; registry updated in #4261. Applied at 32 sites (every line that carried one of the old numbers) — Rust enum + rustdoc cross-refs, Also addressed the three open suggestions
Verification
One note for whoever picks it up: this branch is not rebased onto current |
|
@thepastaclaw re-requesting at the ACTUAL current head |
…-> 37 and mirror it (dashpay#4204) 32 is allocated to `ErrorTransactionBuild` (dashpay#4247, also carried by dashpay#4256) in ERROR_CODE_REGISTRY.md (dashpay#4261). This variant took 32 without a registry row, so the two collide as a hard `E0081: discriminant value 32 assigned more than once` the moment both land — reproduced on a real integration merge, not hypothetical. 27-36 are all claimed (27 ErrorShutdownIncomplete via the merged dashpay#4268; 29 dashpay#4184; 31 dashpay#4183; 32/33 dashpay#4247/dashpay#4256; 34-36 the dashpay#4185 trio) and 28/30 are vacated-but-RESERVED, so 37 is the allocation frontier. The code was also unmirrored on BOTH hosts, which is the more dangerous half: Swift is exhaustive, so it surfaced as .errorUnknown and lost its identity; Kotlin fell through to Generic(32), and in any tree carrying dashpay#4185's ErrorReservationWalletMismatch = 32 it actively MISCLASSIFIED "shielded invite already claimed" as "reservation wallet mismatch". That matters on the claim-recovery path specifically — the error is raised from four sites in shielded/operations.rs, three inside the recovery function. Adds the typed Kotlin PlatformWallet.ShieldedInviteAlreadyClaimed (terminal, inherited isRetryable = false), the Swift enum case + init(ffi:) arm, a DashSdkErrorTest assertion pinning 37, and refreshes the stale Swift reservation comment the registry asked the next toucher to drop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 32 collision dashpay#4204 allocated 32 without a row here, colliding with dashpay#4247's ErrorTransactionBuild. Caught for real during the v41int13 integration as an E0081, not on paper — the first collision this file has actually stopped. dashpay#4204 moves to 37; frontier advances to 38. Also records that the code was unmirrored on both hosts, which had Kotlin misclassifying it as ReservationWalletMismatch wherever dashpay#4185's 32 was present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…keystore-qa4 Brings the ErrorShieldedInviteAlreadyClaimed 32 -> 37 renumber + host mirrors, clearing the E0081 this integration hit against qa3's ErrorReservationWalletMismatch = 32. Conflicts (4) all unions — kept BOTH sides, since this subset does not carry dashpay#4185/dashpay#4256: the deferred-token trio stays at qa3's 27/28/32 and 37 is added alongside. Swift's inherited comment was rewritten to describe THIS tree's numbering rather than the 34-36 layout those branches introduce. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, the code this integration actually uses DashSdkErrorTest asserted offset+29 -> ReservationWalletMismatch, but 29 became ErrorAssetLockInsufficientFunds when dashpay#4184 took it and the mismatch code moved to 32 — so the assertion had silently stopped testing its own mapping on the qa3 integration line. dashpay#4185/dashpay#4256 fix this by moving the trio to 34-36 and updating the assertion; both are out of scope for v41int13, and no feature branch carries ReservationWalletMismatch = 32, so this is corrected here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…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>
…nd-path test fixtures Restack adaptation only — no production-code change. This branch now sits on top of dashpay#4185 (port/v4.1/split-build-broadcast) rather than beside it. dashpay#4185 replaced the bare `Arc<WalletBalance>` generation marker with `Arc<WalletGeneration>` (balance + that generation's lifecycle gate in one Arc, so "same generation" and "same gate" cannot diverge), and renamed `PlatformWalletInfo::balance` to `::generation`. The send-path test fixtures added here still built wallets the old way, so they no longer compiled against the new base. Point them at the shared `WalletGeneration` the rest of the crate already uses: * `core/send.rs` — the local `core_wallet` fixture takes `Arc<WalletGeneration>`; `funded_wallet_manager` already hands one back. * `wallet/funding_privacy.rs` — same, for its two fixtures. * `test_support.rs` — the DashPay split fixture populates `PlatformWalletInfo::generation`. cargo test -p platform-wallet -p platform-wallet-ffi: 537 + 230 pass.
…#4185's registry contract Restack adaptation. This branch now sits on top of dashpay#4247 (which sits on dashpay#4185) instead of beside them, so the funding-path registration meets the contract dashpay#4185 established for the deferred-payment registry: * `RegisteredPayment` keeps ONE funding handle — dashpay#4256's `funding: FundingAccountRef` — and dashpay#4185's MANDATORY `registered_height: u32`. The two evolutions are orthogonal (which account vs. which clock), so the union is a strict superset: the Path arm still reaches a DashPay receiving-funds account, and the age guard can no longer be silently disabled by a `None` height. * `register_funded_by` therefore takes `registered_height: u32`. `FinalizedCorePayment::reservation_height` was already a non-optional `u32` sampled inside the funding critical section, so every caller simply drops its `Some(..)` wrapper — no behaviour change, one less way to disable the guard. * `ReservationToken` is dashpay#4185's newtype; the FFI converts with `as_u64` at the C ABI boundary. * The over-limit refusal binds the payment first and discharges its reservation through `abandon_payment` before returning, rather than stranding the account's coins until the TTL backstop. * Test fixtures build wallets with `WalletGeneration` (balance + the generation's lifecycle gate in one Arc) and read `PlatformWalletInfo::generation`. `register_funded_by` still cannot prove its `core` is the generation that produced the payment the way `register` can — `FinalizedCorePayment` carries no `origin_generation` marker, so the FFI's single `generation_payment_guard` hold is what upholds the binding. That gap is pre-existing on this branch and is documented on the method as follow-up. cargo test -p platform-wallet -p platform-wallet-ffi: 540 + 230 pass.
…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 free on every branch. 31 IS reserved, but for a different contract: 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>
…plit-build-broadcast Brings the branch onto current v4.2-dev so it is mergeable again. dashpay#4268 ("registry-owned coordinator lifecycle with Rust-owned FFI callback contexts") landed upstream on 2026-08-02 and touches the neighbouring lifecycle surface: `platform-wallet-ffi`'s manager / event_handler / persistence, the manager's sync loops, and the Kotlin/Swift manager mirrors. It does NOT touch this PR's `wallet/core/generation.rs`, `manager/wallet_lifecycle.rs` or `wallet/signed_payment_registry.rs`, and git merged every one of those cleanly. The two designs are complementary, not competing: dashpay#4268 owns the *coordinator* (async task registry + callback-context ownership across the FFI boundary), this PR owns the *wallet generation* (the per-generation balance Arc plus its payment/teardown gate). The only overlap is the error space, resolved below. Sole conflict: `PlatformWalletResult.swift`, five hunks, all additive on both sides — dashpay#4268 adds `errorShutdownIncomplete = 27`, this PR adds the deferred-token trio at 34-36. Both kept. The trio already sat at 34-36 precisely because dashpay#4268 had claimed 27, so no renumbering was needed; the in-file registry comment already named dashpay#4268 as 27's owner.
Keeps this branch on top of dashpay#4185, which has just taken current v4.2-dev (post-dashpay#4268). Sole conflict: `platform-wallet-ffi`'s result enum, where dashpay#4268 adds `ErrorShutdownIncomplete = 27` and this branch adds `ErrorTransactionBuild = 32`. Both kept, declared in numeric order; no renumbering was needed because 32 was chosen from the registry frontier with 27 already reserved to dashpay#4268.
Keeps this branch on top of dashpay#4247, which now carries current v4.2-dev (post-dashpay#4268). Merged cleanly — dashpay#4268's coordinator-lifecycle rework and this branch's funding-path finalize touch disjoint code, and the error space was already reconciled to the registry (dashpay#4261): 27 ErrorShutdownIncomplete (dashpay#4268), 32 ErrorTransactionBuild (dashpay#4247), 33 ErrorTransactionSigning (this PR), 34-36 the dashpay#4185 deferred-token trio.
Restacked: this PR is now the base of a 3-PR stackStack order (bottom → top): #4185 → #4247 → #4256 These three PRs previously branched independently from the same point and each rewrote the deferred-payment lifecycle, so no two could be merged without hundreds of conflicted lines in This PR is unchanged in content. Its commits are byte-identical to before; the only new commit is a merge of current What changed
Relationship to #4268 (merged upstream 2026-08-02)#4268 owns the coordinator — the async task registry and Rust-owned FFI callback contexts ( Error-code registry (#4261) after the stack
Identical in Rust, Kotlin and Swift; verified for duplicates in all three. Still open on this PRThe blocking finding |
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>
review blocker 2) Reviewer (thepastaclaw) blocker 2 — build.rs:423-424: build_asset_lock_tx_from_selected_account passed the BIP44 change account (&bip44_acc) as set_funding's `acc`, but set_funding calls funds_acc.next_change_address(Some(&acc.account_xpub)) on the SELECTED funds account before the set_change_address override. For an explicitly selected Standard BIP32 account with no pre-generated unused internal address, that derived [1, index] from the wrong (BIP44) xpub and recorded it under the BIP32 account's own path, poisoning that pool so a later normal BIP32 send could use a change entry whose signer key does not match the address. Now resolve the wallet-level Account whose account-level derivation path equals funding_path and pass ITS xpub to set_funding, while keeping the separate BIP44 set_change_address override. Default BIP44 funding resolves to bip44_acc (unchanged); non-Standard (CoinJoin/DashPay) accounts fail change derivation regardless, so the xpub is immaterial and the bip44_acc fallback preserves prior behavior. Also (thepastaclaw nitpick, test_support.rs): move the DashPay fixture rustdoc so it attaches to split_funded_wallet_manager_dashpay rather than foreign_contact_account_xpub. Blocker 1 (build.rs:427-473, owner-guarded reservation rollback on the pre-broadcast abandonment path) is NOT addressed here: the pinned key-wallet rev 70d4bf8 exposes no owner-guarded release primitive (ReservationSet has only reserve/reserved/release keyed by outpoint; release_reservation is unconditional) and no reservation token, so the required release_if_owner(token) mechanism (rust-dashcore#916 / dashpay#4185) cannot be implemented against this pin without an upstream dependency change — the very atomic-reservation fix this PR is held for. Deferred pending that adoption rather than substituting an unconditional release on the money path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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>
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-jniall pass (504 / 229 / 10);:sdk:assembleRelease+ sdk unit tests pass.🤖 Generated with Claude Code
Summary by CodeRabbit
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,
AutoCloseableKotlin object with a GC/Cleanerbackstop, 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 V2CoreWallethandle 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:
platform_wallet_destroyreleases 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.SignedCoreTransactionandregisteruses it — no post-signing resample; boundary test pins the TTL margin.CoreWallet::is_same_generation(per-generation identity) is checked by BOTH the V2-handle and registry-token paths, with one teardown policy.SignedCoreTransactionis an owningAutoCloseablewith aNativeCleanerbackstop; round-2 adds object-owningbroadcastSigned/releaseReservationoverloads 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).broadcastpeeks 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_registerfour-layer chain is deleted; the single stale-token code is split into typed siblings 27ErrorStaleReservationToken/ 28ErrorReservationTokenConsumed/ 29ErrorReservationWalletMismatch(code 26 is not used by this PR — upstream now owns it asErrorTransactionBroadcastRejected; both the Kotlin and Swift enums on this branch map 27/28/29 explicitly); the stalebuildSignedPaymentKDoc is fixed.Local test evidence (fork PRs skip the Rust CI suite):
platform-wallet --lib508 passed,platform-wallet-ffi --lib197 passed, clippy/fmt clean, Kotlin:sdk:testDebugUnitTestgreen.