Skip to content

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

Open
bfoss765 wants to merge 7 commits into
dashpay:feat/kotlin-sdk-and-example-appfrom
bfoss765:feat/kotlin-sdk-split-build-broadcast
Open

feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission#4090
bfoss765 wants to merge 7 commits into
dashpay:feat/kotlin-sdk-and-example-appfrom
bfoss765:feat/kotlin-sdk-split-build-broadcast

Conversation

@bfoss765

Copy link
Copy Markdown

Implements #4089 (design agreed there). Context: dashpay/dash-wallet#1507 Phase 5c GAP-4.

Problem

BIP70/BIP270 (CTX/DashSpend) sends sign, POST the raw bytes to a merchant server, and broadcast only on the server's ack — a flow that is structurally impossible on the one-shot ManagedPlatformWallet.sendToAddresses (which builds, signs, and broadcasts in one uninterrupted step). This exposes the existing internal build/broadcast split with an explicit reservation lifecycle, while keeping CoreTransactionBuilder internal so the manager remains the sole driver of the setFunding/buildSigned selection race (its KDoc safety argument is preserved).

API (purely ADDITIVE — no existing signature changes)

Kotlin, on ManagedPlatformWallet:

class SignedCoreTransaction(val txidHex, val rawTxBytes, val feeDuffs, val reservationToken: Long)

suspend fun buildSignedPayment(recipients, network, coreSignerHandle, accountType, accountIndex): SignedCoreTransaction
suspend fun broadcastSigned(token: Long): String   // merchant server acked
suspend fun releaseReservation(token: Long)         // abandoned / server nacked

buildSignedPayment runs new → addOutput* → setFunding → buildSigned under the same per-wallet coreSendMutex as sendToAddresses, closing the double-selection window; buildSigned reserves the UTXOs, so broadcastSigned / releaseReservation operate on the token later, without the mutex.

Token / reservation design

  • A new generic SignedPaymentRegistry (in rs-platform-wallet) owns the built tx + its held reservation between build and submission, keyed by an opaque ReservationToken. The FFI layer instantiates one process-global registry pinned to SpvBroadcaster.
  • broadcast removes the entry before sending, so a repeated/concurrent broadcast of the same token gets a typed StaleToken error — never a double-broadcast. On failure it reuses the existing release-on-rejection / keep-on-ambiguous policy.
  • Each token is bound to its originating wallet instance (Arc::ptr_eq on the shared WalletManager), so a re-created wallet is rejected rather than spending against stale state.
  • release is idempotent (unknown / already-consumed token → silent no-op) and needs no handle.
  • Process death: the registry and the underlying ReservationSet are both in-memory, so an app crash between build and broadcast drops the entry and the reservation together — nothing leaks across a restart. This matches dashj's memory-only in-flight reservations; there is no on-disk reservation persistence to follow.

Surface additions

  • FFI (platform-wallet-ffi): core_wallet_transaction_get_bytes, core_wallet_signed_payment_register, core_wallet_signed_payment_broadcast, core_wallet_signed_payment_release; new ErrorStaleReservationToken (22).
  • JNI (rs-unified-sdk-jni): coreTransactionGetBytes, coreWalletRegisterSignedPayment, coreWalletBroadcastSignedPayment, coreWalletReleaseSignedPayment.
  • Kotlin: SignedCoreTransaction, the three methods above, and DashSdkError.PlatformWallet.StaleReservationToken.

The immediate sendToAddresses / coreWalletBroadcastTransaction path is unchanged.

Tests

Rust (SignedPaymentRegistry, 10 tests, all green):

  • build → broadcast happy path (broadcast bytes byte-identical to the registered tx; returned txid matches)
  • build → release → funds spendable again (a rebuild reselects the released UTXO; both BIP44 and BIP32 arms)
  • double-broadcast → StaleToken (network hit exactly once)
  • double-release → idempotent; broadcast-after-release → StaleToken; unknown token → StaleToken
  • token from a different wallet instance → WalletMismatch (nothing sent)
  • ambiguous broadcast keeps the reservation and still consumes the token
  • concurrent broadcasts serialize to exactly one send; concurrent registers yield distinct tokens

Full platform-wallet / platform-wallet-ffi / rs-unified-sdk-jni suites pass; clippy green. Kotlin: added DashSdkErrorTest coverage for the code-22 mapping; :sdk:compileReleaseKotlin green. The Kotlin/JNI native path is exercised only by instrumented tests, so per convention Rust coverage + compile stands in there. The native library was not rebuilt.

cc @QuantumExplorer

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd453643-b9d9-4b6f-b975-49564515b1f9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

@thepastaclaw

thepastaclaw commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 84ef267)
Canonical validated blockers: 3

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

This PR adds a well-tested SignedPaymentRegistry enabling BIP70-style deferred build→broadcast/release for Core sends, with clean Rust idioms (no unwrap, correct pre-await mutex release, double-broadcast prevention). Verification against the actual code confirms one real blocking defect: registry tokens never expire even though the underlying key-wallet UTXO reservation they depend on auto-expires after 24 blocks (~1hr) and is released by raw outpoint with no ownership check, so a delayed merchant ack/nack on a long-outstanding token can silently corrupt or double-spend a completely unrelated, later-built transaction that reused the same now-expired UTXO. Several other issues (wallet-instance binding gaps, an architecture-rule violation on the Kotlin JNI boundary, and orphan-on-failure edge cases) are real but lower-impact.

Source (experiment sonnet-primary-opus-quarter-sample-20260710, cohort sonnet_opus_sample, bucket 0): reviewers codex/general=gpt-5.6-sol(completed); sonnet5/general=claude-sonnet-5(completed); opus/general=claude-opus-4-8(completed); codex/security-auditor=gpt-5.6-sol(completed); sonnet5/security-auditor=claude-sonnet-5(completed); opus/security-auditor=claude-opus-4-8(completed); codex/rust-quality=gpt-5.6-sol(completed); sonnet5/rust-quality=claude-sonnet-5(completed); opus/rust-quality=claude-opus-4-8(completed); codex/ffi-engineer=gpt-5.6-sol(completed); sonnet5/ffi-engineer=claude-sonnet-5(completed); opus/ffi-engineer=claude-opus-4-8(completed); verifier=verifier-sonnet5-4090-1783717967=claude-sonnet-5; orchestrator=openai/gpt-5.6-sol reasoning=high (orchestration-only, not a reviewer/verifier).

