Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::error::*;
use crate::handle::*;
use crate::runtime::runtime;
use crate::{check_ptr, unwrap_option_or_return};
use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return};

/// C-compatible tracked asset lock entry.
#[repr(C)]
Expand Down Expand Up @@ -54,7 +54,7 @@ pub unsafe extern "C" fn asset_lock_manager_list_tracked_locks(
let option = ASSET_LOCK_MANAGER_STORAGE.with_item(handle, |manager| {
use platform_wallet::AssetLockStatus;

let locks = runtime().block_on(manager.list_tracked_locks());
let locks = runtime().try_block_on(manager.list_tracked_locks())?;
let entries: Vec<TrackedAssetLockFFI> = locks
.iter()
.map(|lock| {
Expand Down Expand Up @@ -86,9 +86,9 @@ pub unsafe extern "C" fn asset_lock_manager_list_tracked_locks(
}
})
.collect();
entries
Ok::<_, crate::panic_guard::FfiBoundaryError>(entries)
});
let entries = unwrap_option_or_return!(option);
let entries = unwrap_result_or_return!(unwrap_option_or_return!(option));

*out_count = entries.len();
if entries.is_empty() {
Expand Down
3 changes: 3 additions & 0 deletions packages/rs-platform-wallet-ffi/src/asset_lock/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ pub unsafe extern "C" fn asset_lock_manager_catch_up_blocking(
);
}
};
// Peel the FFI-local outer failure off first: the arm below adds context
// around the error, which would push a caught panic's marker off position 0.
let result = unwrap_result_or_return!(crate::panic_guard::peel_boundary(result));
match result {
Ok(_) => {
tracing::info!(
Expand Down
217 changes: 192 additions & 25 deletions packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
//! FFI bindings for CoreWallet transaction broadcasting.

use crate::core_wallet::lifecycle::lifecycle_gate_or_release;
use crate::error::*;
use crate::handle::*;
use crate::panic_guard::GuardedError;
use crate::runtime::runtime;
use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return};
use platform_wallet::PlatformWalletError;
use std::os::raw::c_char;

fn classify_broadcast_result(
result: Result<dashcore::Txid, PlatformWalletError>,
result: Result<dashcore::Txid, GuardedError<PlatformWalletError>>,
local_txid: dashcore::Txid,
) -> (Option<dashcore::Txid>, PlatformWalletFFIResult) {
match result {
Ok(_) => (Some(local_txid), PlatformWalletFFIResult::ok()),
Err(error @ PlatformWalletError::TransactionBroadcast(_))
| Err(error @ PlatformWalletError::TransactionBroadcastUnconfirmed(_)) => {
(Some(local_txid), error.into())
}
Err(error) => (None, error.into()),
// A boundary failure (caught panic / no runtime) carries NO outcome
// guarantee, so it gets the same shape as any other unclassified
// failure: the generic code, its own message verbatim, and no txid —
// it must not borrow the `Some(local_txid)` treatment that says
// "this reached the network, reconcile by this id".
Err(GuardedError::Boundary(error)) => (None, error.into()),
Comment on lines +18 to +23

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: Return the local txid for an unknown panic outcome

A caught panic can occur after broadcast_finalized_transaction has submitted the transaction but before it returns. This arm nevertheless discards the already-computed deterministic local_txid, after the finalized handle has been consumed, even though the panic contract tells the host to treat the result as an unknown outcome and reconcile against chain state. Supplying the txid does not claim network acceptance—the definitive rejection path already supplies it despite proving that the send failed. Preserve the txid for panic-marked boundary failures and update the Swift and JNI wrappers to expose a code-6, panic-marked result as an unknown outcome carrying that txid; JNI currently throws before reading out_txid, while Swift only constructs an outcome for codes 0, 20, and 26.

source: ['codex']

Err(GuardedError::Domain(error @ PlatformWalletError::TransactionBroadcast(_)))
| Err(GuardedError::Domain(
error @ PlatformWalletError::TransactionBroadcastUnconfirmed(_),
)) => (Some(local_txid), error.into()),
Err(GuardedError::Domain(error)) => (None, error.into()),
}
}

Expand Down Expand Up @@ -76,11 +84,11 @@ pub unsafe extern "C" fn core_wallet_broadcast_signed_transaction(
// exclusive side, so it cannot interleave between the check and the send.
// Scoped per generation, so this send — up to the broadcaster's timeout —
// blocks only THIS wallet's teardown, never an unrelated wallet's.
let (_lifecycle, wallet_is_live) = runtime().block_on(async {
let gate = wallet.generation_payment_guard().await;
let live = wallet.is_current_generation().await;
(gate, live)
});
let (_lifecycle, wallet_is_live) = unwrap_result_or_return!(lifecycle_gate_or_release(
&wallet,
&finalized.wallet,
&finalized.transaction
));
if !wallet_is_live {
runtime().block_on(finalized.wallet.abandon_transaction(&finalized.transaction));
return PlatformWalletFFIResult::err(
Expand Down Expand Up @@ -138,11 +146,17 @@ pub unsafe extern "C" fn core_wallet_abandon_signed_transaction(
"transaction was finalized by a different wallet generation".to_string(),
);
}
runtime().block_on(
// `try_block_on`: releasing the reservation IS this entry point's job, and
// the handle was consumed on entry so there is no retry — a panic must not
// fall through to a success the host records as "reservation released"
// (same contract as `core_wallet_signed_payment_release`). The two abandon
// calls in the error arms above stay on the swallowing `block_on`: they are
// best-effort cleanup on paths that already report an error of their own.
unwrap_result_or_return!(runtime().try_block_on(
transaction
.wallet
.abandon_transaction(&transaction.transaction),
);
));
PlatformWalletFFIResult::ok()
Comment on lines +149 to 160

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Keep the reservation recoverable when abandon panics

The transaction is removed from CORE_SIGNED_TRANSACTION_STORAGE before this guarded release runs. If try_block_on returns a boundary failure—including the nested-runtime failure exercised by the test—the only SignedCoreTransaction ownership object is dropped, even though the release may never have been polled. SignedCoreTransaction has no Drop cleanup, so the inputs remain reserved until sync or the reservation TTL, and retrying with the original handle returns NotFound. Retain or restore a retryable ownership token on this error path, or complete compensation through the originating wallet before permanently consuming the handle. The regression test should verify that the reservation remains recoverable rather than pinning NotFound after a release that did not run.

source: ['codex']

}

Expand Down Expand Up @@ -204,14 +218,21 @@ mod outcome_tests {
dashcore::Txid::from_byte_array([byte; 32])
}

/// A domain error in the guarded shape the classifier now takes.
fn domain(
error: PlatformWalletError,
) -> Result<dashcore::Txid, GuardedError<PlatformWalletError>> {
Err(GuardedError::Domain(error))
}

#[test]
fn network_outcomes_all_carry_a_txid() {
let accepted = classify_broadcast_result(Ok(txid(1)), txid(9));
assert_eq!(accepted.0, Some(txid(9)));
assert_eq!(accepted.1.code, PlatformWalletFFIResultCode::Success);

let rejected = classify_broadcast_result(
Err(PlatformWalletError::TransactionBroadcast(
domain(PlatformWalletError::TransactionBroadcast(
"rejected".to_string(),
)),
txid(2),
Expand All @@ -223,7 +244,7 @@ mod outcome_tests {
);

let unknown = classify_broadcast_result(
Err(PlatformWalletError::TransactionBroadcastUnconfirmed(
domain(PlatformWalletError::TransactionBroadcastUnconfirmed(
"timeout".to_string(),
)),
txid(3),
Expand All @@ -238,7 +259,7 @@ mod outcome_tests {
#[test]
fn operational_error_does_not_carry_a_txid() {
let outcome = classify_broadcast_result(
Err(PlatformWalletError::TransactionBuild("invalid".to_string())),
domain(PlatformWalletError::TransactionBuild("invalid".to_string())),
txid(4),
);
assert_eq!(outcome.0, None);
Expand All @@ -258,12 +279,14 @@ mod tests {
use platform_wallet::{CoreWallet, SignedCoreTransaction};

use super::*;
use crate::core_wallet::lifecycle::arm_lifecycle_gate_panic;
use crate::core_wallet::FFICoreSignedTransaction;

type TestCore = CoreWallet<platform_wallet::broadcaster::SpvBroadcaster>;

fn finalize(core: &TestCore, signer: &WalletSigner, tag: u8) -> SignedCoreTransaction {
runtime()
.raw()
.block_on(core.finalize_transaction(
TransactionBuilder::new().add_output(
&Address::dummy(Network::Testnet, usize::from(tag)),
Expand All @@ -285,13 +308,14 @@ mod tests {

fn assert_released(core: &TestCore, signer: &WalletSigner, tag: u8) {
let retry = finalize(core, signer, tag);
runtime().block_on(core.abandon_transaction(&retry));
runtime().raw().block_on(core.abandon_transaction(&retry));
}

#[test]
fn double_free_is_safe_and_releases_reservation() {
let (core, signer) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let (core, signer) = runtime()
.raw()
.block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let transaction_handle = insert(&core, finalize(&core, &signer, 40));

core_wallet_signed_transaction_free(transaction_handle);
Expand All @@ -302,8 +326,9 @@ mod tests {

#[test]
fn invalid_or_wrong_wallet_consumes_and_releases() {
let (origin, origin_signer) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let (origin, origin_signer) = runtime()
.raw()
.block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let invalid_transaction = insert(&origin, finalize(&origin, &origin_signer, 42));
let invalid =
unsafe { core_wallet_abandon_signed_transaction(u64::MAX, invalid_transaction) };
Expand All @@ -313,8 +338,9 @@ mod tests {
);
assert_released(&origin, &origin_signer, 43);

let (other, _) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let (other, _) = runtime()
.raw()
.block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let other_handle = CORE_WALLET_STORAGE.insert(other);
let wrong_transaction = insert(&origin, finalize(&origin, &origin_signer, 44));
let wrong =
Expand All @@ -329,8 +355,9 @@ mod tests {

#[test]
fn abandon_then_free_or_broadcast_cannot_reconsume_handle() {
let (core, signer) =
runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let (core, signer) = runtime()
.raw()
.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, 46));

Expand All @@ -348,4 +375,144 @@ mod tests {
assert_released(&core, &signer, 47);
CORE_WALLET_STORAGE.remove(core_handle);
}

/// The main-path abandon is this entry point's whole job, and the handle
/// is consumed on entry — so a panic during the release must surface as
/// the typed panic error, never fall through to `ok()` for the host to
/// record as "reservation released".
///
/// Driving the call from inside a runtime context makes the guarded
/// `block_on` panic in the guarded region of the genuine entry point
/// ("Cannot start a runtime from within a runtime") before the abandon
/// future is ever polled — a real panic, on the real path. Before the
/// fix, `block_on`'s `()` recovery swallowed exactly this panic and the
/// function reported `Success`.
#[test]
fn abandon_reports_a_panic_as_an_error_not_success() {
let (core, signer) = runtime()
.raw()
.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, 48));

let result = tokio::runtime::Builder::new_current_thread()
.build()
.expect("build helper runtime")
.block_on(async {
unsafe { core_wallet_abandon_signed_transaction(core_handle, transaction_handle) }
});

assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorWalletOperation,
"a panicked abandon must not report success — the host would \
record the reservation as released"
);
let message = unsafe { std::ffi::CStr::from_ptr(result.message) }
.to_str()
.expect("message is UTF-8");
assert!(
message.starts_with(crate::panic_guard::FFI_PANIC_PREFIX),
"message must carry the panic marker: {message}"
);

// The handle was consumed on entry regardless of the panic — a retry
// is a clean not-found error, not a second release attempt.
let retry =
unsafe { core_wallet_abandon_signed_transaction(core_handle, transaction_handle) };
assert_eq!(retry.code, PlatformWalletFFIResultCode::NotFound);
CORE_WALLET_STORAGE.remove(core_handle);
}

/// A lifecycle-gate failure must not strand the build's UTXO reservation.
///
/// The gate is taken *after* `finalize_transaction` has reserved the
/// inputs, and on this path the transaction handle has already been
/// consumed on entry — so if the guarded acquisition fails and the entry
/// point just returns, the host is left holding neither handle nor token,
/// and nothing in the process can ever release those inputs again. The
/// wallet would silently lose that much spendable balance until restart
/// (`dashpay/platform#4424` review).
///
/// `assert_released` is the proof: it finalizes a *second* transaction
/// against the same account, which can only fund if the first build's
/// inputs came back.
#[test]
fn a_lifecycle_gate_panic_releases_the_reservation_and_reports_the_panic() {
let (core, signer) = runtime()
.raw()
.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, 50));
let mut out_txid: *mut c_char = std::ptr::null_mut();

arm_lifecycle_gate_panic();
let result = unsafe {
core_wallet_broadcast_signed_transaction(core_handle, transaction_handle, &mut out_txid)
};

assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorWalletOperation,
"a caught panic must arrive as the generic code"
);
let message = unsafe { std::ffi::CStr::from_ptr(result.message) }
.to_str()
.expect("message is UTF-8");
assert_eq!(
message.find(crate::panic_guard::FFI_PANIC_PREFIX),
Some(0),
"the marker must be at position 0: {message}"
);
assert!(
message.contains("injected lifecycle-gate panic"),
"the payload must survive: {message}"
);
assert!(
out_txid.is_null(),
"nothing was broadcast, so no txid may be published"
);

// The reservation came back: a fresh build on the same account funds.
assert_released(&core, &signer, 51);

// The handle was consumed on entry, panic or not.
let retry = unsafe {
core_wallet_broadcast_signed_transaction(core_handle, transaction_handle, &mut out_txid)
};
assert_eq!(retry.code, PlatformWalletFFIResultCode::NotFound);
CORE_WALLET_STORAGE.remove(core_handle);
}

/// The same guarantee stated at the seam the other two entry points share
/// (`core_wallet_tx_builder_finalize` and
/// `core_wallet_signed_payment_finalize` reach it with an unpublished
/// handle / unregistered token respectively). Those two need a live
/// `MnemonicResolverHandle` vtable to drive end-to-end, so the shared
/// helper is pinned directly instead.
#[test]
fn lifecycle_gate_helper_releases_before_returning_the_failure() {
let (core, signer) = runtime()
.raw()
.block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account));
let finalized = finalize(&core, &signer, 52);

arm_lifecycle_gate_panic();
let error = lifecycle_gate_or_release(&core, &core, &finalized)
.expect_err("the armed panic must surface as a boundary failure");
assert_eq!(
error.to_string().find(crate::panic_guard::FFI_PANIC_PREFIX),
Some(0)
);

assert_released(&core, &signer, 53);

// The happy path still hands back a held gate and a live generation.
let (_gate, live) = lifecycle_gate_or_release(&core, &core, &finalized)
.expect("acquisition must succeed when nothing is armed");
assert!(live, "a freshly built wallet is its own current generation");
runtime()
.raw()
.block_on(core.abandon_transaction(&finalized));
}
}
Loading
Loading