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 25 commits into
Conversation
📝 WalkthroughWalkthroughAdds deferred Core signed-payment flows across the Rust wallet, FFI, JNI, and Kotlin SDK layers. Payments can be built and reserved, then broadcast or released using opaque reservation tokens, with lifecycle validation and stale-token error mapping. ChangesDeferred payment registry and wallet lifecycle
Native signed-payment FFI
JNI and Kotlin integration
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 |
|
⛔ Blockers found — Sonnet deferred (commit 440897c) |
|
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>
After finalize routing landed, the four-layer deferred-register chain core_wallet_signed_payment_register (FFI) → coreWalletRegisterSignedPayment (JNI) → WalletManagerNative.coreWalletRegisterSignedPayment (Kotlin) → ManagedCoreWallet.registerSignedPayment had zero callers. It is the unsafe variant whose age guard baselines registered_height at registration time (after external signing) rather than at the reservation's own height, so removing it also removes that mis-baselined path. The atomic core_wallet_signed_payment_finalize path is the only remaining register site. Delete all four layers; repoint the surviving broadcast/finalize doc comments at the finalize entry point; drop the now-unused FFICoreTransaction::fee accessor (keep the ABI field, silence the lint). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eservation height finalize_transaction captures last_processed_height inside the funding critical section and stamps the selected inputs' reservation with it, then signs after dropping the wallet-manager lock. The registry, however, sampled a FRESH last_processed_height in register() — run AFTER the (possibly slow, external) signer returned. A slow signer could let the wallet advance so the token's baseline was higher than the reservation's true stamp height, making the age guard measure from the wrong side of signing: the token looked young while its reservation had already aged toward key-wallet's TTL sweep, risking a release/broadcast against an outpoint key-wallet had swept and re-selected. Carry the stamp height on SignedCoreTransaction (reservation_height, captured in the funding section before signing) and have register() take the height as an explicit parameter instead of sampling. The atomic finalize FFI passes finalized.reservation_height(); the age guard now baselines on the same clock the reservation was stamped with. Adds a regression test that registers after the wallet advanced (modelling a slow signer) and proves the guard trips MAX_AGE past the reservation height, not past a post-signing sample. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion identity The V2 finalized-transaction handle validated only wallet_id, while the registry-token path validated the shared WalletManager Arc plus wallet_id. Neither can tell one wallet generation from another: after a wallet is removed and re-created under the same id, both the manager Arc and wallet_id are equal, so an old V2 handle could act through the old generation while the new generation selects the same inputs. Add CoreWallet::is_same_generation — the single generation identity both paths now share. Aliases of one generation share the per-generation Arc<WalletBalance> (created fresh in the wallet-lifecycle create/load paths); a re-created wallet gets a new one, so Arc::ptr_eq on it distinguishes generations that wallet_id + the manager Arc cannot. Holding either handle pins the balance Arc, so its address can't be reused for a different generation — the same soundness argument the registry already uses for the manager Arc. Apply it to both V2 broadcast and abandon (replacing the wallet_id-only check). The registry broadcast path adopts the same identity in the follow-up validate-under-lock change. Adds a unit test proving alias-vs-recreation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only a match SignedPaymentRegistry::broadcast removed the entry first and validated the wallet binding second, so a mismatched caller (wrong wallet, or a re-created generation) destroyed the ORIGINAL wallet's token and left its reservation stranded until the TTL backstop — a wrong-wallet broadcast could grief the rightful owner's in-flight payment. Peek under the registry lock, reject a non-matching caller with WalletMismatch WITHOUT removing the entry, and only remove (consume) an entry whose generation matches. The check-then-remove is one lock hold, so it stays atomic against a concurrent broadcast — the double-broadcast guard is unchanged (the second consumer finds nothing → StaleToken). The binding check now uses the shared CoreWallet::is_same_generation identity, so the registry-token and V2 handle paths agree on when a caller owns a token. Updates the two existing mismatch tests (which asserted the old drop-on-mismatch behaviour) and adds a regression proving a wrong-wallet broadcast preserves the owner's token and the owner can still broadcast it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…; drop them at generation teardown platform_wallet_destroy called remove_entries_for_wallet, which only DROPPED the registry entries. But destroying the last wrapper alias does not remove the logical wallet from its manager — the accounts' ReservationSets stay live and the same wallet can be handed out again — so the dropped tokens' inputs stayed reserved until key-wallet's TTL. Tokens were consumed without releasing live reservations. Split the two teardown moments under one generation identity: - Final-alias destroy (wallet still live): release_entries_for_wallet RELEASES each of the generation's reservations against the still-live wallet (honouring the age guard), so a wallet handed out again can respend the inputs. The final-alias check and the match are both by CoreWallet::is_same_generation. - Actual generation teardown (platform_wallet_manager_remove_wallet): the wallet and its ReservationSets are gone, so remove_entries_for_wallet DROPS the generation's registry tokens (nothing to reconcile) and remove_matching drops its finalized-tx V2 handles. This makes any stale handle to the removed generation inert, which is what makes the destroy-time release provably race-free: a torn-down generation has already had its tokens swept here, so destroy/release can never release-by-outpoint against a re-created generation's inputs. platform_wallet_destroy now block_on's the release (as it already runs off the tokio runtime on the JNI / NativeCleaner threads). Adds HandleStorage::remove_matching, registry release_entries_for_wallet, a registry regression proving destroy-time release frees the reservation while teardown drop does not, and reworks the FFI destroy test to invoke destroy off-runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hree siblings Native code 26 (ErrorStaleReservationToken) mapped all three SignedPaymentError variants — StaleToken (unknown/already-broadcast/released), WalletMismatch (different wallet generation), and StaleReservationToken (aged out) — so a host could not tell "you already broadcast this", "wrong wallet", and "the reservation aged out; rebuild" apart, even though the remedy and messaging differ. Split at the FFI (additive sibling codes, no renumbering): - 26 ErrorStaleReservationToken -> StaleReservationToken (aged out) - 27 ErrorReservationTokenConsumed -> StaleToken (unknown/already broadcast/released) - 28 ErrorReservationWalletMismatch -> WalletMismatch (different generation) core_wallet_signed_payment_broadcast now maps each variant to its own code. All three remain non-retryable-in-place and none touch the network. Host impact (Kotlin SDK only — the Swift host does not map these codes): adds DashSdkError.PlatformWallet.ReservationTokenConsumed / ReservationWalletMismatch, maps 27/28, narrows the code-26 doc, updates the JNI/Kotlin broadcast KDocs, and extends DashSdkErrorTest to assert all three. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-register shape The KDoc still described the pre-finalize build (`new → addOutput* → setFunding → buildSigned`) and credited buildSigned with reserving the inputs. The deferred path now issues a single atomic finalizeSignedPayment (select + reserve + sign + register under the wallet-manager lock). Update the described step sequence and the atomicity claim to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ble with a Cleaner backstop buildSignedPayment returned a plain SignedCoreTransaction through a cancellable coroutine. The blocking JNI registration mints the reservation token before the Kotlin object exists, so if cancellation was observed after that native call returned — or the caller simply dropped the value — the token (and its funding reservation) was orphaned until key-wallet's TTL, with no release path. Make SignedCoreTransaction an AutoCloseable that registers a NativeCleaner backstop at construction: close(), or GC if the caller never calls it, releases the token exactly once. Native release is idempotent and tokens are process-unique, so releasing a token already consumed by broadcastSigned / releaseReservation (or closing twice) is a harmless no-op. This closes the cancellation window — the object is Cleaner-backed the instant it exists (no suspension point between the native return and construction), so a discarded object always releases its token. Adds a pure-JVM test pinning the ownership contract (owning AutoCloseable) and the Cleaner run-once guarantee it relies on, and documents the ownership on buildSignedPayment. :sdk:testDebugUnitTest passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etion Normalize two pre-existing long lines in coreWalletFinalizeSignedPayment that `cargo fmt --check` flags, so the JNI crate is formatting-clean after the register-chain removal touched this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…CoreTransaction Review round 2: the bare-Long token API couples the reservation's lifetime to the SignedCoreTransaction's GC-reachability — extracting the token and dropping the object lets the Cleaner backstop release the reservation out from under a pending broadcast. The object overloads keep the payment reachable across the native call (reachabilityFence) and disarm the backstop once the token is consumed; the bare-token docs now warn about the reachability requirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed deferred payments The deferred-payment registry stored only an `Option<StandardAccountType>`, so a CoinJoin funding — which has no `StandardAccountType` — reconciled nothing on rejection/abandon/free and kept its inputs reserved until key-wallet's 24-block TTL, even though `finalize` reserves the selected inputs for every account variant. Carry the full `AccountTypePreference` (BIP44/BIP32/CoinJoin) as the entry's releasable account handle. The registry now broadcasts through the new `broadcast_payment_releasing_reservation` and releases through `release_transaction_reservation` (both `AccountTypePreference`-typed and CoinJoin-capable), so a rejected or abandoned CoinJoin deferred payment frees its reservation immediately. The FFI finalize passes `account_type.into()` instead of the `StandardAccountType` subset; the now-unused `release_payment_reservation` (registry-only) is removed. Test: `coinjoin_funded_release_frees_the_reservation_immediately` funds CoinJoin account 0, finalizes a sweep, registers the token, and proves release makes the input immediately spendable again. Adds a `#[cfg(test)]` `funded_coinjoin_wallet_manager` fixture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… wallet generation The deferred registry validated a token's generation at the registry lock, then released its reservation later, off that lock. `ReservationSet::release` removes an outpoint unconditionally and is reached via `wallet_id` — an identity a same-id remove-then-recreate preserves — so a wallet re-created in that window could have the NEW generation's reservation freed by the old token's cleanup. Bind the cleanup to the token's own generation: `release_transaction_reservation` now re-validates the generation and mutates the `ReservationSet` under a single manager read-lock hold, acting only when the wallet still registered under the id carries the same per-generation balance `Arc` the handle captured. A recreation needs the manager write lock, so it cannot interleave between the check and the release — validate-and-mutate is atomic. This protects both the registry (release/abandon and broadcast-on-rejection) and the V2 finalized-transaction handle path, which share this primitive. Adds `CoreWallet::generation()`. Test: `recreation_between_validation_and_cleanup_cannot_release_new_generation` recreates the wallet under the same id between registration and release and asserts the input stays reserved (the reservation the new generation owns is untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`PlatformWalletResultCode` jumped from 25 straight to 98, so the three deferred build->broadcast/release codes this PR owns (26 StaleReservationToken, 27 ReservationTokenConsumed, 28 ReservationWalletMismatch) fell through to `.errorUnknown` on iOS, erasing their distinct retry semantics. Add the three raw codes to `PlatformWalletResultCode`, matching cases to `PlatformWalletError`, and map them in both `init(ffi:)` and `init(result:)`. The `init(result:)` switch (no default) stays exhaustive — the same non-exhaustive-switch class shumkov flagged on dashpay#4184. Messages pass the Rust `Display` string straight through, matching the Kotlin SDK's mapping verbatim. Verified with `swiftc -parse` (the DashSDKFFI xcframework — cbindgen header + cdylib — is built separately by build_ios.sh and is not present in this checkout, so a full `swift build` type-check isn't possible here). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…error codes `core_wallet_signed_payment_broadcast` still documented the pre-split semantics: a repeated broadcast and a re-created wallet both as `ErrorStaleReservationToken` (26). Since the three-way split, a repeated/concurrent broadcast yields `ErrorReservationTokenConsumed` (27) and a re-created wallet generation yields `ErrorReservationWalletMismatch` (28); 26 is reserved for the aged-out case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
The exact reservation height, generation-aware cleanup, typed host errors, and Kotlin token-owner cleanup are now implemented correctly. Three blocking lifecycle defects remain: registry registration can mint duplicate capabilities for one reservation, destroying the final wallet alias invalidates independently owned tokens, and wallet removal is not linearized with in-flight finalization or broadcast. The reservation token also remains an untyped Rust u64 capability.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— ffi-engineer (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 💬 1 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:233-251: Registration still does not preserve unique reservation ownership
`register` accepts a freely clonable `Transaction` together with independently supplied wallet, account, and height metadata instead of consuming the non-`Clone` `SignedCoreTransaction` that owns those facts. The test at lines 1080-1115 registers sixteen clones of one reserved transaction, proving that multiple live tokens can name the same reservation. After one token releases the original outpoint and another payment reserves it, a second old token remains generation- and age-valid and can unconditionally release the newer reservation by outpoint. The current FFI finalizer calls this method once, but the registry is publicly re-exported, so the ownership invariant cannot depend on that caller's discipline. Make registration consume the finalized ownership object exactly once and derive its transaction, account, and mandatory reservation height internally.
In `packages/rs-platform-wallet-ffi/src/wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/wallet.rs:420-427: Destroying the final platform-wallet alias invalidates independently owned payment tokens
`platform_wallet_destroy` releases every token for the generation when no other `PlatformWallet` wrapper is currently stored. Those wrappers do not own the logical wallet: the manager still owns it and can return another alias, `platform_wallet_get_core` creates independently owned core handles, each registry entry retains its own `CoreWallet`, and Kotlin documents `SignedCoreTransaction` as the token owner. Closing or collecting the last wrapper therefore consumes and releases a still-live payment token even though its owning object was neither closed nor used, so a later merchant acknowledgement cannot be broadcast through a retained core handle or reacquired alias. Token cleanup must follow the payment owner or actual wallet-generation removal, not transient wrapper-alias count.
In `packages/rs-platform-wallet-ffi/src/manager.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/manager.rs:452-471: Wallet removal is not linearized with finalization or broadcast
Logical wallet removal and registry cleanup are separate operations. An existing token can be consumed after `manager.remove_wallet` has deleted its generation but before `remove_entries_for_wallet` runs: the retained old core handle still matches the entry, `last_processed_height()` returns `None`, `reservation_expired` treats that as valid, and the transaction reaches the broadcaster. Conversely, `finalize_transaction` releases the manager lock before awaiting the external signer; removal can delete the generation and complete its token sweep during that await, after which the finalizer registers a new token without revalidating that the generation still exists. Coordinate token registration and consumption with generation removal under a shared lifecycle lock or gate, reject an absent current generation, and ensure teardown waits for or sweeps in-flight finalizers.
| pub async fn register( | ||
| &self, | ||
| core: CoreWallet<B>, | ||
| tx: Transaction, | ||
| account_type: AccountTypePreference, | ||
| account_index: u32, | ||
| registered_height: Option<u32>, | ||
| ) -> ReservationToken { | ||
| let token = self.next_token.fetch_add(1, Ordering::SeqCst); | ||
| self.lock().insert( | ||
| token, | ||
| RegisteredPayment { | ||
| core, | ||
| tx, | ||
| account_type, | ||
| account_index, | ||
| registered_height, | ||
| }, | ||
| ); |
There was a problem hiding this comment.
🔴 Blocking: Registration still does not preserve unique reservation ownership
register accepts a freely clonable Transaction together with independently supplied wallet, account, and height metadata instead of consuming the non-Clone SignedCoreTransaction that owns those facts. The test at lines 1080-1115 registers sixteen clones of one reserved transaction, proving that multiple live tokens can name the same reservation. After one token releases the original outpoint and another payment reserves it, a second old token remains generation- and age-valid and can unconditionally release the newer reservation by outpoint. The current FFI finalizer calls this method once, but the registry is publicly re-exported, so the ownership invariant cannot depend on that caller's discipline. Make registration consume the finalized ownership object exactly once and derive its transaction, account, and mandatory reservation height internally.
source: ['codex']
There was a problem hiding this comment.
Resolved in 440897c — Registration still does not preserve unique reservation ownership no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| let core = wallet.core(); | ||
| let sibling_alias_alive = | ||
| PLATFORM_WALLET_STORAGE.any(|other| other.core().is_same_generation(core)); | ||
| if !sibling_alias_alive { | ||
| runtime().block_on( | ||
| crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY | ||
| .release_entries_for_wallet(core), | ||
| ); |
There was a problem hiding this comment.
🔴 Blocking: Destroying the final platform-wallet alias invalidates independently owned payment tokens
platform_wallet_destroy releases every token for the generation when no other PlatformWallet wrapper is currently stored. Those wrappers do not own the logical wallet: the manager still owns it and can return another alias, platform_wallet_get_core creates independently owned core handles, each registry entry retains its own CoreWallet, and Kotlin documents SignedCoreTransaction as the token owner. Closing or collecting the last wrapper therefore consumes and releases a still-live payment token even though its owning object was neither closed nor used, so a later merchant acknowledgement cannot be broadcast through a retained core handle or reacquired alias. Token cleanup must follow the payment owner or actual wallet-generation removal, not transient wrapper-alias count.
source: ['codex']
There was a problem hiding this comment.
Resolved in 440897c — Destroying the final platform-wallet alias invalidates independently owned payment tokens no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| }); | ||
| let result = unwrap_option_or_return!(option); | ||
| match result { | ||
| Ok(_) => PlatformWalletFFIResult::ok(), | ||
| Ok(removed) => { | ||
| // Generation teardown: the wallet and its accounts' `ReservationSet`s | ||
| // are now gone from the manager, so the deferred-payment reservations | ||
| // cease to exist — there is nothing to reconcile. DROP (do not | ||
| // release) this generation's registry tokens and its finalized-tx V2 | ||
| // handles. This is the teardown half of the single generation policy | ||
| // both deferred paths share: it makes any stale handle to the removed | ||
| // generation inert, so a later destroy/release of a lingering handle | ||
| // can never release-by-outpoint against a re-created generation's | ||
| // inputs. | ||
| let core = removed.core(); | ||
| crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY | ||
| .remove_entries_for_wallet(core); | ||
| crate::handle::CORE_SIGNED_TRANSACTION_V2_STORAGE | ||
| .remove_matching(|tx| tx.wallet.is_same_generation(core)); |
There was a problem hiding this comment.
🔴 Blocking: Wallet removal is not linearized with finalization or broadcast
Logical wallet removal and registry cleanup are separate operations. An existing token can be consumed after manager.remove_wallet has deleted its generation but before remove_entries_for_wallet runs: the retained old core handle still matches the entry, last_processed_height() returns None, reservation_expired treats that as valid, and the transaction reaches the broadcaster. Conversely, finalize_transaction releases the manager lock before awaiting the external signer; removal can delete the generation and complete its token sweep during that await, after which the finalizer registers a new token without revalidating that the generation still exists. Coordinate token registration and consumption with generation removal under a shared lifecycle lock or gate, reject an absent current generation, and ensure teardown waits for or sweeps in-flight finalizers.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Wallet removal is not linearized with finalization or broadcast no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| /// Opaque handle to a registered, signed-but-unsent payment. Minted by | ||
| /// [`SignedPaymentRegistry::register`]; consumed by | ||
| /// [`SignedPaymentRegistry::broadcast`] or | ||
| /// [`SignedPaymentRegistry::release`]. Values are unique for the process | ||
| /// lifetime and never reused, so a stale token can always be recognised. | ||
| pub type ReservationToken = u64; |
There was a problem hiding this comment.
💬 Nitpick: The reservation capability remains an untyped u64 alias
ReservationToken has single-use capability and provenance semantics distinct from ordinary numeric identifiers, but its type alias provides no Rust-side domain separation. Registry and boundary code can accidentally interchange unrelated u64 handles with destructive reservation operations without a type error. A #[repr(transparent)] newtype would preserve straightforward C/JNI conversion while enforcing the distinction within Rust.
source: ['codex']
There was a problem hiding this comment.
Resolved in 440897c — The reservation capability remains an untyped u64 alias no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
Point all rust-dashcore crates at bfoss765/rust-dashcore e99959ced0062159d629930f488374e29f63c42b (PR dashpay/rust-dashcore#916), which is v4.1-dev's rust-dashcore tip 70d4bf8 plus the additive owner-tagged reservation API: key_wallet::ReservationToken, ReservationSet::reserve/release_if_owner, TransactionBuilder::build_{unsigned,signed}_reserved, ManagedCoreFundsAccount::release_reservation_if_owner, and AssetLockResult.reservation_token. Additive over 70d4bf8, so it stays compatible with the rest of the v4.1-dev workspace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v4.1-dev added ErrorTransactionBroadcastRejected = 26, colliding with this PR's three deferred-reservation siblings that also claimed 26/27/28. Keep v4.1-dev's 26 and shift this PR's codes up by one: 27 = ErrorStaleReservationToken (was 26) 28 = ErrorReservationTokenConsumed (was 27) 29 = ErrorReservationWalletMismatch (was 28) 29 is free on v4.1-dev (dashpay#4184's AssetLockInsufficientFunds is not yet merged there). The Rust FFI enum and Swift bindings were renumbered in the rebase conflict resolution; this finishes the propagation through the Kotlin runtime mapping and KDoc (DashSdkError.kt, WalletManagerNative.kt), the Kotlin error-code test, and the signed_payment FFI doc comments (also recast from fix-round narration to an as-built description). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lease A deferred send reserves its funding inputs at build, awaits the broadcast, and on a definitive rejection releases the reservation for an immediate rebuild. That release was unconditional (release-by-outpoint): during the broadcast await, key-wallet's TTL sweep can reclaim the reservation and a concurrent build can re-reserve the same outpoint under a new token, so the by-outpoint release would free that other build's inputs — the dashpay#4185 release/re-reserve double-spend window. Capture the key_wallet::ReservationToken build_unsigned_reserved stamps onto the selected inputs, carry it on SignedCoreTransaction alongside reservation_height, thread it through the deferred registry (RegisteredPayment / register / broadcast / reconcile) and broadcast_payment_releasing_reservation, and release via ManagedCoreFundsAccount::release_reservation_if_owner so a rejected or abandoned send frees only inputs its own build still owns. The finalize sign-failure path (a platform-side await between reserve and release) is owner-guarded the same way. None (no reservation taken) keeps the old by-outpoint fallback, never reached on the funded finalize path. Adds a regression test: a rejected deferred broadcast whose outpoint was swept and re-reserved under a new token leaves that new reservation intact. Docs name the shared generation identity's sibling V2 handle path (dashpay#4196). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
911c8f7 to
3f719d4
Compare
|
After the rebase onto v4.1-dev, this PR's deferred-reservation errors are #4184 independently allocates Whichever of #4184 / #4185 merges SECOND must shift its |
|
Round-4 verification (two independent passes, reconciled): the rebase preserved the atomicity work — atomic selection+reservation and owner-token-guarded cleanup verified (registry 20/20, core transaction 3/3), and the new owner-guard on the broadcast-reject release answers the post-await recheck ask. Three items:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
At exact head 3f719d4, the newest owner-token delta correctly carries key-wallet's reservation token through finalization and uses owner-guarded release; its regression test models a sweep, re-reservation, and rejected broadcast and verifies that the newer reservation survives. Three carried-forward blocking lifecycle issues remain: public registration does not enforce unique ownership, final wallet-alias destruction consumes independently owned payments, and wallet removal is not linearized with finalization or broadcast. The carried-forward untyped-token nitpick also remains, and the latest error-code renumbering left one JNI Rustdoc block on the old values.
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— ffi-engineer (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 💬 1 nitpick(s)
4 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
| /// throws one of three sibling codes: `ErrorStaleReservationToken` (26, aged | ||
| /// out), `ErrorReservationTokenConsumed` (27, unknown / already broadcast / | ||
| /// already released), or `ErrorReservationWalletMismatch` (28, different wallet | ||
| /// generation). `coreHandle` must resolve to the wallet the token was minted |
There was a problem hiding this comment.
💬 Nitpick: JNI Rustdoc still lists the pre-renumbered deferred error codes
The native deferred-payment errors and Kotlin declarations now use codes 27, 28, and 29, but this exported JNI function still documents them as 26, 27, and 28. Rust-side consumers using this documentation to diagnose or mirror exceptions will associate each condition with the wrong native code.
| /// throws one of three sibling codes: `ErrorStaleReservationToken` (26, aged | |
| /// out), `ErrorReservationTokenConsumed` (27, unknown / already broadcast / | |
| /// already released), or `ErrorReservationWalletMismatch` (28, different wallet | |
| /// generation). `coreHandle` must resolve to the wallet the token was minted | |
| /// throws one of three sibling codes: `ErrorStaleReservationToken` (27, aged | |
| /// out), `ErrorReservationTokenConsumed` (28, unknown / already broadcast / | |
| /// already released), or `ErrorReservationWalletMismatch` (29, different wallet | |
| /// generation). `coreHandle` must resolve to the wallet the token was minted |
source: ['codex']
There was a problem hiding this comment.
Resolved in 440897c — JNI Rustdoc still lists the pre-renumbered deferred error codes no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
…tream failures Addresses the CodeRabbit Major (PR dashpay#916, asset_lock_builder.rs :219-252 / :293-357). Both asset-lock builders took a reservation via build_signed_reserved and captured its reservation_token, but a failure in the code that runs AFTER the successful signed build — credit-key derivation in build_asset_lock, and the Phase 1-3 signer/bookkeeping loop in build_asset_lock_with_signer — returned Err without releasing the reservation. The already-signed transaction's inputs then stranded until the 24-block TTL sweep, since the caller never received the token to release them itself. Clone the funding account's ReservationSet (a shared Arc view) before each loop re-borrows self.accounts, and on any failure release only THIS build's reservation with the owner-guarded release_if_owner(&reserved, token) — never the unconditional release, which could free a concurrent build's inputs that the TTL sweep + re-reserve handed over during this build (the very TOCTOU of dashpay/platform#4185 this PR closes). The success path is unchanged: a successful build consumes the reservation as designed, and the signer-failure release inside build_signed_reserved returns via `?` before this new code runs, so there is no double-release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
review blocker 2) Reviewer (thepastaclaw) blocker 2 — build.rs:423-424: build_asset_lock_tx_from_selected_account passed the BIP44 change account (&bip44_acc) as set_funding's `acc`, but set_funding calls funds_acc.next_change_address(Some(&acc.account_xpub)) on the SELECTED funds account before the set_change_address override. For an explicitly selected Standard BIP32 account with no pre-generated unused internal address, that derived [1, index] from the wrong (BIP44) xpub and recorded it under the BIP32 account's own path, poisoning that pool so a later normal BIP32 send could use a change entry whose signer key does not match the address. Now resolve the wallet-level Account whose account-level derivation path equals funding_path and pass ITS xpub to set_funding, while keeping the separate BIP44 set_change_address override. Default BIP44 funding resolves to bip44_acc (unchanged); non-Standard (CoinJoin/DashPay) accounts fail change derivation regardless, so the xpub is immaterial and the bip44_acc fallback preserves prior behavior. Also (thepastaclaw nitpick, test_support.rs): move the DashPay fixture rustdoc so it attaches to split_funded_wallet_manager_dashpay rather than foreign_contact_account_xpub. Blocker 1 (build.rs:427-473, owner-guarded reservation rollback on the pre-broadcast abandonment path) is NOT addressed here: the pinned key-wallet rev 70d4bf8 exposes no owner-guarded release primitive (ReservationSet has only reserve/reserved/release keyed by outpoint; release_reservation is unconditional) and no reservation token, so the required release_if_owner(token) mechanism (rust-dashcore#916 / dashpay#4185) cannot be implemented against this pin without an upstream dependency change — the very atomic-reservation fix this PR is held for. Deferred pending that adoption rather than substituting an unconditional release on the money path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…per-destroy from consuming payments, type the token Addresses two of the three carried-forward lifecycle blockers on dashpay#4185 plus the two smaller review items. The wallet-removal/finalize linearization blocker is intentionally NOT included here (see PR discussion) — it needs a shared lifecycle gate that is a design change on a money path. Blocker 1 — unique reservation ownership: `SignedPaymentRegistry::register` now CONSUMES the non-`Clone` `SignedCoreTransaction` and derives the transaction, funding account, mandatory reservation height, and owner-guard token from it (new `SignedCoreTransaction::into_registered_parts`). Because the ownership object is moved exactly once, a single finalize can no longer mint two live tokens naming the same held reservation. The FFI finalizer passes the finalized object straight in; the former duplicate-registration test (16 clones of one reserved tx) is removed as it modelled the now-impossible pattern. Blocker 2 — final wallet-alias destroy no longer consumes independently-owned payments: `platform_wallet_destroy` no longer releases the generation's tokens when the last wrapper alias is dropped. A wrapper handle does not own the logical wallet or the registered payment (the manager still owns the wallet; each registry entry pins its own `CoreWallet`). Token cleanup now follows the payment owner (broadcast/release) or actual generation teardown (`remove_wallet` → `remove_entries_for_wallet`), never a transient alias count. The unused `release_entries_for_wallet` method and its test are removed; the destroy test now asserts tokens survive destroying every alias. Nit — typed token: `ReservationToken` is now a `#[repr(transparent)]` newtype instead of a bare `u64` alias, converted to/from `u64` only at the FFI boundary, so a payment handle can't be silently confused with another numeric id. Docs — JNI Rustdoc: the `coreWalletBroadcastSignedPayment` block referenced the pre-renumber codes (26/27/28); updated to the current enum values (27 StaleReservationToken / 28 ReservationTokenConsumed / 29 ReservationWalletMismatch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@thepastaclaw pushed fixes for 2 of the 3 blockers + both smaller items in
Blocker 3 (wallet removal not linearized with finalize/broadcast) — holding for a design decision, not a minimal patch. Both windows you describe are real: (a) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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: reviewersgpt-5.6-sol; verifiergpt-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.
| 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, | ||
| }, | ||
| ); |
There was a problem hiding this comment.
🔴 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']
| 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 | ||
| } |
There was a problem hiding this comment.
🔴 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']
| val blob = WalletManagerNative.coreWalletFinalizeSignedPayment( | ||
| builderPtr, | ||
| walletHandle, | ||
| accountType.ffiValue, | ||
| accountIndex, | ||
| coreSignerHandle, | ||
| ) | ||
| return ManagedPlatformWallet.SignedCoreTransaction.fromRegisterBlob(blob) |
There was a problem hiding this comment.
🟡 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.
| 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']
| /// 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) | ||
| } |
There was a problem hiding this comment.
💬 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']
Splits transaction build from broadcast for BIP70-style deferred submission: a signed-payment registry with reservation release, a deferred-payment token bounded to the reservation's lifetime, native code 26 for stale reservation tokens, token sweeping only when the final wallet write wins, and routing of deferred builds through the atomic finalize-and-register path — across
rs-platform-wallet,platform-wallet-ffi,rs-unified-sdk-jni, and the Kotlin SDK surface.Re-opens #4090 which was auto-closed when the #3999 base branch was deleted; rebased onto
v4.1-dev. All seven original commits replayed cleanly — no hunks needed to be dropped as already-absorbed.Verified:
cargo test -p platform-wallet -p platform-wallet-ffi -p rs-unified-sdk-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; error code 26 is split into typed siblings 26StaleReservationToken/ 27ReservationTokenConsumed/ 28ReservationWalletMismatch(Kotlin-only host impact — Swift's enum never mapped 26 and falls through to unknown safely; codes 29/30 are used by the asset-lock PR #4184, coordinated to avoid collision); the 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.