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/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..0977809af5 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` @@ -1429,8 +1557,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 +1669,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