Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
83 changes: 82 additions & 1 deletion packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -605,6 +623,16 @@ impl From<PlatformWalletError> 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.
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Shortfall documentation contradicts pooled asset-lock funding

These new public docs say available always describes one selected account and that asset-lock funding never unions accounts. The exact-amount path calls build_asset_lock_transaction_with_funding with ASSET_LOCK_FUNDING_SOURCES, which explicitly pools the BIP44 and BIP32 accounts plus every DashPay contact-receiving account; a builder shortfall can therefore describe that permitted union. Only DrainAccountBalance, including the CoinJoin migration, selects exactly one account. Update this documentation and the matching new FFI and Kotlin comments so hosts do not present an exact-amount pooled shortfall as a single-account failure.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18ce5d8 — docs corrected to the funding truth verified in code: available spans the pooled BIP44+BIP32+contact-receiving union on exact-amount asset-lock builds (ASSET_LOCK_FUNDING_SOURCES = SEND_FUNDING_SOURCES), and is single-account only on drains (CoinJoin sole form; never pooled). Same correction in the FFI code-29 docs, DashSdkError KDoc, and the test narrative; also repaired two pre-existing broken intra-doc links in the same block (cargo doc warnings 123 -> 121).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in 18ce5d8Shortfall documentation contradicts pooled asset-lock funding no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

///
/// 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
bfoss765 marked this conversation as resolved.
Outdated
#[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),

Expand Down
Loading
Loading