🔴 1 blocking | 🟡 4 suggestion(s) | 💬 2 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:130-230: Registry tokens never expire, but the UTXO reservation they depend on does — release-by-outpoint can corrupt a newer, unrelated reservation
  Verified against key-wallet's `ReservationSet` (`managed_account/reservation.rs`): `RESERVATION_TTL_BLOCKS = 24` (~1hr), and any later `reserve()`/`reserved()` call sweeps entries older than that, silently returning the outpoint to the selectable pool. `release()` then removes an outpoint unconditionally (`reserved.remove(outpoint)`) with no generation/ownership check.

  `SignedPaymentRegistry` entries carry no age/height and are never swept — a token stays broadcastable/releasable indefinitely until an explicit `broadcast`/`release` call. This PR's entire purpose is to widen the build→submit gap for BIP70/BIP270 flows where the merchant ack is asynchronous and can legitimately take longer than an hour (slow, unresponsive, or simply a user who steps away).

  Concrete failure sequence: (1) build+register payment A, reserving UTXO X at height H; merchant never acks. (2) At height ≥ H+24, X's reservation is swept on the next unrelated `reserve`/`reserved` call. (3) A completely different payment B is built, reselecting and re-reserving X at the new height. (4) The merchant for A finally acks; `broadcastSigned(tokenA)` runs. If the network rejects A's now-conflicting tx, `broadcast_releasing_on_rejection` releases X by outpoint — which removes B's *active* reservation, letting a third build reselect X while B's transaction may still be in flight, risking a real double-spend. If A's broadcast instead succeeds first, B's later build/broadcast fails unpredictably. Either way the outcome is driven entirely by third-party (merchant) timing outside the wallet's control.

  The registry needs either a bounded token lifetime enforced client-side (reject broadcast/require re-registration once the reservation could plausibly have been swept) or a generation-aware release that only frees an outpoint if it still belongs to the same token's reservation.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:183-186: Wallet-instance guard only detects a different PlatformWalletManager, not a different wallet within the same manager
  Verified: `PlatformWalletManager` (`manager/mod.rs:39-47`) is documented as a "Multi-wallet coordinator" holding exactly one `wallet_manager: Arc<RwLock<WalletManager<PlatformWalletInfo>>>` shared by every registered wallet (`wallets: Arc<RwLock<BTreeMap<WalletId, Arc<PlatformWallet>>>>`), and `PlatformWallet::new`/`platform_wallet_get_core` construct every `CoreWallet` from that same cloned Arc. So `Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager)` is true for *any two wallets in the same manager* — it only catches recreation of the whole manager (e.g. the documented network-switch destroy+recreate), not per-wallet removal/recreation or cross-wallet token reuse within one live manager.

  Impact is bounded, not a fund-safety bypass: the actual broadcast/release always operates through `entry.core` (the wallet captured at registration), which still resolves the correct `wallet_id`/account internally — a caller can't spend wallet B's funds with wallet A's token. But the doc comment's claim that a token is "bound to the exact wallet instance it was minted against" is only true across manager instances, not across wallets sharing one manager, and the existing test (`broadcast_rejects_a_different_wallet_instance`) only covers the cross-manager case with two entirely separate `WalletManager`s, missing the realistic same-manager, different-`wallet_id` scenario this multi-wallet coordinator is built for. Tighten the check to also compare `entry.core.wallet_id()` against `current.wallet_id()`.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedCoreWallet.kt:49-71: registerSignedPayment stitches two native round-trips in Kotlin, contrary to this repo's kotlin-sdk rule
  `registerSignedPayment` calls `coreTransactionGetBytes(tx.handle)` and then `coreWalletRegisterSignedPayment(...)`, stitching the two results into one `SignedCoreTransaction` with hand-rolled `ByteBuffer` decoding. `packages/kotlin-sdk/CLAUDE.md` states explicitly: "No JNI functions that stitch together existing Rust calls — add the composite to Rust instead." Confirmed `core_wallet_transaction_get_bytes`/`coreTransactionGetBytes` has exactly one Kotlin call site — this one — so there's no other caller relying on it staying separate.

  `core_wallet_signed_payment_register` (`rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs:84`) already has `(*tx).bytes()` in scope (it uses it to deserialize the transaction at line 100); it could copy those bytes into an additional out-parameter and return everything in one native round trip, removing `coreTransactionGetBytes` from this path entirely.

