Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
61f871e
fix(platform-wallet): age-guard the finalized-transaction handle broa…
bfoss765 Aug 6, 2026
0f2cb03
docs(platform-wallet): de-link the pub(crate) reservation bound from …
bfoss765 Aug 10, 2026
ffc05fc
fix(kotlin-sdk): map the native stale-broadcast error on the public b…
bfoss765 Aug 10, 2026
e4e6784
fix(platform-wallet): validate reservation age atomically with dispatch
bfoss765 Aug 10, 2026
9b033cb
fix(platform-wallet): drop the manager guard before the broadcast await
bfoss765 Aug 11, 2026
80c54b4
fix(platform-wallet): pin in-broadcast inputs across the dispatch await
bfoss765 Aug 12, 2026
831150c
Merge branch 'v4.2-dev' into followup/v4.1/v2-handle-age-guard
bfoss765 Aug 12, 2026
660cfad
Merge remote-tracking branch 'origin/v4.2-dev' into wt-hyg-4309
bfoss765 Aug 13, 2026
2b911bc
fix(platform-wallet): fence dispatched inputs past the broadcaster re…
bfoss765 Aug 13, 2026
6cb9cf7
refactor(platform-wallet): anchor the broadcast fence on the checked …
bfoss765 Aug 13, 2026
89586f7
fix(platform-wallet): fence broadcast inputs by default, release only…
bfoss765 Aug 14, 2026
58efacf
fix(platform-wallet): anchor the broadcast fence after the await, not…
bfoss765 Aug 19, 2026
f1250e0
docs(platform-wallet-ffi,swift-sdk): document the terminal stale-toke…
bfoss765 Aug 19, 2026
7dde1c1
fix(platform-wallet): settle the in-broadcast pin under the manager g…
bfoss765 Aug 19, 2026
adb65b4
fix(platform-wallet): fence broadcast inputs until the spend is observed
bfoss765 Aug 20, 2026
520e5d9
test(platform-wallet): fire the settle-boundary hook inside the torn …
bfoss765 Aug 20, 2026
67017f5
Merge origin/v4.2-dev into followup/v4.1/v2-handle-age-guard
bfoss765 Aug 23, 2026
efe7c73
test(wallet): drive the production SpendObservationHandler in the fen…
bfoss765 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,23 @@ sealed class DashSdkError(
PlatformWallet(message, cause)

/**
* `ErrorStaleReservationToken` (native code 34). A deferred
* (BIP70/BIP270) [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned]
* token has outlived its funding reservation's lifetime: key-wallet's
* TTL may already have swept and re-selected the inputs, so acting on it
* could touch a newer, unrelated reservation. The call did NOT touch the
* network. NOT retryable in place — rebuild the payment with
* [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment].
* `ErrorStaleReservationToken` (native code 34). A payment's funding
* reservation has outlived its lifetime: key-wallet's TTL may already
* have swept and re-selected the inputs, so sending it could spend
* against a newer, unrelated reservation. The call did NOT touch the
* network, and it released the still-owned reservation on the way out
* (owner-guarded — a no-op if ownership had already transferred). NOT
* retryable in place — rebuild the payment, which can reselect the
* freed inputs immediately.
*
* The code is shared by BOTH deferred-payment surfaces (the messages
* distinguish them): a deferred (BIP70/BIP270)
* [broadcastSigned][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.broadcastSigned]
* token, rebuilt with
* [buildSignedPayment][org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet.buildSignedPayment];
* and a finalized handle whose
* [broadcastTransaction][org.dashfoundation.dashsdk.wallet.ManagedCoreWallet.broadcastTransaction]
* aged past the same reservation bound (abandon still works at any age).
*
* Sibling of the other two deferred-token failures this code used to
* conflate: [ReservationTokenConsumed] (unknown / already broadcast /
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,46 @@ class ManagedCoreWallet internal constructor(handle: Long) : AutoCloseable {
check(it != 0L) { "ManagedCoreWallet has been closed" }
}

/** Consume and broadcast a finalized transaction. */
fun broadcastTransaction(tx: FinalizedCoreTransaction): String =
/**
* Consume and broadcast a finalized transaction. A handle held past the
* reservation age bound throws the typed
* [StaleReservationToken][org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.StaleReservationToken]
* (native code 34, shared with the deferred-token surface) instead of
* broadcasting against inputs key-wallet's TTL may have re-selected.
*
* On that refusal the handle has **already been consumed** by this call and
* its funding reservation released owner-guarded (freed only while this
* build still owned it; a no-op once a TTL sweep or re-reservation
* transferred ownership). This call consumes the Kotlin-side handle up
* front (on EVERY outcome, success included), so a follow-up
* [abandonTransaction] fails locally with [IllegalStateException] because
* [FinalizedCoreTransaction] has already been consumed; it never re-enters
* native code and is not a recovery path — there is nothing left to
* release. Recover by rebuilding the transaction, which can reselect the
* freed inputs immediately.
Comment thread
bfoss765 marked this conversation as resolved.
*/
fun broadcastTransaction(tx: FinalizedCoreTransaction): String = mapNativeErrors {
WalletManagerNative.coreWalletBroadcastSignedTransaction(
handle,
tx.takeForBroadcast(),
)
}

/** Consume without sending and release the selected inputs immediately. */
/**
* Consume a finalized transaction without sending. With the build's owner
* token present (the normal funded-finalize case) the release is
* owner-guarded and safe at any age: it frees the selected inputs while
* this build still owns them — so a rebuild can reselect them immediately —
* and no-ops once key-wallet's TTL sweep or a re-reservation transferred
* ownership. Only a token-less handle honours the reservation age bound and
* skips its unguarded by-outpoint release past it (releasing by outpoint
* could free a newer build's reservation), leaving the aged reservation for
* the TTL to reclaim. The handle is torn down either way.
*
* Consumes the Kotlin-side handle: calling this (or [broadcastTransaction])
* on an already-consumed [FinalizedCoreTransaction] fails locally with
* [IllegalStateException] before any native code runs.
*/
fun abandonTransaction(tx: FinalizedCoreTransaction) {
WalletManagerNative.coreWalletAbandonSignedTransaction(
handle,
Expand Down
134 changes: 134 additions & 0 deletions packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ fn classify_broadcast_result(
/// (removed, or re-created under the same id) is refused with `NotFound` (98)
/// **before** the network is touched; the handle is consumed and its reservation
/// reconciled. This mirrors the deferred-token path's `WalletRemoved` → 98.
///
/// # `ErrorStaleReservationToken` (34) is TERMINAL
///
/// A handle held past `RESERVATION_MAX_AGE_BLOCKS` — the wallet's
/// `last_processed_height` advanced that far beyond the funding reservation's
/// stamp — is refused with `ErrorStaleReservationToken` (34) and no txid, again
/// **before** the network is touched. Nothing was sent.
///
/// There is no retry and no abandon from that outcome: the handle was already
/// consumed at the top of this call, so a second
/// `core_wallet_broadcast_signed_transaction` with it returns `NotFound` (98)
/// rather than resending, and `core_wallet_abandon_signed_transaction` likewise
/// finds nothing to free. The refusal path performs the reconciliation itself —
/// it releases the funding reservation owner-guarded, so the inputs are free
/// while this build still owned them and untouched once a TTL sweep or
/// re-reservation transferred ownership.
///
/// **The caller must REBUILD the transaction.** That is the whole recovery: the
/// released inputs are immediately reselectable by a fresh
/// `core_wallet_tx_builder_*` → `finalize` sequence, and no cleanup call is
/// needed (or possible) in between. See
/// `aged_broadcast_refuses_and_releases_for_rebuild`.
#[no_mangle]
pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction(
handle: Handle,
Expand Down Expand Up @@ -288,6 +310,26 @@ mod tests {
runtime().block_on(core.abandon_transaction(&retry));
}

/// Prove the funding reservation was released owner-guarded: a fresh
/// finalize of the same size reselects the single fixture UTXO. An aged
/// abandon/free with the build's owner token present releases via
/// `release_reservation_if_owner` (safe at any age — no-op once ownership
/// transferred), so the input must be immediately reselectable.
fn assert_released_for_rebuild(core: &TestCore, signer: &WalletSigner, tag: u8) {
let rebuild = runtime().block_on(core.finalize_transaction(
TransactionBuilder::new().add_output(
&Address::dummy(Network::Testnet, usize::from(tag)),
1_000_000,
),
&[AccountTypePreference::BIP44],
0,
signer,
));
let rebuilt = rebuild
.expect("aged abandon/free must release the still-owned reservation for a rebuild");
runtime().block_on(core.abandon_transaction(&rebuilt));
}

#[test]
fn double_free_is_safe_and_releases_reservation() {
let (core, signer) =
Expand Down Expand Up @@ -327,6 +369,98 @@ mod tests {
CORE_WALLET_STORAGE.remove(other_handle);
}

/// The deinit/GC backstop (`core_wallet_signed_transaction_free`) is the
/// exact path shumkov flagged: a `FinalizedCoreTransaction` never broadcast
/// or abandoned, freed by the host GC long after finalize. The funded
/// finalize stamped an owner token, so the aged free still releases —
/// owner-guarded via `release_reservation_if_owner`, which is safe at any
/// age (it no-ops once key-wallet's TTL swept and an unrelated build
/// re-reserved the outpoint) — freeing the still-owned input for a rebuild.
/// The handle is torn down (the storage entry is removed) so a re-free is a
/// safe no-op.
#[test]
fn aged_free_releases_owner_guarded() {
let (core, signer) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let transaction_handle = insert(&core, finalize(&core, &signer, 48));

// Age the pinned handle past the guard bound (still below the TTL, so the
// reservation is provably still held — only the software guard trips).
runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&core));

core_wallet_signed_transaction_free(transaction_handle);

// The aged free released owner-guarded: the input is reselectable.
assert_released_for_rebuild(&core, &signer, 49);
// Handle is gone regardless — a re-free is a harmless no-op.
core_wallet_signed_transaction_free(transaction_handle);
}

/// The FFI broadcast/abandon *failure* paths (invalid or wrong-generation
/// wallet handle) route their cleanup through `abandon_transaction`, so they
/// inherit the same policy: an aged handle with the build's owner token
/// still releases owner-guarded (safe at any age), so the failure-path
/// cleanup frees the still-owned input instead of stranding it.
#[test]
fn aged_failure_path_abandon_releases_owner_guarded() {
let (origin, signer) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let transaction_handle = insert(&origin, finalize(&origin, &signer, 50));

runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&origin));

// Invalid wallet handle → routes through abandon_transaction, then returns
// ErrorInvalidHandle. The embedded aged reservation is released
// owner-guarded on the way out.
let invalid =
unsafe { core_wallet_abandon_signed_transaction(u64::MAX, transaction_handle) };
assert_eq!(
invalid.code,
PlatformWalletFFIResultCode::ErrorInvalidHandle
);
assert_released_for_rebuild(&origin, &signer, 51);
}

/// The terminal FFI stale-broadcast behavior: by the time the age guard
/// runs, `core_wallet_broadcast_signed_transaction` has already consumed
/// the opaque handle (and the host bindings cleared theirs before entering
/// the ABI), so no follow-up abandon is possible. The refusal must
/// therefore reconcile the reservation itself — owner-guarded, freeing the
/// still-owned input so the instructed immediate rebuild can reselect it —
/// and surface the shared `ErrorStaleReservationToken` (34) code with no
/// txid. A retry of the consumed handle is `NotFound`, not a resend.
#[test]
fn aged_broadcast_refuses_and_releases_for_rebuild() {
let (core, signer) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let core_handle = CORE_WALLET_STORAGE.insert(core.clone());
let transaction_handle = insert(&core, finalize(&core, &signer, 52));

runtime().block_on(platform_wallet::test_support::age_core_past_reservation_guard(&core));

let mut txid = ptr::null_mut();
let stale = unsafe {
core_wallet_broadcast_signed_transaction(core_handle, transaction_handle, &mut txid)
};
assert_eq!(
stale.code,
PlatformWalletFFIResultCode::ErrorStaleReservationToken
);
assert!(txid.is_null());

// The refusal released owner-guarded: the input is reselectable with no
// further cleanup call.
assert_released_for_rebuild(&core, &signer, 53);

// The handle was consumed by the refused broadcast — a retry cannot
// reconsume it.
let retry = unsafe {
core_wallet_broadcast_signed_transaction(core_handle, transaction_handle, &mut txid)
};
assert_eq!(retry.code, PlatformWalletFFIResultCode::NotFound);
CORE_WALLET_STORAGE.remove(core_handle);
}

#[test]
fn abandon_then_free_or_broadcast_cannot_reconsume_handle() {
let (core, signer) =
Expand Down
72 changes: 72 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,23 @@ pub enum PlatformWalletFFIResultCode {
/// [`Self::ErrorReservationWalletMismatch`] (36, minted against a different
/// wallet generation). All three are non-retryable-in-place and none touched
/// the network; they are distinct codes so a host can message each precisely.
///
/// Also maps `PlatformWalletError::StaleReservation` from the atomic
/// finalized-transaction handle path
/// (`core_wallet_broadcast_signed_transaction`): a pinned handle whose
/// funding reservation aged past the SAME `RESERVATION_MAX_AGE_BLOCKS` bound
/// carries the identical "may already have been swept — rebuild" meaning, so
/// the two surfaces intentionally share this one code. The handle carries
/// no numeric reservation token, hence a distinct (token-less) wallet-error
/// variant behind the same FFI code. The refusal reconciles the reservation
/// on the way out: a funded finalize always stamps an owner token, so the
/// release is owner-guarded (safe at any age — a no-op once ownership
/// transferred) and the still-owned inputs are freed for the instructed
/// rebuild. Abandon/free of a handle never surfaces this — abandon returns
/// no result code and likewise releases owner-guarded at any age; only a
/// token-less build skips its unguarded by-outpoint release past the bound
/// (leaving the aged outpoint to key-wallet's TTL, since releasing it
/// unguarded could free an unrelated newer build's reservation).
ErrorStaleReservationToken = 34,

/// Maps `SignedPaymentError::StaleToken`. The deferred reservation token is
Expand Down Expand Up @@ -611,6 +628,33 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
PlatformWalletError::TransactionBroadcast(..) => {
PlatformWalletFFIResultCode::ErrorTransactionBroadcastRejected
}
// The finalized-transaction handle path's age guard. Shares the
// `ErrorStaleReservationToken` code with the deferred registry-token
// sibling (`SignedPaymentError::StaleReservationToken`): both mean
// "the funding reservation may already have been swept — rebuild",
// and neither touched the network. See the code's doc note.
PlatformWalletError::StaleReservation => {
PlatformWalletFFIResultCode::ErrorStaleReservationToken
}
// A coin selection that picked an input still held by an in-flight
// broadcast dispatch. Typed on the Rust side (it carries the
// conflicting `OutPoint`, and is the one build refusal that is
// safely retryable unchanged), but DELIBERATELY mapped to the same
// numeric code it produced before that variant existed: all three
// choke points previously returned it as
// `TransactionBuild` / `AssetLockTransaction`, neither of which is
// matched here, so both fell to `ErrorUnknown`.
//
// Minting a dedicated code is a separate, coordinated change — the
// numeric space is a cross-PR registry (see the claim table on
// `ErrorStaleReservationToken` above) and every new value has to be
// mirrored into the Swift and Kotlin result enums. This arm exists
// so the mapping is an explicit, reviewable decision in one place
// rather than an accident of the catch-all, and so it is a one-line
// change when a code is claimed (`dashpay/platform#4309`).
PlatformWalletError::InputMidBroadcast { .. } => {
PlatformWalletFFIResultCode::ErrorUnknown
Comment thread
bfoss765 marked this conversation as resolved.
}
// A definitively-failed address-nonce race (reaches the blanket impl
// via identity `top_up_from_addresses` → `?`/`.into()`). Exposing
// provided/expected nonce as structured out-fields is INTENTIONALLY
Expand Down Expand Up @@ -1258,6 +1302,34 @@ mod tests {
assert_eq!(msg, rendered, "Display payload must survive verbatim");
}

/// The finalized-transaction handle age guard
/// (`core_wallet_broadcast_signed_transaction` → `broadcast_finalized_transaction`)
/// surfaces `PlatformWalletError::StaleReservation` through the blanket
/// `From` impl, which must reuse the deferred registry-token path's
/// `ErrorStaleReservationToken` (34) code rather than flattening to
/// `ErrorUnknown` — the two surfaces share the "reservation may have been
/// swept; rebuild" meaning and this one code. The typed Display rendering
/// survives across the boundary as the message.
#[test]
fn stale_reservation_maps_to_shared_stale_reservation_code() {
let err = PlatformWalletError::StaleReservation;
let rendered = err.to_string();
let result: PlatformWalletFFIResult = err.into();
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorStaleReservationToken,
"StaleReservation must reuse the registry-token stale code (rendered: {rendered})"
);
assert!(!result.message.is_null());
let msg = unsafe { std::ffi::CStr::from_ptr(result.message) }
.to_string_lossy()
.into_owned();
assert_eq!(
msg, rendered,
"Display payload must survive the FFI boundary verbatim"
);
}

/// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch`
/// FFI code through the blanket `From` impl (the path identity
/// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening
Expand Down
Loading
Loading