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 29986168bc..ea700dd7ea 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 @@ -115,6 +115,33 @@ sealed class DashSdkError( class AssetLockFundingMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorAssetLockInsufficientFunds` (native code 29). Asset-lock coin + * 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 + * 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 (single-account + * drain), and by + * [shieldedFundFromAssetLock][org.dashfoundation.dashsdk.wallet.PlatformWalletManager.shieldedFundFromAssetLock] + * when the pooled funding sources 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 @@ -486,10 +513,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 d85f538d31..dd886361ac 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 07d143c1c5..1a2463b64e 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 @@ -1481,6 +1481,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 37169cc094..94cc410285 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,33 @@ 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) — 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, + "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 444573c5db..c4021e1e69 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -217,6 +217,24 @@ 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. 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. + 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 @@ -249,7 +267,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 @@ -621,6 +642,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. @@ -1092,6 +1123,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-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 21d98fac4d..85a4d6379c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -613,10 +613,35 @@ 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 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, @@ -624,6 +649,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 +1879,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 +1908,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 + ); + } } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8349eb1df2..cf02e8c95d 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -282,6 +282,49 @@ 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). + /// + /// 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 + /// 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 `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. + /// + /// [`CoreInsufficientFunds`]: Self::CoreInsufficientFunds + /// [`CorePooledInsufficientFunds`]: Self::CorePooledInsufficientFunds + #[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 ca1b8feb71..a8084c1fa2 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::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -215,10 +217,19 @@ impl AssetLockManager { ) .await .map_err(|e| { - PlatformWalletError::AssetLockTransaction(format!( - "Asset lock builder failed: {}", - 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. @@ -1024,6 +1035,61 @@ 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 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. +/// +/// 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}; @@ -1054,6 +1120,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` @@ -1231,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, @@ -1429,8 +1636,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:?}" ); @@ -1535,8 +1748,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 f8dc82f050..3efc1378ca 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 8528fe091d..18f4f5bfb2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -76,6 +76,34 @@ 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 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 + + /// 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 +114,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 +247,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 +355,15 @@ 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, 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 /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -440,7 +482,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 +547,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 799617ae93..9b375b852d 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