In `packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs:84-134: Reservation-holding register() can orphan its UTXO reservation on failure paths after the side effect commits
  `core_wallet_signed_payment_register` calls `SIGNED_PAYMENT_REGISTRY.register(core, transaction, ...)` at line 112 — an irreversible side effect that mints a token and keeps `build_signed`'s UTXO reservation held — before the fallible `CString::new(txid.to_string())` at line 120. If that (admittedly near-impossible, since txid hex can't contain a NUL) conversion fails, the function returns an error without ever writing `out_token`, orphaning the reservation with no token to release it.

  The same class of bug reaches further down the JNI boundary: in `rs-unified-sdk-jni/src/wallet_manager.rs` (`coreWalletRegisterSignedPayment`, lines ~1053-1090), the native call already committed the registration by the time `env.byte_array_from_slice(&blob)` runs at line 1087. That call's failure path is `.unwrap_or(ptr::null_mut())` — no exception thrown, no token returned to Kotlin, same orphaned-reservation outcome (this time reachable on JVM allocation pressure rather than an impossible NUL byte).

  Both paths leave the reservation to be reclaimed only by the 24-block TTL backstop. Reorder the fallible/pure steps before the registry insert on the Rust side, and release the token on the JNI marshalling-failure path.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs:98: Registry captures its own CoreWallet Arc clone, bypassing core_wallet_destroy/platform_wallet_destroy teardown
  `register()` clones the `CoreWallet<B>` from `CORE_WALLET_STORAGE` (line 98) and stores it inside `RegisteredPayment` in the process-global `SIGNED_PAYMENT_REGISTRY` — independent of the handle-storage lifecycle. `core_wallet_destroy`/`platform_wallet_destroy` only remove the handle-storage entry, dropping *that* Arc reference; they have no visibility into the registry.

  If a caller registers a token and never calls `broadcastSigned`/`releaseReservation` on it (app killed mid-flow, or — per `packages/kotlin-sdk/CLAUDE.md`'s own documented pattern, "Network switch = destroy + new instance" — a network switch destroys the `PlatformWalletManager` while a token is outstanding), the registry entry keeps the entire `WalletManager` (accounts, keys, sync state) alive for the rest of the process. Not memory-unsafe, but it silently defeats the destroy-wallet invariant the SDK's own docs rely on, with no error surfaced to the caller.

Comment on lines +130 to +230
pub fn register(
&self,
core: CoreWallet<B>,
tx: Transaction,
account_type: Option<StandardAccountType>,
account_index: u32,
) -> ReservationToken {
let token = self.next_token.fetch_add(1, Ordering::SeqCst);
self.entries
.lock()
.expect("signed-payment registry mutex poisoned")
.insert(
token,
RegisteredPayment {
core,
tx,
account_type,
account_index,
},
);
token
}

/// Broadcast the payment behind `token`, reconciling its UTXO reservation on
/// failure, then consume the token.
///
/// The entry is removed **before** the send, so a repeated or concurrent
/// broadcast of the same token gets [`SignedPaymentError::StaleToken`]
/// instead of a second send. `current` must be the same wallet instance the
/// token was minted against (checked by `Arc::ptr_eq` on the shared
/// `WalletManager`); otherwise the call fails with
/// [`SignedPaymentError::WalletMismatch`] and the stale token is dropped.
///
/// On a definitive rejection the reservation is released for an immediate
/// rebuild; on an ambiguous ("may already be on the network") failure it is
/// kept — the same policy as the non-deferred send path.
pub async fn broadcast(
&self,
token: ReservationToken,
current: &CoreWallet<B>,
) -> Result<Txid, SignedPaymentError> {
// Remove under the lock and drop the guard *before* awaiting — a
// std::Mutex guard must never be held across an await point, and the
// atomic take is what makes a double-broadcast impossible.
let entry = {
let mut entries = self
.entries
.lock()
.expect("signed-payment registry mutex poisoned");
entries.remove(&token)
}
.ok_or(SignedPaymentError::StaleToken(token))?;

if !Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager) {
// The token belongs to another wallet instance; it has been removed,
// so it can never be replayed here.
return Err(SignedPaymentError::WalletMismatch(token));
}

let txid = match entry.account_type {
Some(account_type) => {
entry
.core
.broadcast_transaction_releasing_reservation(
account_type,
entry.account_index,
&entry.tx,
)
.await?
}
None => entry.core.broadcast_transaction(&entry.tx).await?,
};
Ok(txid)
}

/// Release the funding reservation behind `token` and drop it. Idempotent:
/// releasing an unknown / already-consumed token is a silent no-op, so a
/// double release (or a release after a broadcast) is harmless.
///
/// The release acts on the wallet instance the token was minted against —
/// the one whose `ReservationSet` actually holds the inputs — so no wallet
/// handle need be threaded in.
pub async fn release(&self, token: ReservationToken) {
let entry = {
let mut entries = self
.entries
.lock()
.expect("signed-payment registry mutex poisoned");
entries.remove(&token)
};
let Some(entry) = entry else {
// Unknown / already consumed — idempotent no-op.
return;
};
if let Some(account_type) = entry.account_type {
entry
.core
.release_transaction_reservation(account_type, entry.account_index, &entry.tx)
.await;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Registry tokens never expire, but the UTXO reservation they depend on does — release-by-outpoint can corrupt a newer, unrelated reservation

Verified against key-wallet's ReservationSet (managed_account/reservation.rs): RESERVATION_TTL_BLOCKS = 24 (~1hr), and any later reserve()/reserved() call sweeps entries older than that, silently returning the outpoint to the selectable pool. release() then removes an outpoint unconditionally (reserved.remove(outpoint)) with no generation/ownership check.

SignedPaymentRegistry entries carry no age/height and are never swept — a token stays broadcastable/releasable indefinitely until an explicit broadcast/release call. This PR's entire purpose is to widen the build→submit gap for BIP70/BIP270 flows where the merchant ack is asynchronous and can legitimately take longer than an hour (slow, unresponsive, or simply a user who steps away).

Concrete failure sequence: (1) build+register payment A, reserving UTXO X at height H; merchant never acks. (2) At height ≥ H+24, X's reservation is swept on the next unrelated reserve/reserved call. (3) A completely different payment B is built, reselecting and re-reserving X at the new height. (4) The merchant for A finally acks; broadcastSigned(tokenA) runs. If the network rejects A's now-conflicting tx, broadcast_releasing_on_rejection releases X by outpoint — which removes B's active reservation, letting a third build reselect X while B's transaction may still be in flight, risking a real double-spend. If A's broadcast instead succeeds first, B's later build/broadcast fails unpredictably. Either way the outcome is driven entirely by third-party (merchant) timing outside the wallet's control.

The registry needs either a bounded token lifetime enforced client-side (reject broadcast/require re-registration once the reservation could plausibly have been swept) or a generation-aware release that only frees an outpoint if it still belongs to the same token's reservation.

source: ['claude', 'codex']

@thepastaclaw thepastaclaw Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Still valid at a428f54 — the new 20-block registry guard reads synced_height(), while the production reservation is stamped with last_processed_height(), so the clocks can diverge and the original reservation-ownership risk remains. The current-head blocking thread is #4090 (comment).

The earlier automated resolution reply was corrected because title-based reconciliation failed to match the reworded carried-forward finding.

Comment on lines +183 to +186
if !Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager) {
// The token belongs to another wallet instance; it has been removed,
// so it can never be replayed here.
return Err(SignedPaymentError::WalletMismatch(token));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Wallet-instance guard only detects a different PlatformWalletManager, not a different wallet within the same manager

Verified: PlatformWalletManager (manager/mod.rs:39-47) is documented as a "Multi-wallet coordinator" holding exactly one wallet_manager: Arc<RwLock<WalletManager<PlatformWalletInfo>>> shared by every registered wallet (wallets: Arc<RwLock<BTreeMap<WalletId, Arc<PlatformWallet>>>>), and PlatformWallet::new/platform_wallet_get_core construct every CoreWallet from that same cloned Arc. So Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager) is true for any two wallets in the same manager — it only catches recreation of the whole manager (e.g. the documented network-switch destroy+recreate), not per-wallet removal/recreation or cross-wallet token reuse within one live manager.

Impact is bounded, not a fund-safety bypass: the actual broadcast/release always operates through entry.core (the wallet captured at registration), which still resolves the correct wallet_id/account internally — a caller can't spend wallet B's funds with wallet A's token. But the doc comment's claim that a token is "bound to the exact wallet instance it was minted against" is only true across manager instances, not across wallets sharing one manager, and the existing test (broadcast_rejects_a_different_wallet_instance) only covers the cross-manager case with two entirely separate WalletManagers, missing the realistic same-manager, different-wallet_id scenario this multi-wallet coordinator is built for. Tighten the check to also compare entry.core.wallet_id() against current.wallet_id().

Suggested change
if !Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager) {
// The token belongs to another wallet instance; it has been removed,
// so it can never be replayed here.
return Err(SignedPaymentError::WalletMismatch(token));
if !Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager)
|| entry.core.wallet_id() != current.wallet_id()
{
return Err(SignedPaymentError::WalletMismatch(token));
}

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in a428f54Wallet-instance guard only detects a different PlatformWalletManager, not a different wallet within the same manager no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +49 to +71
internal fun registerSignedPayment(
tx: CoreTransaction,
): ManagedPlatformWallet.SignedCoreTransaction {
val rawTxBytes = WalletManagerNative.coreTransactionGetBytes(tx.handle)
val blob = WalletManagerNative.coreWalletRegisterSignedPayment(
handle,
tx.handle,
tx.accountType.ffiValue,
tx.accountIndex,
)
val buffer = java.nio.ByteBuffer.wrap(blob) // big-endian by default
val token = buffer.long
val feeDuffs = buffer.long
val txidLen = buffer.int
val txidBytes = ByteArray(txidLen)
buffer.get(txidBytes)
return ManagedPlatformWallet.SignedCoreTransaction(
txidHex = String(txidBytes, Charsets.UTF_8),
rawTxBytes = rawTxBytes,
feeDuffs = feeDuffs,
reservationToken = token,
)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: registerSignedPayment stitches two native round-trips in Kotlin, contrary to this repo's kotlin-sdk rule

registerSignedPayment calls coreTransactionGetBytes(tx.handle) and then coreWalletRegisterSignedPayment(...), stitching the two results into one SignedCoreTransaction with hand-rolled ByteBuffer decoding. packages/kotlin-sdk/CLAUDE.md states explicitly: "No JNI functions that stitch together existing Rust calls — add the composite to Rust instead." Confirmed core_wallet_transaction_get_bytes/coreTransactionGetBytes has exactly one Kotlin call site — this one — so there's no other caller relying on it staying separate.

core_wallet_signed_payment_register (rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs:84) already has (*tx).bytes() in scope (it uses it to deserialize the transaction at line 100); it could copy those bytes into an additional out-parameter and return everything in one native round trip, removing coreTransactionGetBytes from this path entirely.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in a428f54registerSignedPayment stitches two native round-trips in Kotlin, contrary to this repo's kotlin-sdk rule no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +84 to +134
pub unsafe extern "C" fn core_wallet_signed_payment_register(
core_handle: Handle,
tx: *const FFICoreTransaction,
account_type: CoreAccountTypeFFI,
account_index: u32,
out_token: *mut u64,
out_fee: *mut u64,
out_txid: *mut *mut c_char,
) -> PlatformWalletFFIResult {
check_ptr!(tx);
check_ptr!(out_token);
check_ptr!(out_fee);
check_ptr!(out_txid);

let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone()));

let transaction: dashcore::Transaction = match dashcore::consensus::deserialize((*tx).bytes()) {
Ok(t) => t,
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorDeserialization,
format!("failed to deserialize signed transaction: {e}"),
);
}
};
let txid = transaction.txid();
let fee = (*tx).fee();

let token = SIGNED_PAYMENT_REGISTRY.register(
core,
transaction,
account_type.as_standard_account_type(),
account_index,
);

// txid hex never contains a NUL, but handle the impossible case anyway.
let c_txid = match CString::new(txid.to_string()) {
Ok(s) => s,
Err(_) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorUtf8Conversion,
"txid string contained an interior NUL".to_string(),
);
}
};

*out_token = token;
*out_fee = fee;
*out_txid = c_txid.into_raw();
PlatformWalletFFIResult::ok()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Reservation-holding register() can orphan its UTXO reservation on failure paths after the side effect commits

core_wallet_signed_payment_register calls SIGNED_PAYMENT_REGISTRY.register(core, transaction, ...) at line 112 — an irreversible side effect that mints a token and keeps build_signed's UTXO reservation held — before the fallible CString::new(txid.to_string()) at line 120. If that (admittedly near-impossible, since txid hex can't contain a NUL) conversion fails, the function returns an error without ever writing out_token, orphaning the reservation with no token to release it.

The same class of bug reaches further down the JNI boundary: in rs-unified-sdk-jni/src/wallet_manager.rs (coreWalletRegisterSignedPayment, lines ~1053-1090), the native call already committed the registration by the time env.byte_array_from_slice(&blob) runs at line 1087. That call's failure path is .unwrap_or(ptr::null_mut()) — no exception thrown, no token returned to Kotlin, same orphaned-reservation outcome (this time reachable on JVM allocation pressure rather than an impossible NUL byte).

Both paths leave the reservation to be reclaimed only by the 24-block TTL backstop. Reorder the fallible/pure steps before the registry insert on the Rust side, and release the token on the JNI marshalling-failure path.

Suggested change
pub unsafe extern "C" fn core_wallet_signed_payment_register(
core_handle: Handle,
tx: *const FFICoreTransaction,
account_type: CoreAccountTypeFFI,
account_index: u32,
out_token: *mut u64,
out_fee: *mut u64,
out_txid: *mut *mut c_char,
) -> PlatformWalletFFIResult {
check_ptr!(tx);
check_ptr!(out_token);
check_ptr!(out_fee);
check_ptr!(out_txid);
let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone()));
let transaction: dashcore::Transaction = match dashcore::consensus::deserialize((*tx).bytes()) {
Ok(t) => t,
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorDeserialization,
format!("failed to deserialize signed transaction: {e}"),
);
}
};
let txid = transaction.txid();
let fee = (*tx).fee();
let token = SIGNED_PAYMENT_REGISTRY.register(
core,
transaction,
account_type.as_standard_account_type(),
account_index,
);
// txid hex never contains a NUL, but handle the impossible case anyway.
let c_txid = match CString::new(txid.to_string()) {
Ok(s) => s,
Err(_) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorUtf8Conversion,
"txid string contained an interior NUL".to_string(),
);
}
};
*out_token = token;
*out_fee = fee;
*out_txid = c_txid.into_raw();
PlatformWalletFFIResult::ok()
}
match env.byte_array_from_slice(&blob) {
Ok(array) => array.into_raw(),
Err(_) => {
let _ = unsafe {
platform_wallet_ffi::core_wallet_signed_payment_release(token)
};
ptr::null_mut()
}
}

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in a428f54Reservation-holding register() can orphan its UTXO reservation on failure paths after the side effect commits 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.

check_ptr!(out_fee);
check_ptr!(out_txid);

let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Registry captures its own CoreWallet Arc clone, bypassing core_wallet_destroy/platform_wallet_destroy teardown

register() clones the CoreWallet<B> from CORE_WALLET_STORAGE (line 98) and stores it inside RegisteredPayment in the process-global SIGNED_PAYMENT_REGISTRY — independent of the handle-storage lifecycle. core_wallet_destroy/platform_wallet_destroy only remove the handle-storage entry, dropping that Arc reference; they have no visibility into the registry.

If a caller registers a token and never calls broadcastSigned/releaseReservation on it (app killed mid-flow, or — per packages/kotlin-sdk/CLAUDE.md's own documented pattern, "Network switch = destroy + new instance" — a network switch destroys the PlatformWalletManager while a token is outstanding), the registry entry keeps the entire WalletManager (accounts, keys, sync state) alive for the rest of the process. Not memory-unsafe, but it silently defeats the destroy-wallet invariant the SDK's own docs rely on, with no error surfaced to the caller.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in a428f54Registry captures its own CoreWallet Arc clone, bypassing core_wallet_destroy/platform_wallet_destroy teardown no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +174 to +186
let entry = {
let mut entries = self
.entries
.lock()
.expect("signed-payment registry mutex poisoned");
entries.remove(&token)
}
.ok_or(SignedPaymentError::StaleToken(token))?;

if !Arc::ptr_eq(&entry.core.wallet_manager, &current.wallet_manager) {
// The token belongs to another wallet instance; it has been removed,
// so it can never be replayed here.
return Err(SignedPaymentError::WalletMismatch(token));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: std::Mutex poisoning permanently disables the process-global deferred-payment surface

register/broadcast/release all call .expect("signed-payment registry mutex poisoned") on the entries lock (lines 140, 178, 217). Since SIGNED_PAYMENT_REGISTRY is a single process-global, any panic anywhere while this lock is held poisons it for the entire process — every subsequent call then panics via .expect(), permanently disabling BIP70 deferred payments (for every wallet) until the app restarts. Availability hardening rather than an exploitable defect, but worth a poisoned.into_inner() recovery given the registry's process-wide blast radius — the sibling ReservationSet::lock() in key-wallet already does exactly this.

source: ['claude']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in a428f54std::Mutex poisoning permanently disables the process-global deferred-payment surface no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +49 to +54
/// Opaque handle to a registered, signed-but-unsent payment. Minted by
/// [`SignedPaymentRegistry::register`]; consumed by
/// [`SignedPaymentRegistry::broadcast`] or
/// [`SignedPaymentRegistry::release`]. Values are unique for the process
/// lifetime and never reused, so a stale token can always be recognised.
pub type ReservationToken = u64;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: ReservationToken is documented as opaque but is only a bare u64 alias

pub type ReservationToken = u64 gives Rust callers no domain separation for a capability-like, process-scoped handle whose uniqueness/provenance matters. A #[repr(transparent)] newtype would preserve the C ABI while catching accidental misuse of unrelated numeric IDs at compile time. Low priority — style/hygiene only.

source: ['codex']

@thepastaclaw thepastaclaw Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Still valid at a428f54ReservationToken remains a bare pub type ReservationToken = u64 alias. This nitpick was carried forward in the current review and intentionally not duplicated as a new inline comment.

The earlier automated resolution reply was corrected because title-based reconciliation failed to match the reworded carried-forward finding.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Follow-up review of PR #4090 at head a428f54 (prior head ee2135b). All seven prior findings were re-verified against current source, including the exact pinned key-wallet dependency (rev 1860089eddea27f66e5b3e637d18a73ae138478c) fetched from the cargo git checkout cache to resolve a factual disagreement among reviewer lanes. Carried-forward prior findings: prior-1 (blocking) is STILL VALID — first-principles inspection confirms core_wallet_tx_builder_build_signed (packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs:550) stamps the underlying ReservationSet TTL using last_processed_height(), while SignedPaymentRegistry::register (packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:210) records the wallet's synced_height() — a field independently documented (packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:96-99) as decoupled from and capable of regressing relative to the monotonic last_processed_height. The new registry test helper (build_signed_tx in the registry's own test module) happens to use synced_height() for both stamping and comparison, masking this divergence in tests while the production FFI path does not share that property. Three lanes (sonnet5-general, sonnet5-rust-quality, sonnet5-ffi-engineer) incorrectly reconciled this as FIXED by conflating the test helper's clock with the production FFI clock; that reconciliation does not hold under direct source inspection and is overridden here. prior-7 (nitpick) is STILL VALID — ReservationToken remains a bare pub type ReservationToken = u64 alias. Prior-2 (wallet-instance guard now also compares wallet_id()), prior-3 (Kotlin double round-trip eliminated — core_wallet_transaction_get_bytes/coreTransactionGetBytes no longer exist, tx bytes ride back in the single register BLOB), prior-4 (fallible CString::new now runs before the registry insert; the JNI byte_array_from_slice failure path now releases the token), prior-5 (registry-held CoreWallet Arc now swept via remove_entries_for_wallet on platform_wallet_destroy), and prior-6 (mutex poisoning now recovered via a lock() helper) are all FIXED and verified directly against current code. New findings in the latest delta: the remove_entries_for_wallet sweep added to fix prior-5 matches on Arc::ptr_eq(wallet_manager) && wallet_id rather than on the specific destroyed handle, so it does not distinguish between multiple live PlatformWallet handles that alias the same logical wallet — a pattern the Kotlin SDK's own doc comments (ManagedPlatformWallet.kt:38-40) explicitly anticipate (loadPersistedWallets can hand out a fresh wrapper for a wallet id an earlier wrapper still holds). Destroying/GC'ing one such alias silently invalidates a deferred-payment token registered through a sibling, wrongly rejecting a legitimate broadcast/release with StaleToken and pinning the UTXO reservation until the 20-block TTL backstop. A second, narrower manifestation of the same root cause is a race between an in-flight core_wallet_signed_payment_register on one short-lived core handle and a concurrent platform_wallet_destroy on another alias of the same wallet. Separately, buildSignedPayment in Kotlin commits the native registry token inside withContext(Dispatchers.IO); coroutine cancellation racing the dispatcher hop back to the caller can discard the returned SignedCoreTransaction after the token was already minted, orphaning it until the TTL backstop reclaims it. Review action is REQUEST_CHANGES due to the carried-forward blocking clock-mismatch and the new alias-teardown regression, both introduced/exposed by this PR's own reservation-hardening code.

Source (experiment sonnet-primary-opus-quarter-sample-20260710, cohort sonnet_primary, bucket 1): reviewers codex/general=gpt-5.6-sol(completed); sonnet5/general=claude-sonnet-5(completed); codex/security-auditor=gpt-5.6-sol(completed); sonnet5/security-auditor=claude-sonnet-5(completed); codex/rust-quality=gpt-5.6-sol(completed); sonnet5/rust-quality=claude-sonnet-5(completed); codex/ffi-engineer=gpt-5.6-sol(completed); sonnet5/ffi-engineer=claude-sonnet-5(completed); verifier=verifier-sonnet5-4090-1783735156=claude-sonnet-5; orchestrator=openai/gpt-5.6-sol reasoning=high (orchestration-only, not a reviewer/verifier).

🔴 2 blocking | 🟡 1 suggestion(s)

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

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

In `packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:203-223: Token expiry guard measures a different height clock than the reservation it bounds (prior-1, still valid)
  `register()` records `core.synced_height()` as `registered_height`, and `broadcast`/`release` compare it against a later `synced_height()` read using `RESERVATION_MAX_AGE_BLOCKS = 20`. But the underlying `ReservationSet` TTL that this guard is meant to stay under is stamped by `core_wallet_tx_builder_build_signed` (packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs:550-557) using `last_processed_height()`, not `synced_height()`. `payments.rs:96-99` documents these as intentionally decoupled clocks — `synced_height` is the filter-scan checkpoint and can regress during a DashPay rescan while `last_processed_height` remains monotonic. Whenever `synced_height` lags `last_processed_height` at registration time (or is later rolled back by a rescan), the 20-block guard can fail to trip before key-wallet's real 24-block `RESERVATION_TTL_BLOCKS` sweep (packages/rs-platform-wallet's pinned key-wallet dependency, `managed_account/reservation.rs:32,59-61`) has already reclaimed and possibly re-reserved the outpoint for an unrelated build. A broadcast or release on the stale token then acts on state it no longer owns — `ReservationSet::release`/`reserve` have no per-outpoint ownership check, so this can silently release or corrupt a newer build's reservation, the exact corruption class the guard was added to close. The new tests do not catch this because their `build_signed_tx` helper stamps and advances only `synced_height`, unlike the production FFI path. Track and pass through the exact height `build_signed` used (e.g. via `FFICoreTransaction`) instead of independently re-reading `synced_height` at registration.

