From a711c55eca5a948d382a4083fb43b7fec75d83ef Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:06:52 -0400 Subject: [PATCH 01/10] feat(kotlin-sdk): CoinJoin-drain shielded funding binding + typed asset-lock shortfall The two Android-facing pieces that #4327 left out. #4327 added the CoinJoin-drain asset-lock funding FFI export (`platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain`) and its Swift wrapper, but no Kotlin/JNI, so Android could not call CoinJoin-funded shielding at all. This adds the JNI export and the Kotlin surface, following the Swift wrapper's contract: no amount (the lock value is the builder's Sigma inputs - L1 fee) and no surplus output (the single-recipient remainder flow pins the consensus surplus to zero). It also allocates the typed asset-lock shortfall at its long-reserved code 29. The FFI error registry has held 29 for `ErrorAssetLockInsufficientFunds` since #4184, and every host mirror already documents the number, but the code was never allocated: #4184 and its successor #4316 were both closed unmerged, leaving the producing `PlatformWalletError` variant absent too. Without it an asset-lock coin-selection shortfall flattens to `ErrorUnknown` (99) and hosts must substring-match the Display text. The empty-candidate-set case now stays on the same structured path as a partial shortfall instead of falling through to the generic string form. Draining an empty CoinJoin account is exactly a coin-selection shortfall, so the two halves meet at the same call: the Android mixed-funds migration needs the binding to run and the typed code to explain a failure. Co-Authored-By: Claude Fable 5 --- .../dashsdk/errors/DashSdkError.kt | 24 ++- .../dashsdk/ffi/FundingNative.kt | 20 +++ .../dashsdk/wallet/PlatformWalletManager.kt | 48 ++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 25 ++++ packages/rs-platform-wallet-ffi/src/error.rs | 83 ++++++++++- packages/rs-platform-wallet/src/error.rs | 30 ++++ .../src/wallet/asset_lock/build.rs | 139 ++++++++++++++++-- packages/rs-unified-sdk-jni/src/funding.rs | 60 ++++++++ 8 files changed, 418 insertions(+), 11 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index b6c555a3add..b66b5489ac2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -99,6 +99,27 @@ sealed class DashSdkError( class AssetLockFundingMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorAssetLockInsufficientFunds` (native code 29). Asset-lock coin + * selection came up short on the ONE funds account the caller selected + * — asset-lock funding never unions across accounts, so another source + * must be named explicitly rather than combined automatically. + * + * Distinct from [CoreInsufficientFunds] (22), which is the atomic + * Core-send selector rather than the asset-lock builder. The shortfall + * figures travel in [message] as `available {n} duffs, required {n} + * duffs` — the native result is ABI-frozen to code + message, so there + * are no structured fields to read. + * + * Raised by + * [shieldedFundFromCoinJoinDrain][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedFundFromCoinJoinDrain] + * when the CoinJoin account has nothing to drain, and by + * [shieldedFundFromAssetLock][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedFundFromAssetLock] + * when the funding account cannot cover the requested lock. + */ + class AssetLockInsufficientFunds(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -470,10 +491,11 @@ sealed class DashSdkError( 24 -> PlatformWallet.AssetLockAlreadyConsumed(message, cause) // ErrorAssetLockAlreadyConsumed 25 -> PlatformWallet.AssetLockFundingMismatch(message, cause) // ErrorAssetLockFundingMismatch 26 -> PlatformWallet.TransactionBroadcastRejected(message, cause) // ErrorTransactionBroadcastRejected + 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds - // (#4184), 31 ErrorSigningKeyUnavailable (#4183/#4259), 32 + // (mapped above), 31 ErrorSigningKeyUnavailable (#4183/#4259), 32 // ErrorTransactionBuild (#4247/#4256), 33 ErrorTransactionSigning // (#4256). See packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md. 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index d85f538d31d..dd886361ac4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -62,6 +62,26 @@ internal object FundingNative { coreSignerHandle: Long, ) + /** + * Fund the shielded pool by DRAINING the wallet's CoinJoin account into a + * single asset lock (bridges + * `platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain`). + * Sibling of [shieldedFundFromAssetLock] with drain funding: there is no + * amount (the lock value is `Σ inputs − L1 fee`, computed Rust-side) and no + * surplus output (the single-recipient remainder flow pins it to zero). + * [coinJoinAccountIndex] selects the CoinJoin account to drain; + * [recipientRaw43] is the 43-byte raw Orchard address; [coreSignerHandle] + * is the manager's `MnemonicResolverHandle`. Blocks for the ~30s Halo 2 + * proof; the note arrives on the next shielded sync. + */ + external fun shieldedFundFromCoinJoinDrain( + managerHandle: Long, + walletId: ByteArray, + coinJoinAccountIndex: Int, + recipientRaw43: ByteArray, + coreSignerHandle: Long, + ) + /** * Resume a stuck shielded fund-from-asset-lock by outpoint (bridges * `platform_wallet_manager_shielded_resume_fund_from_asset_lock`). diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1d216a594bb..3479498e966 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1480,6 +1480,54 @@ class PlatformWalletManager( } } + /** + * Fund a wallet's shielded (Orchard) pool by DRAINING its CoinJoin account + * (`m/9'/coinType'/4'/coinJoinAccountIndex'`) into a single asset lock — + * port of Swift's `shieldedFundFromCoinJoinDrain`. + * + * Sibling of [shieldedFundFromAssetLock] with drain funding, which is what + * makes this the CoinJoin → Shielded migration path: every final mixed-coin + * UTXO is consumed and the lock value is `Σ inputs − L1 fee`, computed + * Rust-side, so the mixed coins never hop through a transparent BIP44 + * address on the way in. Hence no amount parameter, and no surplus output + * (the single-recipient remainder flow pins the consensus surplus to zero). + * + * The recipient receives `lockValue − poolFee` credits. The Rust preflight + * rejects a drain whose balance could not clear the Type 18 pool fee, so an + * unrecoverable dust lock is never broadcast; a drain of an empty account + * fails with the typed asset-lock shortfall + * ([org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.AssetLockInsufficientFunds]). + * A stuck lock resumes via [shieldedResumeFundFromAssetLock] exactly like a + * BIP44-funded one. + * + * Blocks for the ~30s Halo 2 proof; the shielded note itself arrives on the + * next shielded sync pass, so nothing is returned. + * + * @param walletId the 32-byte wallet id. + * @param recipientRaw43 the 43-byte raw Orchard payment address + * (11-byte diversifier + 32-byte pk_d). + * @param coinJoinAccountIndex the CoinJoin account whose whole balance + * funds the asset lock (account 0 for every current wallet). + */ + suspend fun shieldedFundFromCoinJoinDrain( + walletId: ByteArray, + recipientRaw43: ByteArray, + coinJoinAccountIndex: Int = 0, + ): Unit = teardownGate.op { + require(coinJoinAccountIndex >= 0) { + "coinJoinAccountIndex must be non-negative, got $coinJoinAccountIndex" + } + mapNativeErrors { + FundingNative.shieldedFundFromCoinJoinDrain( + managerHandle, + walletId, + coinJoinAccountIndex, + recipientRaw43, + mnemonicResolverHandle, + ) + } + } + /** * Shield from Platform balance (Type 15) — port of Swift's * `shieldedShield`. Spends [amount] credits from the wallet's diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 57f758e2868..3e87ca24d2b 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -100,6 +100,31 @@ class DashSdkErrorTest { DashSdkError.fromNative(DashSDKException(offset + 22, "inputs reserved")) assertTrue(coreInsufficientFunds is DashSdkError.PlatformWallet.CoreInsufficientFunds) + // The asset-lock coin-selection shortfall (29) must reach callers as its + // own type rather than Generic, and must stay DISTINCT from the atomic + // Core-send shortfall (22) — asset-lock funding never unions across + // accounts, so hosts message the two differently. Its available/required + // duffs ride the message, which must survive verbatim. + val assetLockShort = DashSdkError.fromNative( + DashSDKException( + offset + 29, + "asset lock coin selection is short: available 18000000 duffs, " + + "required 100000000 duffs", + ), + ) + assertTrue( + "code 29 must not fall through to Generic", + assetLockShort is DashSdkError.PlatformWallet.AssetLockInsufficientFunds, + ) + assertFalse( + "the asset-lock shortfall must not be conflated with the Core-send one", + assetLockShort is DashSdkError.PlatformWallet.CoreInsufficientFunds, + ) + assertTrue( + "shortfall amounts must survive in the message", + assetLockShort.message!!.contains("available 18000000 duffs"), + ) + val recoveryCodes = mapOf( 23 to DashSdkError.PlatformWallet.AssetLockNotTracked::class, 24 to DashSdkError.PlatformWallet.AssetLockAlreadyConsumed::class, diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cb560d46f31..0d3630acac6 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -216,6 +216,21 @@ pub enum PlatformWalletFFIResultCode { /// join instead of erroring. Swift mirror: /// `PlatformWalletResultCode.errorShutdownIncomplete`. ErrorShutdownIncomplete = 27, + /// Asset-lock coin selection came up short over the *permitted* funding + /// set (dashpay/platform#4073). Carries the structured + /// `available`/`required` duff amounts in the message string — the + /// by-value `PlatformWalletFFIResult` is ABI-frozen (code + message only), + /// so the figures ride the typed `Display` rendering or not at all. + /// + /// Distinct from [`Self::ErrorCoreInsufficientFunds`] (22), which is the + /// atomic Core-send selector rather than the asset-lock builder. Asset-lock + /// funding never unions across accounts, so this names a shortfall on the + /// ONE account the caller selected; a host offering another source must + /// name it explicitly. + /// + /// Reached by the CoinJoin → shielded migration when the mixed account + /// cannot cover the lock, which is why the Android binding needs it typed. + ErrorAssetLockInsufficientFunds = 29, /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — the stored blob is /// missing, stranded, or written under a different Keystore/Keychain @@ -248,7 +263,10 @@ pub enum PlatformWalletFFIResultCode { // // 27 ErrorShutdownIncomplete MERGED on v4.2-dev (dashpay/platform#4268) // 28 (free — vacated by this PR) - // 29 ErrorAssetLockInsufficientFunds dashpay/platform#4184 + // 29 ErrorAssetLockInsufficientFunds ALLOCATED above. Claimed by + // dashpay/platform#4184, which was closed unmerged along with its + // successor #4316; this PR salvages the code at its reserved number + // so the ABI matches what every host mirror already documents. // 30 (free — vacated by this PR) // 31 ErrorSigningKeyUnavailable dashpay/platform#4183, #4259 // 32 ErrorTransactionBuild dashpay/platform#4247, #4256 @@ -605,6 +623,16 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockFundingMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch } + // The asset-lock coin-selection shortfall (dashpay/platform#4073). + // Without this arm it flattens to `ErrorUnknown` (99), hiding a + // typed shortfall behind the catch-all and forcing hosts to + // string-match the Display text. The structured + // `available`/`required` duff amounts still travel in the message + // (there are no out-params for them), but the code now lets a host + // branch on the shortfall without parsing text. + PlatformWalletError::AssetLockInsufficientFunds { .. } => { + PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds + } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. @@ -1076,6 +1104,59 @@ mod tests { } } + /// The asset-lock coin-selection shortfall must cross the FFI boundary as + /// the dedicated `ErrorAssetLockInsufficientFunds` (29) code — NOT + /// `ErrorUnknown` (99) as it did before this arm existed + /// (dashpay/platform#4073) — and its structured `available`/`required` + /// duffs must survive verbatim in the message so hosts can parse the + /// amounts. + #[test] + fn asset_lock_insufficient_funds_maps_to_dedicated_code() { + let err = PlatformWalletError::AssetLockInsufficientFunds { + available: 18_000_000, + required: 100_000_000, + }; + let rendered = err.to_string(); + // Guard the exact text hosts (dash-wallet) substring-match on. + assert!( + rendered.contains("asset lock coin selection is short"), + "shortfall Display text changed — coordinate dash-wallet's matcher \ + (rendered: {rendered})" + ); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds, + "must not flatten to ErrorUnknown(99) (rendered: {rendered})" + ); + assert_ne!( + result.code as i32, + PlatformWalletFFIResultCode::ErrorUnknown as i32 + ); + assert!(!result.message.is_null()); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "structured available/required duffs must survive the FFI boundary verbatim" + ); + } + + /// The numeric value of `ErrorAssetLockInsufficientFunds` is ABI, mirrored + /// by hand in the Swift and Kotlin host enums. Pin it so a future + /// renumbering of the surrounding block cannot silently re-point a host's + /// shortfall branch at some other error. + #[test] + fn asset_lock_insufficient_funds_code_is_pinned_at_29() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds as i32, + 29, + "code 29 is reserved for the asset-lock shortfall in the FFI \ + error-code registry; hosts mirror the number, not the name" + ); + } + /// `WalletAlreadyExists` maps to the dedicated /// `ErrorWalletAlreadyExists` FFI code rather than flattening to /// `ErrorUnknown`, so multi-network wallet create/enable callers can diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index f018bc37b87..4bf1a7773ce 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -252,6 +252,36 @@ pub enum PlatformWalletError { actual_identity_index: u32, }, + /// Asset-lock coin selection came up short, so a host (and ultimately the + /// wallet UI) can render a precise shortfall instead of a stringly-typed + /// "Insufficient funds" message (dashpay/platform#4073). + /// + /// The `available` figure reflects the single funds account the caller + /// selected — the unmixed BIP44 account by default, or an explicit account + /// such as CoinJoin. A shortfall here means that *one* account is short: + /// asset-lock funding never unions across accounts, so a different source + /// must be named explicitly rather than combined automatically. + /// + /// Distinct from [`CoreInsufficientFunds`] / [`CorePooledInsufficientFunds`], + /// which belong to the atomic Core-send selector rather than the asset-lock + /// builder, and which carry `Option` amounts because a pooled send may not + /// know them. The asset-lock builder always has concrete figures: the + /// key-wallet shortfall errors carry their own, and the empty-candidate-set + /// case is reported as `available: 0` against the requested target. + /// + /// On a *drain* build (whole-account funding, e.g. the CoinJoin → shielded + /// migration) the requested target is the zero credit-output placeholder + /// that key-wallet rewrites to `Σ inputs − fee`, so an empty account + /// surfaces here as `available: 0, required: 0` — the "this account has + /// nothing to drain" signal. The real floor for a drain is the Type 18 pool + /// fee, enforced downstream against the built payload once the lock value + /// is known. + #[error( + "asset lock coin selection is short: available {available} duffs, \ + required {required} duffs" + )] + AssetLockInsufficientFunds { available: u64, required: u64 }, + #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 1fe80310c2a..2cfbcb8db5e 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -13,9 +13,11 @@ use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::signer::ExtendedPubKeySigner; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{ - AssetLockFundingAccount, AssetLockFundingType, CreditOutputFunding, + AssetLockError, AssetLockFundingAccount, AssetLockFundingType, CreditOutputFunding, }; +use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionError; use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; +use key_wallet::wallet::managed_wallet_info::transaction_builder::BuilderError; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -188,12 +190,7 @@ impl AssetLockManager { signer, ) .await - .map_err(|e| { - PlatformWalletError::AssetLockTransaction(format!( - "Asset lock builder failed: {}", - e - )) - })?; + .map_err(|e| map_builder_error(e, amount_duffs))?; // 4. Pull the (pubkey, path) for our single credit output. // @@ -964,6 +961,58 @@ impl AssetLockManager { } } +/// Map a key-wallet [`AssetLockError`] to a [`PlatformWalletError`], promoting +/// every coin-selection shortfall shape to the typed +/// [`PlatformWalletError::AssetLockInsufficientFunds`] so callers get one +/// structured shortfall contract (dashpay/platform#4073) instead of a string +/// they must pattern-match: +/// +/// - `BuilderError::InsufficientFunds` / `SelectionError::InsufficientFunds` +/// carry their own exact `available`/`required` duff amounts — preserved +/// verbatim. +/// - `SelectionError::NoUtxosAvailable` — the zero-spendable-candidate case, +/// the MOST extreme shortfall — carries no amounts, so it would otherwise +/// fall through to the generic string form while *partial* shortfalls +/// stayed typed. It maps to `available: 0` against the caller's +/// `requested` target, keeping the empty candidate set on the same +/// structured path. +/// +/// `requested` is the caller's target in duffs. On a drain build it is the +/// zero credit-output placeholder (key-wallet rewrites the value to +/// `Σ inputs − fee`), so an empty account reports `available: 0, required: 0` +/// — "nothing to drain". A drain's real floor is the pool fee, enforced +/// downstream by `broadcast_funded_asset_lock_with_funding` against the built +/// payload. +/// +/// Every other builder error keeps the pre-existing generic +/// `AssetLockTransaction` string form. +fn map_builder_error(e: AssetLockError, requested: u64) -> PlatformWalletError { + match e { + AssetLockError::Builder( + BuilderError::InsufficientFunds { + available, + required, + } + | BuilderError::CoinSelection(SelectionError::InsufficientFunds { + available, + required, + }), + ) => PlatformWalletError::AssetLockInsufficientFunds { + available, + required, + }, + AssetLockError::Builder(BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)) => { + PlatformWalletError::AssetLockInsufficientFunds { + available: 0, + required: requested, + } + } + other => { + PlatformWalletError::AssetLockTransaction(format!("Asset lock builder failed: {other}")) + } + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -992,6 +1041,68 @@ mod tests { use crate::wallet::platform_wallet::WalletId; use crate::{AssetLockFundingType, PlatformWalletError}; + /// The zero-spendable-candidate selection error must surface the SAME + /// typed shortfall as a partial shortfall (not the generic string form), + /// so hosts stay on one structured path; and a partial shortfall must + /// still carry its own exact amounts (dashpay/platform#4073). + #[test] + fn coin_selection_shortfalls_map_to_typed_insufficient_funds() { + use super::{map_builder_error, AssetLockError, BuilderError, SelectionError}; + + // Zero spendable candidates -> typed, available: 0, required = requested. + match map_builder_error( + AssetLockError::Builder(BuilderError::CoinSelection( + SelectionError::NoUtxosAvailable, + )), + 12_345, + ) { + PlatformWalletError::AssetLockInsufficientFunds { + available, + required, + } => { + assert_eq!(available, 0, "empty candidate set means nothing available"); + assert_eq!( + required, 12_345, + "requested target threaded through as required" + ); + } + other => panic!("expected typed AssetLockInsufficientFunds, got {other:?}"), + } + + // A partial shortfall keeps its own exact amounts; the requested arg is + // NOT substituted for the builder's carried values. + match map_builder_error( + AssetLockError::Builder(BuilderError::CoinSelection( + SelectionError::InsufficientFunds { + available: 100, + required: 500, + }, + )), + 999, + ) { + PlatformWalletError::AssetLockInsufficientFunds { + available, + required, + } => { + assert_eq!(available, 100); + assert_eq!(required, 500, "carried amounts win over the requested arg"); + } + other => panic!("expected typed AssetLockInsufficientFunds, got {other:?}"), + } + + // A non-shortfall builder error keeps the pre-existing generic string + // form — the typed promotion must not swallow unrelated failures. + match map_builder_error(AssetLockError::WatchOnlyWallet, 42) { + PlatformWalletError::AssetLockTransaction(msg) => { + assert!( + msg.starts_with("Asset lock builder failed: "), + "generic form preserved, got {msg}" + ); + } + other => panic!("expected generic AssetLockTransaction, got {other:?}"), + } + } + /// Persistence stub that records every stored changeset so tests can /// assert what the asset-lock flow queued. `fail_flush` simulates a /// backend whose durability boundary fails; `flushes` counts `flush` @@ -1369,8 +1480,14 @@ mod tests { &signer, ) .await; + // The reserved UTXO leaves zero spendable candidates, so this is the + // typed selection shortfall — a stronger assertion than the old generic + // build-error match, which any unrelated failure would also satisfy. assert!( - matches!(rebuild, Err(PlatformWalletError::AssetLockTransaction(_))), + matches!( + rebuild, + Err(PlatformWalletError::AssetLockInsufficientFunds { available: 0, .. }) + ), "rebuild must fail at input selection while the reservation is \ kept, got {rebuild:?}" ); @@ -1475,8 +1592,12 @@ mod tests { &signer, ) .await; + // As above: zero spendable candidates is the typed selection shortfall. assert!( - matches!(rebuild, Err(PlatformWalletError::AssetLockTransaction(_))), + matches!( + rebuild, + Err(PlatformWalletError::AssetLockInsufficientFunds { available: 0, .. }) + ), "rebuild must fail at input selection while the reservation is \ kept for the advanced row, got {rebuild:?}" ); diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f8dc82f050a..3efc1378ca9 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -371,6 +371,66 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde }) } +/// Fund the shielded pool by DRAINING the wallet's CoinJoin account +/// (`m/9'/coinType'/4'/accountIndex'`) into a single asset lock — bridges +/// `platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain`. +/// +/// Mirrors Swift's `PlatformWalletManager.shieldedFundFromCoinJoinDrain`. +/// Sibling of [`Java_..._shieldedFundFromAssetLock`] with drain funding, so +/// it differs in exactly two ways: +/// +/// 1. **No amount** — every final mixed-coin UTXO is consumed and the lock +/// value is `Σ inputs − L1 fee`, computed Rust-side. The mixed coins never +/// hop through a transparent BIP44 address, which is what makes this the +/// CoinJoin → Shielded migration path rather than a normal shield. +/// 2. **No surplus output** — the single-recipient remainder flow pins the +/// consensus surplus to zero, so the parameter is omitted rather than +/// plumbed as null. +/// +/// `coinJoinAccountIndex` selects the CoinJoin account to drain (0 for every +/// current wallet); `recipientRaw43` is the 43-byte raw Orchard address; +/// `coreSignerHandle` is the manager's `MnemonicResolverHandle`. The Rust +/// preflight rejects a drain whose balance could not clear the Type 18 pool +/// fee, so an unrecoverable dust lock is never broadcast, and a stuck lock +/// resumes through the same `shieldedResumeFundFromAssetLock` entry point as a +/// BIP44-funded one. The ~30s Halo 2 proof runs inside the call; nothing is +/// returned on success. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shieldedFundFromCoinJoinDrain( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + coin_join_account_index: jint, + recipient_raw43: JByteArray, + core_signer_handle: jlong, +) { + guard(&mut env, (), |env| { + // Reject a negative index at the boundary — it would otherwise + // bit-cast to a huge u32 on the FFI call. + if coin_join_account_index < 0 { + throw_sdk_exception(env, 1, "coinJoinAccountIndex must be non-negative"); + return; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return; + }; + let Some(recipient) = read_recipient43(env, &recipient_raw43) else { + return; + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain( + manager_handle as Handle, + wid.as_ptr(), + coin_join_account_index as u32, + recipient.as_ptr(), + core_signer_handle as *mut MnemonicResolverHandle, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + /// Resume a shielded fund-from-asset-lock from an already-tracked lock by /// outpoint — bridges /// `platform_wallet_manager_shielded_resume_fund_from_asset_lock`. Sibling From bf9a134b9ee883986a753a31ebb0c3923fff2bfd Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:52:45 -0400 Subject: [PATCH 02/10] fix(platform-wallet): report the drain floor, not the zero placeholder, in typed shortfalls An empty CoinJoin drain surfaced AssetLockInsufficientFunds { available: 0, required: 0 } because the drain's zero-valued credit-output placeholder was passed as the requested amount. The shielded flow already threads the positive minimum through AssetLockBuildAmount::DrainAll; use that floor for amount-less selection errors so the advertised pair describes the actual gap. Co-Authored-By: Claude Fable 5 --- .../src/wallet/asset_lock/build.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 2cfbcb8db5e..5f43695b460 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -190,7 +190,21 @@ impl AssetLockManager { signer, ) .await - .map_err(|e| map_builder_error(e, amount_duffs))?; + .map_err(|e| { + // A drain's credit-output value is a zero placeholder, so it + // must not be advertised as the `required` amount of a typed + // shortfall (an empty CoinJoin account would report + // `available: 0, required: 0`). The shielded flow already + // computed the positive floor and threads it through + // `DrainAll`; use it so the pair describes the real gap. + let required = match amount { + AssetLockBuildAmount::Exact(value) => value, + AssetLockBuildAmount::DrainAll { + minimum_lock_duffs, + } => minimum_lock_duffs.unwrap_or(0), + }; + map_builder_error(e, required) + })?; // 4. Pull the (pubkey, path) for our single credit output. // From fc97ca38d07b7225723b1e54283f92ccc26e718e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:42:41 -0400 Subject: [PATCH 03/10] docs(platform-wallet): drain shortfalls report the configured floor, not 0/0 The typed-shortfall docs still promised 'available: 0, required: 0' for an empty drain; since the mapper passes minimum_lock_duffs.unwrap_or(0) as required, an empty drain with a configured floor (the shielded flow installs the positive Type 18 pool-fee floor before building) reports that floor, and only a floor-less drain reports 0. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet/src/error.rs | 12 +++++++----- .../src/wallet/asset_lock/build.rs | 11 +++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8d9c6a9065f..11222caa201 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -301,11 +301,13 @@ pub enum PlatformWalletError { /// /// On a *drain* build (whole-account funding, e.g. the CoinJoin → shielded /// migration) the requested target is the zero credit-output placeholder - /// that key-wallet rewrites to `Σ inputs − fee`, so an empty account - /// surfaces here as `available: 0, required: 0` — the "this account has - /// nothing to drain" signal. The real floor for a drain is the Type 18 pool - /// fee, enforced downstream against the built payload once the lock value - /// is known. + /// that key-wallet rewrites to `Σ inputs − fee`, so `required` reports the + /// caller's drain floor instead: an empty account surfaces as + /// `available: 0, required: ` (the shielded flow + /// installs the positive Type 18 pool-fee floor before building), and only + /// a floor-less drain reports `required: 0`. The floor is additionally + /// enforced downstream against the built payload once the lock value is + /// known. #[error( "asset lock coin selection is short: available {available} duffs, \ required {required} duffs" diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index eba6c8f61b8..e0e135c97e1 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1051,10 +1051,13 @@ impl AssetLockManager { /// `requested` target, keeping the empty candidate set on the same /// structured path. /// -/// `requested` is the caller's target in duffs. On a drain build it is the -/// zero credit-output placeholder (key-wallet rewrites the value to -/// `Σ inputs − fee`), so an empty account reports `available: 0, required: 0` -/// — "nothing to drain". A drain's real floor is the pool fee, enforced +/// `requested` is the caller's target in duffs. On a drain build the target is +/// the zero credit-output placeholder (key-wallet rewrites the value to +/// `Σ inputs − fee`), so the mapper substitutes the drain floor — +/// `minimum_lock_duffs.unwrap_or(0)` — as `required`: an empty account reports +/// `available: 0` against the configured floor (positive for the shielded +/// flow, which installs the Type 18 pool-fee floor before building), and 0 +/// only when no floor was supplied. The floor is additionally enforced /// downstream by `broadcast_funded_asset_lock_with_funding` against the built /// payload. /// From 18ce5d83bf50d4142e2d1fea292585ba57b84173 Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 08:57:45 -0400 Subject: [PATCH 04/10] docs(platform-wallet): asset-lock shortfalls span the pooled sources on exact-amount builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new shortfall docs claimed 'available' always describes the one account the caller selected and that asset-lock funding never unions accounts. Wrong for the exact-amount path: it funds through ASSET_LOCK_FUNDING_SOURCES, which pools the BIP44 and BIP32 accounts with every DashPay contact-receiving account, so that shortfall describes the whole permitted union. Only a whole-account drain build (CoinJoin's only form — mixed coins are never pooled) names a single account's shortfall. Corrected in the Rust error docs, the FFI code-29 docs, the Kotlin AssetLockInsufficientFunds KDoc, and the Kotlin error test narrative; also repaired the block's two pre-existing broken intra-doc links (CoreInsufficientFunds / CorePooledInsufficientFunds) with reference definitions. Doc-only; no funding behavior changed. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/errors/DashSdkError.kt | 16 +++++++++----- .../dashsdk/errors/DashSdkErrorTest.kt | 8 ++++--- packages/rs-platform-wallet-ffi/src/error.rs | 11 ++++++---- packages/rs-platform-wallet/src/error.rs | 21 ++++++++++++++----- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 3f03666576c..ea700dd7ea7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -117,9 +117,14 @@ sealed class DashSdkError( /** * `ErrorAssetLockInsufficientFunds` (native code 29). Asset-lock coin - * selection came up short on the ONE funds account the caller selected - * — asset-lock funding never unions across accounts, so another source - * must be named explicitly rather than combined automatically. + * selection came up short over the build's *permitted funding set*. + * What that set is depends on the funding form: an exact-amount build + * POOLS the default source list (the BIP44 and BIP32 accounts plus + * every DashPay contact-receiving account), so its shortfall + * describes that whole union rather than any single account; only a + * whole-account *drain* build — CoinJoin's only form, since mixed + * coins are never pooled with transparent ones — names a single + * account's shortfall. * * Distinct from [CoreInsufficientFunds] (22), which is the atomic * Core-send selector rather than the asset-lock builder. The shortfall @@ -129,9 +134,10 @@ sealed class DashSdkError( * * Raised by * [shieldedFundFromCoinJoinDrain][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedFundFromCoinJoinDrain] - * when the CoinJoin account has nothing to drain, and by + * when the CoinJoin account has nothing to drain (single-account + * drain), and by * [shieldedFundFromAssetLock][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedFundFromAssetLock] - * when the funding account cannot cover the requested lock. + * when the pooled funding sources cannot cover the requested lock. */ class AssetLockInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 57bbc75f996..94cc410285e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -102,9 +102,11 @@ class DashSdkErrorTest { // The asset-lock coin-selection shortfall (29) must reach callers as its // own type rather than Generic, and must stay DISTINCT from the atomic - // Core-send shortfall (22) — asset-lock funding never unions across - // accounts, so hosts message the two differently. Its available/required - // duffs ride the message, which must survive verbatim. + // Core-send shortfall (22) — the two selectors report over different + // funding sets (the asset-lock figures span the pooled sources on an + // exact-amount build and exactly one account on a drain), so hosts + // message the two differently. Its available/required duffs ride the + // message, which must survive verbatim. val assetLockShort = DashSdkError.fromNative( DashSDKException( offset + 29, diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 62b03006221..c4021e1e697 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -224,10 +224,13 @@ pub enum PlatformWalletFFIResultCode { /// so the figures ride the typed `Display` rendering or not at all. /// /// Distinct from [`Self::ErrorCoreInsufficientFunds`] (22), which is the - /// atomic Core-send selector rather than the asset-lock builder. Asset-lock - /// funding never unions across accounts, so this names a shortfall on the - /// ONE account the caller selected; a host offering another source must - /// name it explicitly. + /// atomic Core-send selector rather than the asset-lock builder. What the + /// figures cover depends on the build's funding form: an exact-amount + /// build pools the default source list (BIP44 + BIP32 + every DashPay + /// contact-receiving account), so its shortfall describes that whole + /// permitted union — not any single account — while a whole-account + /// *drain* build names exactly one account's shortfall. CoinJoin funds + /// only through the drain form (never pooled). /// /// Reached by the CoinJoin → shielded migration when the mixed account /// cannot cover the lock, which is why the Android binding needs it typed. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 11222caa201..cf02e8c95d4 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -286,11 +286,19 @@ pub enum PlatformWalletError { /// wallet UI) can render a precise shortfall instead of a stringly-typed /// "Insufficient funds" message (dashpay/platform#4073). /// - /// The `available` figure reflects the single funds account the caller - /// selected — the unmixed BIP44 account by default, or an explicit account - /// such as CoinJoin. A shortfall here means that *one* account is short: - /// asset-lock funding never unions across accounts, so a different source - /// must be named explicitly rather than combined automatically. + /// What `available` covers depends on the build's funding form. An + /// exact-amount build funds from a POOLED source list — the default + /// [`ASSET_LOCK_FUNDING_SOURCES`](crate::ASSET_LOCK_FUNDING_SOURCES) + /// unions the BIP44 and BIP32 accounts with every DashPay + /// contact-receiving account — so its shortfall describes that whole + /// permitted union, not any single account (an explicit single-element + /// source list narrows it back to one account). Only a *drain* build + /// (whole-account funding) selects exactly one account, so only there + /// does the figure name a single account's shortfall. CoinJoin funds + /// exclusively through the drain form — it is never pooled (spending + /// mixed outputs alongside transparent ones would link them), so the + /// CoinJoin → shielded migration's shortfall is always the mixed + /// account's own. /// /// Distinct from [`CoreInsufficientFunds`] / [`CorePooledInsufficientFunds`], /// which belong to the atomic Core-send selector rather than the asset-lock @@ -308,6 +316,9 @@ pub enum PlatformWalletError { /// a floor-less drain reports `required: 0`. The floor is additionally /// enforced downstream against the built payload once the lock value is /// known. + /// + /// [`CoreInsufficientFunds`]: Self::CoreInsufficientFunds + /// [`CorePooledInsufficientFunds`]: Self::CorePooledInsufficientFunds #[error( "asset lock coin selection is short: available {available} duffs, \ required {required} duffs" From 35dbd8c4c09cf1db5efd6af953abf415b889ce5c Mon Sep 17 00:00:00 2001 From: bfoss765 Date: Wed, 12 Aug 2026 18:29:50 -0400 Subject: [PATCH 05/10] style(platform-wallet): rustfmt the DrainAll shortfall match arm cargo fmt --check --all is a gate in the Rust workspace tests workflow and 18ce5d83bf left one arm unformatted, failing the whole job in 40s. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/wallet/asset_lock/build.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index e0e135c97e1..0977809af52 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -225,9 +225,9 @@ impl AssetLockManager { // `DrainAll`; use it so the pair describes the real gap. let required = match amount { AssetLockBuildAmount::Exact(value) => value, - AssetLockBuildAmount::DrainAll { - minimum_lock_duffs, - } => minimum_lock_duffs.unwrap_or(0), + AssetLockBuildAmount::DrainAll { minimum_lock_duffs } => { + minimum_lock_duffs.unwrap_or(0) + } }; map_builder_error(e, required) })?; From 2eac8a897eddb3b5bec0074ace9b9609b7e6d10d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:43:04 -0400 Subject: [PATCH 06/10] fix(platform-wallet-ffi): keep the asset-lock shortfall typed on the exact-amount funding path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `map_asset_lock_funding_result` preserved only `AssetLockAlreadyConsumed` and sent everything else to the generic `ErrorWalletOperation` (6) catch-all. `AssetLockInsufficientFunds` therefore never reached its blanket-`From` arm, so the dedicated `ErrorAssetLockInsufficientFunds` (29) that `error.rs` has always produced could not actually cross the boundary through `platform_wallet_manager_shielded_fund_from_asset_lock` — the exact-amount funding form — or through its resume sibling. Hosts saw the same code every unclassified wallet failure uses and were left substring-matching the Display text. Add the typed arm so both entry points hand the variant to the blanket impl, which also carries the structured available/required duffs verbatim (`PlatformWalletFFIResult` is ABI-frozen at code + message, so the figures ride the message or not at all). Extend the helper test to pin 29 through the exact-amount entry point and cover the resume sibling, mirroring `error::tests::asset_lock_insufficient_funds_maps_to_dedicated_code`. Rename the existing case, whose `_code_only` name asserted exactly the behaviour this changes. --- .../src/shielded_send.rs | 90 ++++++++++++++++++- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 21d98fac4db..180705f7945 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -613,10 +613,27 @@ fn map_spend_result( } } -/// Preserve the typed "already consumed" funding report across the FFI +/// Preserve the typed funding reports that hosts branch on across the FFI /// boundary while keeping every other funding failure on the existing generic -/// error path. The wallet retains nonterminal consumption-unknown state; the -/// host must not interpret this code as authenticated completion. +/// error path. +/// +/// Both preserved variants reach their dedicated code through the blanket +/// `From for PlatformWalletFFIResult` impl in +/// [`crate::error`], so `e.into()` also carries each typed `Display` +/// rendering verbatim — the structured figures ride the message string or +/// not at all (`PlatformWalletFFIResult` is ABI-frozen at code + message). +/// +/// - `AssetLockAlreadyConsumed` -> `ErrorAssetLockAlreadyConsumed` (24). The +/// wallet retains nonterminal consumption-unknown state; the host must not +/// interpret this code as authenticated completion. +/// - `AssetLockInsufficientFunds` -> `ErrorAssetLockInsufficientFunds` (29). +/// Coin selection came up short over the permitted funding set, so nothing +/// was built or broadcast and no funding output was consumed; the host may +/// re-run preflight and confirm a smaller amount. Without this arm the +/// shortfall flattened into the generic `ErrorWalletOperation` (6) +/// catch-all below, hiding a typed error behind the code every unclassified +/// failure already uses and forcing hosts back to substring-matching the +/// Display text. fn map_asset_lock_funding_result( result: Result<(), PlatformWalletError>, operation: &str, @@ -624,6 +641,7 @@ fn map_asset_lock_funding_result( match result { Ok(()) => PlatformWalletFFIResult::ok(), Err(e @ PlatformWalletError::AssetLockAlreadyConsumed(_)) => e.into(), + Err(e @ PlatformWalletError::AssetLockInsufficientFunds { .. }) => e.into(), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1853,7 +1871,7 @@ mod tests { } #[test] - fn map_asset_lock_funding_result_preserves_already_consumed_code_only() { + fn map_asset_lock_funding_result_preserves_typed_funding_codes() { let out_point = dashcore::OutPoint { txid: dashcore::Txid::all_zeros(), vout: 7, @@ -1882,4 +1900,68 @@ mod tests { PlatformWalletFFIResultCode::Success ); } + + /// The asset-lock coin-selection shortfall must reach hosts as the + /// dedicated `ErrorAssetLockInsufficientFunds` (29) through THIS entry + /// point — `platform_wallet_manager_shielded_fund_from_asset_lock`, the + /// exact-amount funding form, whose whole result path is this helper. + /// The blanket `From` impl has always produced 29 + /// (`error::tests::asset_lock_insufficient_funds_maps_to_dedicated_code`), + /// but the helper's catch-all used to flatten the variant to + /// `ErrorWalletOperation` (6) before it ever got there, so the typed code + /// never actually crossed the boundary on this call. Kotlin already + /// mirrors 29 as + /// `DashSdkError.PlatformWallet.AssetLockInsufficientFunds` + /// (`DashSdkError.kt`) — this pins the Rust side that feeds it. + #[test] + fn map_asset_lock_funding_result_preserves_shortfall_code_29() { + let err = PlatformWalletError::AssetLockInsufficientFunds { + available: 18_000_000, + required: 100_000_000, + }; + let rendered = err.to_string(); + let result = map_asset_lock_funding_result(Err(err), "shielded fund-from-asset-lock"); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds, + "must not flatten into the generic ErrorWalletOperation catch-all \ + (rendered: {rendered})" + ); + assert_ne!( + result.code as i32, + PlatformWalletFFIResultCode::ErrorWalletOperation as i32 + ); + // The number, not the name, is what Swift/Kotlin mirror by hand. + assert_eq!(result.code as i32, 29); + + // The structured available/required duffs have no out-params, so they + // only survive if the arm hands the typed error to the blanket impl + // verbatim instead of re-wrapping it behind an operation prefix. + assert_eq!( + message_of(&result), + rendered, + "structured available/required duffs must cross the boundary verbatim" + ); + assert!(message_of(&result).contains("asset lock coin selection is short")); + } + + /// The resume sibling shares the helper, so the shortfall stays typed on + /// `platform_wallet_manager_shielded_resume_fund_from_asset_lock` too — a + /// host must not have to classify the same failure two different ways + /// depending on which funding entry point it came in through. + #[test] + fn map_asset_lock_funding_result_shortfall_is_typed_on_resume_too() { + let result = map_asset_lock_funding_result( + Err(PlatformWalletError::AssetLockInsufficientFunds { + available: 0, + required: 100_000_000, + }), + "shielded resume fund-from-asset-lock", + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAssetLockInsufficientFunds + ); + } } From 15aa2caea1113127fa0c0f04e0a6d6200c16fd2e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:43:15 -0400 Subject: [PATCH 07/10] fix(swift-sdk): mirror ErrorAssetLockInsufficientFunds (29) at the Swift boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift could not decode code 29 at all: `PlatformWalletResultCode` had no raw-value case for it and `init(ffi:)` had no arm, so every asset-lock coin-selection shortfall fell through to `.errorUnknown` and surfaced as `PlatformWalletError.unknown`. Kotlin has mirrored 29 as `DashSdkError.PlatformWallet.AssetLockInsufficientFunds` since this branch's binding landed, so the boundary was typed on one host and blind on the other. Mirror it the same way the neighbouring asset-lock codes are: the raw-value case in numeric order, the C-enum arm beside its 23/24/25 siblings, the typed `PlatformWalletError.assetLockInsufficientFunds` case, and its arms in `errorDescription` and `init(code:message:)` — that switch is exhaustive over the code enum, so the new case has to be handled there. The shortfall figures ride the message unaltered. Refresh the in-file allocation note, which still described 29 as claimed outside this PR, to match the wording in `error.rs`. Extend `ErrorHandlingTests` with the mapping, a pin on the hand-mirrored raw value, and the typed-error decode, following `testShieldedInsufficientBalanceFFIResultMapping`. --- .../PlatformWallet/PlatformWalletResult.swift | 46 +++++++++++++++---- .../ErrorHandlingTests.swift | 40 ++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8528fe091dd..f59e09be13e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -76,6 +76,23 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// (Not returned by `destroy`: Rust owns the callback contexts, so a /// straggling worker is memory-safe and merely logged there.) case errorShutdownIncomplete = 27 + /// Asset-lock coin selection came up short over the *permitted* funding + /// set (dashpay/platform#4073). Nothing was built or broadcast and no + /// funding output was consumed, so the caller may refresh its preflight + /// and confirm a smaller amount. + /// + /// The structured `available` / `required` duff amounts travel in the + /// message string — `PlatformWalletFFIResult` is ABI-frozen at code + + /// message, so there are no out-params for them. + /// + /// Distinct from `errorCoreInsufficientFunds` (22), which is the atomic + /// Core-send selector rather than the asset-lock builder. What the figures + /// cover depends on the funding form: an exact-amount build pools the + /// default source list (BIP44 + BIP32 + every DashPay contact-receiving + /// account) and its shortfall describes that whole permitted union, while + /// a whole-account *drain* build names exactly one account. CoinJoin funds + /// only through the drain form. + case errorAssetLockInsufficientFunds = 29 /// A state transition could not be signed because the signer has no /// usable private key for the requested public key — restored from the /// structured signer completion code (dashpay/platform#4060 finding 7). @@ -86,13 +103,16 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// wallet does not own — or owns only watch-only, a DashPay *external* /// account holding a contact's addresses. case errorSigningKeyUnavailable = 31 - // Codes 27-33 are claimed outside this PR and must not be reused here: - // 27 errorShutdownIncomplete (dashpay/platform#4268, merged), 29 - // errorAssetLockInsufficientFunds (#4184), 31 errorSigningKeyUnavailable - // (#4183/#4259), 32 errorTransactionBuild (#4247/#4256), 33 - // errorTransactionSigning (#4256); 28 and 30 are free. The deferred-token - // trio therefore occupies the contiguous block 34-36. These raw values - // MUST match `PlatformWalletFFIResultCode` in + // Codes 27-33 are claimed outside this PR — except 29, mirrored above — + // and must not be reused here: 27 errorShutdownIncomplete + // (dashpay/platform#4268, merged), 29 errorAssetLockInsufficientFunds + // (claimed by #4184, which was closed unmerged along with its successor + // #4316; this PR salvages the code at its reserved number, so the mirror + // above matches the Rust discriminant rather than trailing it), 31 + // errorSigningKeyUnavailable (#4183/#4259), 32 errorTransactionBuild + // (#4247/#4256), 33 errorTransactionSigning (#4256); 28 and 30 are free. + // The deferred-token trio therefore occupies the contiguous block 34-36. + // These raw values MUST match `PlatformWalletFFIResultCode` in // packages/rs-platform-wallet-ffi/src/error.rs — there is no compile-time // check across the ABI. See ERROR_CODE_REGISTRY.md (#4261). /// A deferred (BIP70/BIP270) reservation token has outlived its funding @@ -216,6 +236,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockAlreadyConsumed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_FUNDING_MISMATCH: self = .errorAssetLockFundingMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INSUFFICIENT_FUNDS: + self = .errorAssetLockInsufficientFunds case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_REJECTED: self = .errorTransactionBroadcastRejected case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHUTDOWN_INCOMPLETE: @@ -322,6 +344,13 @@ public enum PlatformWalletError: LocalizedError { /// prove that the requested operation completed. case assetLockAlreadyConsumed(String) case assetLockFundingMismatch(String) + /// Asset-lock coin selection could not cover the requested funding over + /// the permitted source set. Nothing was built or broadcast and no + /// funding output was consumed — refresh the preflight and confirm a + /// smaller amount. The `available` / `required` duff figures are in the + /// message. Kotlin parity: + /// `DashSdkError.PlatformWallet.AssetLockInsufficientFunds`. + case assetLockInsufficientFunds(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -440,7 +469,7 @@ public enum PlatformWalletError: LocalizedError { .arithmeticOverflow(let m), .noSelectableInputs(let m), .coreInsufficientFunds(let m), .assetLockNotTracked(let m), .assetLockAlreadyConsumed(let m), - .assetLockFundingMismatch(let m), + .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -505,6 +534,7 @@ public enum PlatformWalletError: LocalizedError { case .errorAssetLockNotTracked: self = .assetLockNotTracked(detail) case .errorAssetLockAlreadyConsumed: self = .assetLockAlreadyConsumed(detail) case .errorAssetLockFundingMismatch: self = .assetLockFundingMismatch(detail) + case .errorAssetLockInsufficientFunds: self = .assetLockInsufficientFunds(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 799617ae93c..9b375b852d4 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -33,6 +33,46 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testAssetLockInsufficientFundsFFIResultMapping() { + // The asset-lock coin-selection shortfall (dashpay/platform#4073). + // Swift could not decode code 29 at all before this mirror existed — + // no raw-value case and no C-enum arm meant it fell through to + // .errorUnknown, so the Rust-side typed code died at the Swift + // boundary while Kotlin already branched on it. + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INSUFFICIENT_FUNDS + ), + .errorAssetLockInsufficientFunds + ) + XCTAssertNotEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_INSUFFICIENT_FUNDS + ), + .errorUnknown + ) + // The raw value is hand-mirrored ABI, not a derived ordinal. + XCTAssertEqual( + PlatformWalletResultCode.errorAssetLockInsufficientFunds.rawValue, + 29 + ) + + // The structured available/required duffs ride the message string — + // PlatformWalletFFIResult is ABI-frozen at code + message — so the + // typed error must carry them through unaltered. + let rendered = "asset lock coin selection is short: available 18000000 duffs, " + + "required 100000000 duffs" + let error = PlatformWalletError( + code: .errorAssetLockInsufficientFunds, + message: rendered + ) + guard case .assetLockInsufficientFunds(let message) = error else { + return XCTFail("expected typed assetLockInsufficientFunds error") + } + XCTAssertEqual(message, rendered) + XCTAssertEqual(error.errorDescription, rendered) + } + func testPlatformWalletNotFoundFFIResultMapping() { // Code 98 (the blanket Option→result miss) stays typed inside the // wallet-error family — the mapping Kotlin now converges on From c3e15a865f7ff87a9fbc81082061fce251141c33 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:42:25 -0400 Subject: [PATCH 08/10] docs: split ErrorAssetLockInsufficientFunds recovery guidance by funding form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code 29 is raised by BOTH asset-lock funding forms, but every doc site told the host to "confirm a smaller amount". That is only actionable for the exact-amount build: the whole-account CoinJoin drain (`shielded_fund_from_asset_lock` drain entry point) takes no amount argument at all, so there is nothing for a host to lower. A drain shortfall means the account's drainable balance is under the required minimum lock floor, and the only remedies are to add funds to that account or lower the floor. Corrects all three sites that carried the amount-reduction advice, each in its own existing format: - swift-sdk PlatformWalletResult.swift, `errorAssetLockInsufficientFunds` (29): the verbose result-code block gains a per-form recovery list. - swift-sdk PlatformWalletResult.swift, `assetLockInsufficientFunds`: the compact error-case prose gains the same split inline. - rs-platform-wallet-ffi shielded_send.rs, `map_asset_lock_funding_result`: the mapper doc now records that both funding forms land on this one code and how recovery differs. Documentation only — no behavior, no ABI, no signature changes. Co-Authored-By: Claude Opus 4.8 --- .../src/shielded_send.rs | 18 +++++++++++---- .../PlatformWallet/PlatformWalletResult.swift | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 180705f7945..85a4d6379c7 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -629,11 +629,19 @@ fn map_spend_result( /// - `AssetLockInsufficientFunds` -> `ErrorAssetLockInsufficientFunds` (29). /// Coin selection came up short over the permitted funding set, so nothing /// was built or broadcast and no funding output was consumed; the host may -/// re-run preflight and confirm a smaller amount. Without this arm the -/// shortfall flattened into the generic `ErrorWalletOperation` (6) -/// catch-all below, hiding a typed error behind the code every unclassified -/// failure already uses and forcing hosts back to substring-matching the -/// Display text. +/// re-run preflight and retry. Recovery depends on which funding form +/// raised it, and BOTH reach this one code: an exact-amount build +/// (`AssetLockFunding::FromWalletBalance`) can be re-confirmed at a smaller +/// amount, but the whole-account CoinJoin *drain* takes no amount argument +/// at all — there is nothing to lower. A drain shortfall means the account's +/// drainable balance sits under the required minimum lock floor, so the +/// host's only remedies are to add funds to that account or lower the +/// floor. Do not surface "try a smaller amount" for the drain form. +/// +/// Without this arm the shortfall flattened into the generic +/// `ErrorWalletOperation` (6) catch-all below, hiding a typed error behind +/// the code every unclassified failure already uses and forcing hosts back +/// to substring-matching the Display text. fn map_asset_lock_funding_result( result: Result<(), PlatformWalletError>, operation: &str, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index f59e09be13e..18f4f5bfb2f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -79,7 +79,18 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// Asset-lock coin selection came up short over the *permitted* funding /// set (dashpay/platform#4073). Nothing was built or broadcast and no /// funding output was consumed, so the caller may refresh its preflight - /// and confirm a smaller amount. + /// and retry. + /// + /// **Recovery depends on the funding form** — both reach this one code, + /// and only one of them can be retried at a smaller amount: + /// - *exact-amount* funding carries a caller-chosen amount, so the fix is + /// to re-run preflight and confirm a smaller one; + /// - a whole-account *drain* accepts no amount argument at all, so there + /// is nothing to lower. Its shortfall means the account's drainable + /// balance is under the required minimum lock floor: add funds to that + /// account, or lower the floor. + /// + /// Do not present "try a smaller amount" to the user on the drain path. /// /// The structured `available` / `required` duff amounts travel in the /// message string — `PlatformWalletFFIResult` is ABI-frozen at code + @@ -346,10 +357,12 @@ public enum PlatformWalletError: LocalizedError { case assetLockFundingMismatch(String) /// Asset-lock coin selection could not cover the requested funding over /// the permitted source set. Nothing was built or broadcast and no - /// funding output was consumed — refresh the preflight and confirm a - /// smaller amount. The `available` / `required` duff figures are in the - /// message. Kotlin parity: - /// `DashSdkError.PlatformWallet.AssetLockInsufficientFunds`. + /// funding output was consumed — refresh the preflight, then recover by + /// the funding form: an exact-amount build can confirm a smaller amount, + /// while a whole-account drain takes no amount to lower and instead needs + /// funds added to the drained account (or a lower minimum lock floor). + /// The `available` / `required` duff figures are in the message. Kotlin + /// parity: `DashSdkError.PlatformWallet.AssetLockInsufficientFunds`. case assetLockInsufficientFunds(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition From 1bb816dd4ea079e240cf1b562a17f740f4ff732c Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:42:36 -0400 Subject: [PATCH 09/10] test(platform-wallet): cover the DrainAll shortfall floor at build level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing regression only called `map_builder_error` directly, passing `12_345` in as the `requested` argument. That exercises the mapper but never the new `AssetLockBuildAmount::DrainAll` arm in `build_asset_lock_transaction_with_funding` that computes `requested` in the first place — so reverting that arm left the suite green. Adds an async build-level test over the CoinJoin fixture: the account's single UTXO is reserved by a first drain build whose reservation token is held for the rest of the test, leaving zero spendable candidates, and a second `DrainAll { minimum_lock_duffs: Some(12_345) }` must come back as `AssetLockInsufficientFunds { available: 0, required: 12_345 }`. Also asserts the broadcaster was never reached. Mutation-checked: replacing the branch with the built `amount_duffs` (a zero placeholder on the drain path) fails this test with `left: 0, right: 12345`, while the pre-existing direct-call test still passes — which is exactly the gap that was reported. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/asset_lock/build.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 0977809af52..a8084c1fa21 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1359,6 +1359,85 @@ mod tests { ); } + /// A whole-account drain that finds nothing selectable must report the + /// `DrainAll` minimum-lock floor as the shortfall's `required`, judged at + /// BUILD level rather than by calling `map_builder_error` directly. + /// + /// This is the branch guard for the `AssetLockBuildAmount::DrainAll` + /// arm of that `required` computation. A drain's credit output carries a + /// ZERO placeholder value (the key-wallet builder rewrites it to + /// `Σ inputs − fee`), so reverting the arm to the built `amount_duffs` + /// would advertise the meaningless pair `available: 0, required: 0` — and + /// the direct-call unit test above, which passes its own `requested` + /// argument in, would stay green through that revert. This one would not. + /// + /// The zero-spendable-candidate state is reached by holding the first + /// build's reservation token for the whole test, which keeps the fixture's + /// single CoinJoin UTXO reserved and leaves the account fully committed. + #[tokio::test] + async fn drain_shortfall_reports_the_minimum_lock_floor_as_required() { + let broadcaster = Arc::new(CountingOkBroadcaster::default()); + let (manager, signer, _persistence) = + coinjoin_funded_asset_lock_manager(Arc::clone(&broadcaster)).await; + + // Reserve the account's only UTXO. `_token` is a live binding, so the + // reservation cannot be released before the second build runs; `None` + // skips the floor check, which a build never applies anyway (it is + // judged downstream against the BUILT payload). + let (_tx, _path, _token, _accounts) = manager + .build_asset_lock_transaction_with_funding( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: None, + }, + &[AccountTypePreference::CoinJoin], + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + ) + .await + .expect("first drain builds over the funded CoinJoin account"); + + // Second drain: zero spendable candidates over a CoinJoin account, + // which is exactly the whole-account form the shielded flow uses. + let shortfall = manager + .build_asset_lock_transaction_with_funding( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: Some(12_345), + }, + &[AccountTypePreference::CoinJoin], + 0, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + 0, + &signer, + ) + .await; + + match shortfall { + Err(PlatformWalletError::AssetLockInsufficientFunds { + available, + required, + }) => { + assert_eq!( + available, 0, + "the fully-reserved CoinJoin account has nothing selectable" + ); + assert_eq!( + required, 12_345, + "a drain must report the floor threaded through DrainAll, \ + not the zero credit-output placeholder" + ); + } + other => panic!("expected typed AssetLockInsufficientFunds, got {other:?}"), + } + + assert_eq!( + broadcaster.calls(), + 0, + "a build-level shortfall must never reach the broadcaster" + ); + } + /// Builds an `AssetLockManager` over the shared BIP44-funded fixture. async fn funded_asset_lock_manager( broadcaster: Arc, From 17e9b8dccdb515c841216827f2cf44080a3dbf62 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 25 Aug 2026 22:06:24 -0700 Subject: [PATCH 10/10] fix(platform-wallet-ffi): catch drain panics before the C ABI frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CoinJoin-drain funding export ran block_on_worker directly in its extern "C" body, so a panicking funding/proving task re-panicked through the .expect("tokio worker panicked") and hit the C ABI frame — aborting the Android process before the JNI layer's support::guard (which sits on the far side of this export) could translate it into a Java exception. Especially unsafe for a whole-account drain, where the panic can strike after the asset-lock transaction was broadcast. Follow the shielded_shield_to_recipient precedent: split the export into an ordinary Rust inner function and wrap the call in catch_funding_panic, a catch_panic_to_code specialization mapping the panic to the conservative ErrorTransactionBroadcastUnconfirmed (20) contract — the outcome is genuinely ambiguous, the host must not re-drain (the funding UTXOs' reservation is still held: ReservationToken is a plain id, not a drop-release guard, so the unwind does not free it), and a tracked lock resumes through the resume entry point. The exact-amount and resume siblings from #4327 carry the same exposure; they predate this PR and are left for a follow-up. cargo test -p platform-wallet-ffi --features shielded --lib: 297 passed. clippy --all-targets -D warnings + fmt --check: clean. Co-Authored-By: Claude Opus 5 --- .../src/shielded_send.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 5f248b362fc..6e287c7933f 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -712,6 +712,36 @@ fn catch_spend_panic( ) } +/// Post-panic guidance for the asset-lock funding exports. Paired with +/// `ErrorTransactionBroadcastUnconfirmed` in [`catch_funding_panic`]. +const FUNDING_PANIC_GUIDANCE: &str = "The asset lock may or may not have been broadcast — do \ + NOT retry; the funding UTXOs stay reserved, and the reservation TTL or the next sync \ + reconciles the outcome (a tracked lock resumes via the resume entry point)."; + +/// [`catch_panic_to_code`] specialized for the asset-lock funding exports. +/// +/// The panic is mapped to +/// [`PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed`], NOT to a definitive +/// failure code: a panic can strike after the asset-lock transaction reached the wire (broadcast +/// precedes the ChainLock wait, the Platform submit, and the note bookkeeping), so the outcome +/// is genuinely ambiguous. That code's contract is exactly the conservative one this needs — the +/// host must not auto-retry, the funding UTXOs' reservation is still held (`ReservationToken` is +/// a plain id, not a drop-release guard, so the unwind does not free it and an immediate retry +/// fails at input selection instead of double-spending), and the reservation TTL or a sync +/// observing the transaction reconciles the outcome; a tracked lock is resumable through +/// `platform_wallet_manager_shielded_resume_fund_from_asset_lock`. +fn catch_funding_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed, + FUNDING_PANIC_GUIDANCE, + body, + ) +} + /// Preserve the typed funding reports that hosts branch on across the FFI /// boundary while keeping every other funding failure on the existing generic /// error path. @@ -1428,6 +1458,38 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock_c account_index: u32, recipient_raw_43: *const u8, core_signer_handle: *mut MnemonicResolverHandle, +) -> PlatformWalletFFIResult { + // The whole body runs under `catch_unwind`: a panic (most concretely `block_on_worker`'s + // `.expect` on a panicking funding/proving task) must NOT reach this `extern "C"` frame, + // where it would abort the process instead of surfacing to the host as a typed error — on + // Android that abort strikes before the JNI layer's `support::guard`, which sits on the far + // side of this export. A panic can land after the whole-account lock was broadcast, so the + // ambiguous broadcast-unconfirmed contract applies (the same code the flow's own + // ambiguous-outcome errors use); the host resumes a tracked lock via + // `platform_wallet_manager_shielded_resume_fund_from_asset_lock` rather than re-draining. + catch_funding_panic("shielded CoinJoin-drain fund-from-asset-lock", || { + shielded_fund_from_asset_lock_coinjoin_drain_inner( + handle, + wallet_id_bytes, + account_index, + recipient_raw_43, + core_signer_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_shielded_fund_from_asset_lock_coinjoin_drain`], as an +/// ordinary Rust function so a panic unwinds into [`catch_funding_panic`] instead of across +/// the C ABI. +/// +/// # Safety +/// Identical contract to the export that calls it. +unsafe fn shielded_fund_from_asset_lock_coinjoin_drain_inner( + handle: Handle, + wallet_id_bytes: *const u8, + account_index: u32, + recipient_raw_43: *const u8, + core_signer_handle: *mut MnemonicResolverHandle, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(recipient_raw_43); @@ -2167,6 +2229,35 @@ mod tests { ); } + /// A panic inside the CoinJoin-drain funding export must NOT unwind into the `extern "C"` + /// frame (that aborts the Android process before the JNI layer's own guard can translate it + /// into a Java exception). It becomes `ErrorTransactionBroadcastUnconfirmed` — the + /// conservative "may already be on the wire, do NOT retry" contract — because a panic can + /// strike after the whole-account lock was broadcast. Same panic-hook note as the spend + /// sibling above. + #[test] + fn catch_funding_panic_maps_a_panic_to_the_broadcast_unconfirmed_contract() { + let result = catch_funding_panic("shielded CoinJoin-drain fund-from-asset-lock", || { + panic!("tokio worker panicked"); + }); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed, + "a funding panic must map to the ambiguous, do-not-retry code" + ); + let message = message_of(&result); + assert!( + message.contains("shielded CoinJoin-drain fund-from-asset-lock panicked") + && message.contains("tokio worker panicked"), + "the panic payload must survive into the FFI message: {message}" + ); + assert!( + message.contains("do NOT retry"), + "the message must carry the do-not-retry guidance: {message}" + ); + } + /// `map_spend_result` pins the retry-relevant code split the three spend /// entry points depend on: /// - `ShieldedSpendUnconfirmed` → `ErrorShieldedSpendUnconfirmed` (host