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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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) })?;