In `packages/rs-platform-wallet-ffi/src/wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/wallet.rs:200-214: platform_wallet_destroy's registry sweep is keyed on wallet identity, not handle identity — destroying one alias invalidates a sibling wrapper's deferred payment
  `platform_wallet_destroy` now sweeps every registry entry whose `Arc::ptr_eq(wallet_manager)` and `wallet_id` match the destroyed handle's wallet (via `remove_entries_for_wallet`, packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:329-337). But a `PlatformWallet` handle is not a 1:1 proxy for the logical wallet's lifetime: `platform_wallet_manager_get_wallet` (packages/rs-platform-wallet-ffi/src/manager.rs:322-349) mints a fresh, independent handle on every call, cloning the same underlying `wallet_manager` Arc and `wallet_id`. The Kotlin SDK explicitly documents and relies on this — `ManagedPlatformWallet.kt:38-40` notes `loadPersistedWallets` can hand out a new wrapper for a wallet id an earlier live wrapper still holds. If wrapper B registers a deferred payment and wrapper A (a sibling alias of the same wallet) is then closed via `close()`/the `Cleaner` backstop, A's `platform_wallet_destroy` call sweeps B's token too, since the match is on wallet identity, not on which handle actually produced the token. B's later `broadcastSigned`/`releaseReservation` then wrongly fails with `StaleToken`, and because this sweep deliberately does not release the reservation, the funding UTXO stays locked until the 20-block TTL backstop. A narrower variant of the same root cause is a race between an in-flight `core_wallet_signed_payment_register` on a short-lived core handle and a concurrent `platform_wallet_destroy` on another alias — if the register lands after the sweep, that token is never covered by any future sweep, pinning the `WalletManager` alive indefinitely instead of just until TTL. Scope cleanup to the wallet actually being removed from the manager (or track live handle count and sweep only when the last alias is destroyed) rather than any single handle's teardown.

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:265-299: Coroutine cancellation can discard a minted reservation token after buildSignedPayment's native call has already committed it
  `core.registerSignedPayment(tx)` (line 295) mints a process-global registry token and starts holding the UTXO reservation, but this happens inside the dispatcher-changing `withContext(Dispatchers.IO)` wrapping the whole function (line 265). Kotlin's prompt-cancellation semantics mean a `Job` cancellation racing the dispatcher hop back to the caller can cause `withContext` to throw `CancellationException` even though the enclosed block already completed successfully and committed the native registration. In that case `buildSignedPayment` never delivers the `SignedCoreTransaction` (and its `reservationToken`) to the caller, so nothing can `broadcastSigned` or `releaseReservation` it — the registry entry sits until the `RESERVATION_MAX_AGE_BLOCKS` TTL backstop reclaims it. This is a narrow, timing-dependent window (must race cancellation against the specific dispatcher-resume point after a successful native call) and is bounded by the existing TTL guard, so it's an availability/leak concern rather than a correctness or fund-loss issue — but it is new exposure from this PR's side-effecting native call inside a cancellable coroutine scope.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Token expiry guard measures a different height clock than the reservation it bounds (prior-1, still valid)

register() records core.synced_height() as registered_height, and broadcast/release compare it against a later synced_height() read using RESERVATION_MAX_AGE_BLOCKS = 20. But the underlying ReservationSet TTL that this guard is meant to stay under is stamped by core_wallet_tx_builder_build_signed (packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs:550-557) using last_processed_height(), not synced_height(). payments.rs:96-99 documents these as intentionally decoupled clocks — synced_height is the filter-scan checkpoint and can regress during a DashPay rescan while last_processed_height remains monotonic. Whenever synced_height lags last_processed_height at registration time (or is later rolled back by a rescan), the 20-block guard can fail to trip before key-wallet's real 24-block RESERVATION_TTL_BLOCKS sweep (packages/rs-platform-wallet's pinned key-wallet dependency, managed_account/reservation.rs:32,59-61) has already reclaimed and possibly re-reserved the outpoint for an unrelated build. A broadcast or release on the stale token then acts on state it no longer owns — ReservationSet::release/reserve have no per-outpoint ownership check, so this can silently release or corrupt a newer build's reservation, the exact corruption class the guard was added to close. The new tests do not catch this because their build_signed_tx helper stamps and advances only synced_height, unlike the production FFI path. Track and pass through the exact height build_signed used (e.g. via FFICoreTransaction) instead of independently re-reading synced_height at registration.

source: ['codex-general', 'codex-security-auditor', 'codex-rust-quality', 'sonnet5-security-auditor']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — Token expiry guard measures a different height clock than the reservation it bounds (prior-1, still valid) no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines 200 to 214
pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult {
// Sweep any outstanding deferred-payment tokens bound to this wallet first,
// so the registry stops pinning its `WalletManager` (accounts, keys, sync
// state) alive for the rest of the process via the `CoreWallet` clone each
// token captured. Hooked here rather than into `core_wallet_destroy`: the
// deferred flow builds/registers on one short-lived core handle and
// broadcasts on another, so sweeping on core-handle destroy would drop
// tokens between register and broadcast.
PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| {
crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY
.remove_entries_for_wallet(wallet.core());
});
PLATFORM_WALLET_STORAGE.remove(handle);
PlatformWalletFFIResult::ok()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: platform_wallet_destroy's registry sweep is keyed on wallet identity, not handle identity — destroying one alias invalidates a sibling wrapper's deferred payment

platform_wallet_destroy now sweeps every registry entry whose Arc::ptr_eq(wallet_manager) and wallet_id match the destroyed handle's wallet (via remove_entries_for_wallet, packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:329-337). But a PlatformWallet handle is not a 1:1 proxy for the logical wallet's lifetime: platform_wallet_manager_get_wallet (packages/rs-platform-wallet-ffi/src/manager.rs:322-349) mints a fresh, independent handle on every call, cloning the same underlying wallet_manager Arc and wallet_id. The Kotlin SDK explicitly documents and relies on this — ManagedPlatformWallet.kt:38-40 notes loadPersistedWallets can hand out a new wrapper for a wallet id an earlier live wrapper still holds. If wrapper B registers a deferred payment and wrapper A (a sibling alias of the same wallet) is then closed via close()/the Cleaner backstop, A's platform_wallet_destroy call sweeps B's token too, since the match is on wallet identity, not on which handle actually produced the token. B's later broadcastSigned/releaseReservation then wrongly fails with StaleToken, and because this sweep deliberately does not release the reservation, the funding UTXO stays locked until the 20-block TTL backstop. A narrower variant of the same root cause is a race between an in-flight core_wallet_signed_payment_register on a short-lived core handle and a concurrent platform_wallet_destroy on another alias — if the register lands after the sweep, that token is never covered by any future sweep, pinning the WalletManager alive indefinitely instead of just until TTL. Scope cleanup to the wallet actually being removed from the manager (or track live handle count and sweep only when the last alias is destroyed) rather than any single handle's teardown.

source: ['codex-general', 'codex-rust-quality', 'codex-ffi-engineer', 'sonnet5-ffi-engineer', 'sonnet5-security-auditor']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — platform_wallet_destroy's registry sweep is keyed on wallet identity, not handle identity — destroying one alias invalidates a sibling wrapper's deferred payment no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +265 to +299
): SignedCoreTransaction = withContext(Dispatchers.IO) {
require(accountIndex >= 0) { "accountIndex must be non-negative, got $accountIndex" }
require(recipients.isNotEmpty()) { "recipients must not be empty" }
require(recipients.all { it.second > 0 }) {
"every recipient amount must be positive"
}
val builderAccountType = when (accountType) {
AccountType.BIP44 -> CoreTransactionBuilder.AccountType.BIP44
AccountType.BIP32 -> CoreTransactionBuilder.AccountType.BIP32
}
coreSendMutex.withLock {
mapNativeErrors {
coreWallet().use { core ->
val builder = CoreTransactionBuilder(network)
// `buildSigned` consumes the builder; `use` still safely
// destroys it on the pre-build failure paths.
val signedTx = builder.use {
for ((address, amount) in recipients) {
it.addOutput(address, amount)
}
it.setFunding(this@ManagedPlatformWallet, builderAccountType, accountIndex)
it.buildSigned(
this@ManagedPlatformWallet,
builderAccountType,
accountIndex,
coreSignerHandle,
)
}
// Register the signed tx (holding its reservation) before the
// native transaction is freed; `use` frees it afterward.
signedTx.use { tx -> core.registerSignedPayment(tx) }
}
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Coroutine cancellation can discard a minted reservation token after buildSignedPayment's native call has already committed it

core.registerSignedPayment(tx) (line 295) mints a process-global registry token and starts holding the UTXO reservation, but this happens inside the dispatcher-changing withContext(Dispatchers.IO) wrapping the whole function (line 265). Kotlin's prompt-cancellation semantics mean a Job cancellation racing the dispatcher hop back to the caller can cause withContext to throw CancellationException even though the enclosed block already completed successfully and committed the native registration. In that case buildSignedPayment never delivers the SignedCoreTransaction (and its reservationToken) to the caller, so nothing can broadcastSigned or releaseReservation it — the registry entry sits until the RESERVATION_MAX_AGE_BLOCKS TTL backstop reclaims it. This is a narrow, timing-dependent window (must race cancellation against the specific dispatcher-resume point after a successful native call) and is bounded by the existing TTL guard, so it's an availability/leak concern rather than a correctness or fund-loss issue — but it is new exposure from this PR's side-effecting native call inside a cancellable coroutine scope.

source: ['codex-general']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — Coroutine cancellation can discard a minted reservation token after buildSignedPayment's native call has already committed it no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

@bfoss765

Copy link
Copy Markdown
Author

Thanks — all findings were real. Bounded token lifetime added: register captures the wallet's synced height and broadcast/release now refuse a token older than RESERVATION_MAX_AGE_BLOCKS (20, below key-wallet's 24-block TTL) with a typed StaleReservationToken, deliberately without releasing (a post-sweep release could free a newer build's reservation). The pinned key-wallet exposes no per-outpoint generation check, so generation-aware release isn't possible without touching that crate — the bounded lifetime is the guard, documented in-code. Also: WalletMismatch now compares wallet_id; register returns the tx bytes in one native round trip (dead coreTransactionGetBytes removed); fallible marshalling reordered before the registry insert with token-release on JNI marshal failure; and a registry sweep on platform_wallet_destroy (not core_wallet_destroy — the deferred flow registers and broadcasts on different transient core handles, so sweeping on core-handle destroy would drop live tokens). Mutex now recovers from poisoning. Added expiry, same-manager wallet_id, and sweep tests. Left the ReservationToken newtype nitpick as-is (C ABI).

@thepastaclaw

Copy link
Copy Markdown
Collaborator

Thanks — this summary matches the changes already present at a428f540 and the five findings marked fixed in the current review. The current review still has two blocking findings on that same head: the guard records synced_height() while production stamps the underlying reservation with last_processed_height(), and platform_wallet_destroy sweeps tokens for sibling handles that alias the same logical wallet. The coroutine-cancellation window is also still open as a non-blocking suggestion. Please see the current-head threads linked from the a428f540 review; I’ll re-review after the next push.

@bfoss765 bfoss765 changed the title [kotlin-sdk] Split build/broadcast with reservation release for BIP70-style deferred submission feat(kotlin-sdk): split build/broadcast with reservation release for BIP70-style deferred submission Jul 16, 2026
bfoss765 and others added 3 commits July 16, 2026 15:34
…BIP70 deferred submission

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bfoss765
bfoss765 force-pushed the feat/kotlin-sdk-split-build-broadcast branch from a428f54 to bbcd88e Compare July 16, 2026 19:49

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

Carried-forward prior findings: all four priors remain valid at bbcd88e—two blocking reservation-lifecycle defects, one coroutine-cancellation cleanup gap, and one token type-safety nitpick. New findings in the latest delta: removing the shared send mutex leaves deferred builds on the explicitly non-atomic funding/signing sequence, and the stale-token mapping test still uses code 22 after the mapping moved to code 26. The PR has four confirmed blockers and is not ready to merge.

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 (failed), gpt-5.6-sol — security-auditor (failed), gpt-5.6-sol — rust-quality (failed), gpt-5.6-sol — ffi-engineer (failed), gpt-5.6-sol — general (failed), gpt-5.6-sol — security-auditor (failed), gpt-5.6-sol — rust-quality (failed), gpt-5.6-sol — ffi-engineer (failed), gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (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)

🔴 4 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:203-210: Token age uses a different height clock than the reserved inputs
  `register` records `core.synced_height()`, while the production `core_wallet_tx_builder_build_signed` path stamps the underlying key-wallet reservation using `last_processed_height`. These clocks are intentionally independent: the DashPay rescan code documents that `synced_height` can regress while `last_processed_height` remains monotonic. The registry's 20-block guard can therefore continue accepting an old token after key-wallet's 24-block reservation TTL has swept and allowed the outpoint to be reserved by a newer transaction. A later release or definitively rejected broadcast for the old token removes reservations by outpoint without an ownership generation and can free the newer transaction's reservation. Pass the exact height used during transaction finalization into the registry instead of independently reading `synced_height`.

In `packages/rs-platform-wallet-ffi/src/wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/wallet.rs:400-404: Destroying one alias handle invalidates another wrapper's payment token
  `platform_wallet_destroy` removes every registry entry matching the underlying wallet-manager pointer and wallet ID, although each `platform_wallet_manager_get_wallet` call creates an independent handle for the same logical wallet. The Kotlin `loadPersistedWallets` path can consequently publish a new wrapper while callers still retain an older wrapper. If wrapper B registers a deferred payment and wrapper A is then closed or cleaned, destroying A consumes B's live token. B's later broadcast fails as stale, and the sweep intentionally leaves the UTXO reserved until its TTL. A handle destructor should only drop that handle; wallet-wide registry cleanup must occur when the logical wallet is removed or when its final alias is destroyed.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt`:
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt:262-276: Deferred builds lost the atomic selection-and-reservation boundary
  `buildSignedPayment` still calls the deprecated `setFunding` and `buildSigned` methods as two native operations, but the latest delta removed the shared per-wallet `coreSendMutex` and replaced it with `gate.op`. `TeardownGate` only counts active operations for safe teardown; it releases its mutex before running the block and does not serialize sends. Key-wallet explicitly requires `set_funding` through `assemble_unsigned` to run under one uninterrupted wallet lock because selection observes reservations before the chosen inputs are reserved. Concurrent deferred builds can therefore select the same UTXO, and an atomic immediate send can also reserve an input after the deferred path selected it but before `buildSigned` reserves it. Both paths can return signed transactions spending the same input. Use an atomic finalize-and-register native operation for deferred payments, or restore shared per-wallet serialization around the entire funding, signing, and registration sequence.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt:274-278: Coroutine cancellation can orphan a committed reservation token
  `core.registerSignedPayment(tx)` commits the process-global registry entry inside `gate.op`, whose implementation executes the block through `withContext(Dispatchers.IO)`. Cancellation can win during the dispatcher handoff after the native call has returned but before the completed `SignedCoreTransaction` is delivered to the caller. The caller then never receives the token required to broadcast or release the reservation, while `TeardownGate`'s `NonCancellable` cleanup only decrements its active-operation counter. Track whether token ownership was delivered and release a minted token from `NonCancellable` cleanup when delivery is cancelled.

In `packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt`:
- [BLOCKING] packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt:109: The stale-token mapping test still uses native code 22
  The latest delta assigns code 22 to `ErrorCoreInsufficientFunds` and moves `ErrorStaleReservationToken` to code 26. The production Kotlin mapping correctly reflects those assignments, but this test constructs code 22 and asserts that it produces `StaleReservationToken`. It deterministically receives `CoreInsufficientFunds`, so the test fails and no longer verifies the code-26 mapping.

Comment on lines +203 to +210
pub async fn register(
&self,
core: CoreWallet<B>,
tx: Transaction,
account_type: Option<StandardAccountType>,
account_index: u32,
) -> ReservationToken {
let registered_height = core.synced_height().await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Token age uses a different height clock than the reserved inputs

register records core.synced_height(), while the production core_wallet_tx_builder_build_signed path stamps the underlying key-wallet reservation using last_processed_height. These clocks are intentionally independent: the DashPay rescan code documents that synced_height can regress while last_processed_height remains monotonic. The registry's 20-block guard can therefore continue accepting an old token after key-wallet's 24-block reservation TTL has swept and allowed the outpoint to be reserved by a newer transaction. A later release or definitively rejected broadcast for the old token removes reservations by outpoint without an ownership generation and can free the newer transaction's reservation. Pass the exact height used during transaction finalization into the registry instead of independently reading synced_height.

source: ['codex-general', 'codex-security-auditor', 'codex-rust-quality', 'codex-ffi-engineer']

Comment on lines 400 to 404
PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| {
crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY
.remove_entries_for_wallet(wallet.core());
});
PLATFORM_WALLET_STORAGE.remove(handle);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Destroying one alias handle invalidates another wrapper's payment token

platform_wallet_destroy removes every registry entry matching the underlying wallet-manager pointer and wallet ID, although each platform_wallet_manager_get_wallet call creates an independent handle for the same logical wallet. The Kotlin loadPersistedWallets path can consequently publish a new wrapper while callers still retain an older wrapper. If wrapper B registers a deferred payment and wrapper A is then closed or cleaned, destroying A consumes B's live token. B's later broadcast fails as stale, and the sweep intentionally leaves the UTXO reserved until its TTL. A handle destructor should only drop that handle; wallet-wide registry cleanup must occur when the logical wallet is removed or when its final alias is destroyed.

source: ['codex-general', 'codex-security-auditor', 'codex-rust-quality', 'codex-ffi-engineer']

Comment on lines +262 to +276
val signedTx = builder.use {
for ((address, amount) in recipients) {
it.addOutput(address, amount)
}
it.setFunding(this@ManagedPlatformWallet, builderAccountType, accountIndex)
it.buildSigned(
this@ManagedPlatformWallet,
builderAccountType,
accountIndex,
coreSignerHandle,
)
}
// Register the signed tx (holding its reservation) before the
// native transaction is freed; `use` frees it afterward.
signedTx.use { tx -> core.registerSignedPayment(tx) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Deferred builds lost the atomic selection-and-reservation boundary

buildSignedPayment still calls the deprecated setFunding and buildSigned methods as two native operations, but the latest delta removed the shared per-wallet coreSendMutex and replaced it with gate.op. TeardownGate only counts active operations for safe teardown; it releases its mutex before running the block and does not serialize sends. Key-wallet explicitly requires set_funding through assemble_unsigned to run under one uninterrupted wallet lock because selection observes reservations before the chosen inputs are reserved. Concurrent deferred builds can therefore select the same UTXO, and an atomic immediate send can also reserve an input after the deferred path selected it but before buildSigned reserves it. Both paths can return signed transactions spending the same input. Use an atomic finalize-and-register native operation for deferred payments, or restore shared per-wallet serialization around the entire funding, signing, and registration sequence.

source: ['codex-general', 'codex-security-auditor', 'codex-rust-quality', 'codex-ffi-engineer']


// Deferred build/broadcast: a stale/consumed/wrong-wallet reservation
// token → typed StaleReservationToken, not retryable.
val staleToken = DashSdkError.fromNative(DashSDKException(offset + 22, "stale token 7"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: The stale-token mapping test still uses native code 22

The latest delta assigns code 22 to ErrorCoreInsufficientFunds and moves ErrorStaleReservationToken to code 26. The production Kotlin mapping correctly reflects those assignments, but this test constructs code 22 and asserts that it produces StaleReservationToken. It deterministically receives CoreInsufficientFunds, so the test fails and no longer verifies the code-26 mapping.

Suggested change
val staleToken = DashSdkError.fromNative(DashSDKException(offset + 22, "stale token 7"))
val staleToken = DashSdkError.fromNative(DashSDKException(offset + 26, "stale token 7"))

source: ['codex-general']

Comment on lines +274 to +278
// Register the signed tx (holding its reservation) before the
// native transaction is freed; `use` frees it afterward.
signedTx.use { tx -> core.registerSignedPayment(tx) }
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Coroutine cancellation can orphan a committed reservation token

core.registerSignedPayment(tx) commits the process-global registry entry inside gate.op, whose implementation executes the block through withContext(Dispatchers.IO). Cancellation can win during the dispatcher handoff after the native call has returned but before the completed SignedCoreTransaction is delivered to the caller. The caller then never receives the token required to broadcast or release the reservation, while TeardownGate's NonCancellable cleanup only decrements its active-operation counter. Track whether token ownership was delivered and release a minted token from NonCancellable cleanup when delivery is cancelled.

source: ['codex-general', 'codex-security-auditor', 'codex-rust-quality', 'codex-ffi-engineer']

Comment on lines +63 to +68
/// Opaque handle to a registered, signed-but-unsent payment. Minted by
/// [`SignedPaymentRegistry::register`]; consumed by
/// [`SignedPaymentRegistry::broadcast`] or
/// [`SignedPaymentRegistry::release`]. Values are unique for the process
/// lifetime and never reused, so a stale token can always be recognised.
pub type ReservationToken = u64;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: The opaque reservation capability remains an untyped u64 alias

ReservationToken has lifecycle and provenance semantics distinct from other numeric identifiers, but a type alias provides no Rust-side domain separation. Rust callers can accidentally interchange unrelated u64 values with reservation tokens without a type error. A #[repr(transparent)] newtype would preserve the C-compatible representation while enforcing the distinction within Rust.

source: ['codex-general', 'codex-security-auditor', 'codex-rust-quality']

bfoss765 and others added 4 commits July 17, 2026 08:55
…n mapping

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

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

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

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

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

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

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

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

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

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

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

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

Carried-forward prior findings: prior-5 remains a cancellation-safety suggestion and prior-6 remains a token-type nitpick, while prior-1 through prior-4 are fixed as originally reported. New findings in the latest delta: three blocking root causes remain—the token lifetime starts after signing instead of at reservation, registration can mint duplicate or misattributed capabilities, and wallet cleanup and identity are tied to transient wrapper aliases instead of the logical wallet generation—so the PR is not ready to merge.

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 — security-auditor (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

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

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

In `packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/signed_payment_registry.rs:207-225: Registration loses the reservation's exact height
  `finalize_transaction` stamps the selected inputs using `last_processed_height` before releasing the wallet lock and awaiting the external signer, but `register` independently reads the height after signing finishes. If signing spans at least five blocks, the registry's 20-block guard can still accept the token after key-wallet's 24-block TTL has swept and re-reserved its outpoint. A later release or definitive broadcast rejection can then remove the newer reservation because release operates unconditionally by outpoint. Store the height used by `set_current_height` in `SignedCoreTransaction` and transfer that exact value into the registry.

In `packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/core_wallet/signed_payment.rs:81-115: Registration does not preserve unique reservation ownership
  `FFICoreTransaction` contains only serialized bytes and a fee, while this function independently accepts the wallet and funding account and does not consume the transaction. A valid transaction can therefore be registered repeatedly or registered against an unrelated wallet/account. Duplicate tokens defeat reservation-level single-use semantics: one token can release the outpoint, a new payment can reserve it, and another old token can later release that newer reservation. Registering wallet A's transaction against wallet B instead reconciles B and leaves A's actual reservation held until TTL. Consume a single opaque finalized-payment handle containing its originating wallet, account, reservation height, and transaction, or remove the ownership-less legacy registration path.

In `packages/rs-platform-wallet-ffi/src/wallet.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/wallet.rs:392-423: Token lifecycle is tied to transient wallet aliases
  `platform_wallet_destroy` deletes every token when no sibling wrapper is currently visible, even though the manager still owns the logical wallet and can immediately return another handle for it. Closing the last current wrapper therefore makes a retained payment token stale while intentionally leaving its UTXO reserved until TTL. The `remove`, sibling scan, alias insertion, and registry sweep also use separate locks, so a concurrent lookup or registration can race the sweep. Conversely, an alias retained across logical wallet removal and recreation still matches `(WalletManager Arc, wallet_id)`, allowing an old token to operate on the replacement generation. Perform cleanup on logical wallet removal or manager shutdown and coordinate it with a stable per-wallet generation or atomic lifecycle reference count.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Registration loses the reservation's exact height

finalize_transaction stamps the selected inputs using last_processed_height before releasing the wallet lock and awaiting the external signer, but register independently reads the height after signing finishes. If signing spans at least five blocks, the registry's 20-block guard can still accept the token after key-wallet's 24-block TTL has swept and re-reserved its outpoint. A later release or definitive broadcast rejection can then remove the newer reservation because release operates unconditionally by outpoint. Store the height used by set_current_height in SignedCoreTransaction and transfer that exact value into the registry.

source: ['codex']

Comment on lines +81 to +115
let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_handle, |w| w.clone()));

let bytes = (*tx).bytes();
let transaction: dashcore::Transaction = match dashcore::consensus::deserialize(bytes) {
Ok(t) => t,
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorDeserialization,
format!("failed to deserialize signed transaction: {e}"),
);
}
};
let txid = transaction.txid();
let fee = (*tx).fee();

// Do all fallible/pure marshalling BEFORE the registry insert — that insert
// mints a token and holds the funding reservation, so a later failure would
// orphan the reservation with no token to release it. txid hex never
// contains a NUL, but handle the impossible case anyway.
let c_txid = match CString::new(txid.to_string()) {
Ok(s) => s,
Err(_) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorUtf8Conversion,
"txid string contained an interior NUL".to_string(),
);
}
};

let token = runtime().block_on(SIGNED_PAYMENT_REGISTRY.register(
core,
transaction,
account_type.as_standard_account_type(),
account_index,
));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Registration does not preserve unique reservation ownership

FFICoreTransaction contains only serialized bytes and a fee, while this function independently accepts the wallet and funding account and does not consume the transaction. A valid transaction can therefore be registered repeatedly or registered against an unrelated wallet/account. Duplicate tokens defeat reservation-level single-use semantics: one token can release the outpoint, a new payment can reserve it, and another old token can later release that newer reservation. Registering wallet A's transaction against wallet B instead reconciles B and leaves A's actual reservation held until TTL. Consume a single opaque finalized-payment handle containing its originating wallet, account, reservation height, and transaction, or remove the ownership-less legacy registration path.

source: ['codex']

Comment on lines 392 to +423
pub unsafe extern "C" fn platform_wallet_destroy(handle: Handle) -> PlatformWalletFFIResult {
PLATFORM_WALLET_STORAGE.remove(handle);
// Remove this handle first so it is excluded from the final-alias scan
// below (and so a concurrent lookup can no longer resolve it).
let Some(wallet) = PLATFORM_WALLET_STORAGE.remove(handle) else {
return PlatformWalletFFIResult::ok();
};

// `platform_wallet_manager_get_wallet` hands out an independent handle for
// each alias of the same logical wallet (they share the underlying
// `WalletManager` `Arc` and `wallet_id`). A deferred-payment token minted
// through one alias must NOT be invalidated when a *sibling* alias is
// destroyed — the token is still live and broadcastable through the survivor.
//
// So only sweep the registry when THIS is the final live alias: no other
// stored handle shares the same (`WalletManager` pointer + `wallet_id`) —
// exactly the key `remove_entries_for_wallet` matches on. When a sibling is
// still live, the destructor just drops this handle, leaving its tokens
// (and the shared `WalletManager` pin) in place. Once the last alias goes,
// the sweep runs, releasing the registry's pin on the wallet's
// `WalletManager` (accounts, keys, sync state) that each token's captured
// `CoreWallet` clone would otherwise keep alive for the process lifetime.
let core = wallet.core();
let wallet_id = core.wallet_id();
let manager = wallet.wallet_manager();
let sibling_alias_alive = PLATFORM_WALLET_STORAGE.any(|other| {
other.wallet_id() == wallet_id
&& std::sync::Arc::ptr_eq(other.wallet_manager(), manager)
});
if !sibling_alias_alive {
crate::core_wallet::signed_payment::SIGNED_PAYMENT_REGISTRY
.remove_entries_for_wallet(core);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Token lifecycle is tied to transient wallet aliases

platform_wallet_destroy deletes every token when no sibling wrapper is currently visible, even though the manager still owns the logical wallet and can immediately return another handle for it. Closing the last current wrapper therefore makes a retained payment token stale while intentionally leaving its UTXO reserved until TTL. The remove, sibling scan, alias insertion, and registry sweep also use separate locks, so a concurrent lookup or registration can race the sweep. Conversely, an alias retained across logical wallet removal and recreation still matches (WalletManager Arc, wallet_id), allowing an old token to operate on the replacement generation. Perform cleanup on logical wallet removal or manager shutdown and coordinate it with a stable per-wallet generation or atomic lifecycle reference count.

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants