Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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 @@ -90,6 +90,17 @@ sealed class DashSdkError(
class CoreInsufficientFunds(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorShieldedInsufficientBalance` (native code 41; historical FFI
* spelling). A Platform Payment account's deterministic shield input
* set cannot cover the requested amount plus input 0's retained fee
* reserve. Nothing was built or broadcast. Refresh preflight and ask
* the user to confirm a smaller amount rather than retrying unchanged.
* This is distinct from insufficient private shielded-note balance.
*/
class PlatformShieldCapacityExceeded(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

class AssetLockNotTracked(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

Expand Down Expand Up @@ -509,6 +520,7 @@ sealed class DashSdkError(
)
}.getOrNull()
} ?: PlatformWallet.Generic(code, message, cause)
41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause)
// ErrorSigningKeyUnavailable — the STRUCTURED signer
// discriminator (dashpay/platform#4060 finding 7): the typed
// completion code rides the whole Rust round-trip, no message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,25 @@ class DashSdkErrorTest {
assertFalse("Generic platform-wallet errors are not retryable", mapped.isRetryable)
}

@Test
fun platformShieldCapacityCode41MapsTyped() {
val message =
"Platform shield capacity exceeded: available 3623849220, required 3623849221"
val mapped = DashSdkError.fromNative(
DashSDKException(
DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 41,
message,
),
)

assertTrue(mapped is DashSdkError.PlatformWallet.PlatformShieldCapacityExceeded)
assertEquals(message, mapped.message)
assertFalse(
"unchanged amount must not be retried without refreshing preflight",
mapped.isRetryable,
)
}

@Test
fun signingKeyUnavailableCode31MapsTyped() {
// The STRUCTURED discriminator (dashpay/platform#4060 finding 7):
Expand Down
60 changes: 57 additions & 3 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ pub enum PlatformWalletFFIResultCode {
ErrorInvalidIdentifier = 10,
ErrorMemoryAllocation = 11,
ErrorUtf8Conversion = 12,
/// Reserved slot for the arithmetic-overflow mapping arriving via #3549 —
/// no in-tree producer today. Holding the slot here keeps language-mirror
/// enums (Swift, Kotlin) numerically aligned with the eventual producer.
/// Maps `PlatformWalletError::InputSumOverflow`: summing candidate input
/// balances overflowed `u64`. Nothing was built or broadcast; retrying the
/// same inconsistent wallet state cannot succeed.
ErrorArithmeticOverflow = 13,
/// Auto-select had no candidate inputs. Covers all three "can't-select-inputs"
/// wallet variants: `NoSpendableInputs` (account has nothing spendable),
Expand Down Expand Up @@ -359,6 +359,15 @@ pub enum PlatformWalletFFIResultCode {
/// `PlatformWalletError.contestedNameNotTradable`.
ErrorContestedNameNotTradable = 40,

/// Maps `PlatformWalletError::PlatformShieldCapacityExceeded`. A shield's
/// selected Platform-address set cannot cover the requested claim plus the
/// fee reserve retained on input 0. The transition was not built or
/// broadcast; refresh preflight capacity and ask the user to confirm the
/// new amount. The public C spelling is retained for numeric ABI and
/// language-surface compatibility; it is a Platform Payment-account
/// shortfall, not a shielded-note shortfall.
ErrorShieldedInsufficientBalance = 41,

/// The named thing does not exist.
///
/// Originally (and still mostly) the code for every `Option` returned as an
Expand Down Expand Up @@ -545,6 +554,9 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
| PlatformWalletError::OnlyDustInputs { .. } => {
PlatformWalletFFIResultCode::ErrorNoSelectableInputs
}
PlatformWalletError::InputSumOverflow => {
PlatformWalletFFIResultCode::ErrorArithmeticOverflow
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
PlatformWalletError::WalletAlreadyExists(..) => {
PlatformWalletFFIResultCode::ErrorWalletAlreadyExists
}
Expand All @@ -570,6 +582,9 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
PlatformWalletError::ShieldedNoRecordedAnchor(..) => {
PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor
}
PlatformWalletError::PlatformShieldCapacityExceeded { .. } => {
PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance
}
// The core-transaction sibling of the shielded pair above: the
// do-not-retry signal must survive the boundary as a typed code
// so hosts can distinguish it from a definitive rejection.
Expand Down Expand Up @@ -1156,6 +1171,37 @@ mod tests {
assert_eq!(msg, rendered, "Display payload must survive verbatim");
}

#[test]
fn platform_shield_capacity_maps_to_dedicated_code_without_claiming_note_shortfalls() {
let error = PlatformWalletError::PlatformShieldCapacityExceeded {
available: 3_623_849_220,
required: 3_623_849_221,
};
let rendered = error.to_string();
let result: PlatformWalletFFIResult = error.into();

assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance
);
let message = unsafe { std::ffi::CStr::from_ptr(result.message) }
.to_string_lossy()
.into_owned();
assert_eq!(message, rendered);

let note_shortfall: PlatformWalletFFIResult =
PlatformWalletError::ShieldedInsufficientBalance {
available: 100,
required: 200,
}
.into();
assert_eq!(
note_shortfall.code,
PlatformWalletFFIResultCode::ErrorUnknown,
"shielded-note selection must not claim the Platform funding code"
);
}

/// The ambiguous core-broadcast outcome keeps its typed code across the
/// boundary — flattening it to `ErrorUnknown` would erase the
/// do-not-retry signal the variant exists to carry.
Expand Down Expand Up @@ -1529,6 +1575,14 @@ mod tests {
);
}

#[test]
fn shielded_insufficient_balance_code_is_pinned_at_41() {
assert_eq!(
PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance as i32,
41
);
}

/// `MessageSigningFailed` is intentionally unmapped: its causes are
/// internal invariant breaks, which should read as a bug rather than as a
/// key-repair prompt, so it falls through to ErrorUnknown carrying the
Expand Down
155 changes: 154 additions & 1 deletion packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ use crate::error::*;
use crate::handle::*;
use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI};
use crate::runtime::{block_on_worker, runtime};
use crate::shielded_types::ShieldedShieldPreflightFFI;

/// A serialized `PlatformAddress` is exactly 21 bytes (1-byte variant tag + 20-byte hash).
const PLATFORM_ADDRESS_LEN: usize = 21;
Expand Down Expand Up @@ -595,6 +596,16 @@ fn map_spend_result(
PlatformWalletFFIResultCode::ErrorAddressNonceMismatch,
format!("{operation} failed: {e}"),
),
// The cached Platform Payment-account set no longer covers the
// requested claim plus input-0's fee reserve. Keep this distinct from
// generic wallet-operation failures so hosts can refresh preflight and
// re-confirm a smaller amount instead of retrying unchanged.
Err(e @ PlatformWalletError::PlatformShieldCapacityExceeded { .. }) => {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance,
format!("{operation} failed: {e}"),
)
}
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("{operation} failed: {e}"),
Expand Down Expand Up @@ -809,14 +820,73 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p
}
}

/// Preflight the maximum credits the cached state can shield from one Platform
/// Payment account.
///
/// Uses the exact same Rust planner as
/// [`platform_wallet_manager_shielded_shield`]: candidates are ordered by
/// lexicographic `PlatformAddress`, the leading prefix before the first address
/// whose balance is strictly greater than the shared fee reserve is excluded,
/// later addresses below the protocol version's minimum input amount are
/// omitted, and the lexicographically earliest usable set is truncated to the
/// versioned maximum address-input count. The reserve is retained only on input
/// 0. Capacity is therefore executable under the wallet's deterministic policy,
/// not globally optimized over later balances. No DAPI request, signing, proof
/// construction, or broadcast is performed.
///
/// A normal no-capacity result writes all numeric fields (including the total
/// account balance and zero usable/max capacity), returns `Success`, and carries
/// an advisory reason in the result message. Bad handles, missing wallets or
/// accounts, and arithmetic overflow remain FFI errors with `out` untouched.
///
/// # Safety
/// - `wallet_id_bytes` must point to 32 readable bytes.
/// - `out` must point to a writable `ShieldedShieldPreflightFFI`.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_preflight(
handle: Handle,
wallet_id_bytes: *const u8,
payment_account: u32,
out: *mut ShieldedShieldPreflightFFI,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id_bytes);
check_ptr!(out);

let mut wallet_id = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32);
let wallet = match resolve_wallet(handle, &wallet_id) {
Ok(wallet) => wallet,
Err(result) => return result,
};

let result =
block_on_worker(async move { wallet.shielded_shield_preflight(payment_account).await });
match result {
Ok(preflight) => {
*out = ShieldedShieldPreflightFFI {
can_shield: preflight.can_shield,
account_balance_credits: preflight.account_balance_credits,
usable_balance_credits: preflight.usable_balance_credits,
fee_reserve_credits: preflight.fee_reserve_credits,
max_shieldable_credits: preflight.max_shieldable_credits,
};
match preflight.reason {
Some(reason) => PlatformWalletFFIResult::success_with_message(reason),
None => PlatformWalletFFIResult::ok(),
}
}
Err(error) => error.into(),
}
}

/// Shield: spend credits from a Platform Payment account into
/// the bound shielded sub-wallet's pool.
///
/// `shielded_account` selects which ZIP-32 Orchard account on
/// the bound shielded sub-wallet receives the new note.
/// `payment_account` selects which Platform Payment account on
/// the transparent side funds the shield (auto-selects input
/// addresses in ascending derivation order until the cumulative
/// addresses in lexicographic Platform-address order until the cumulative
/// balance covers `amount + fee buffer`).
///
/// `signer_address_handle` is a `*mut SignerHandle` produced by
Expand Down Expand Up @@ -1434,6 +1504,31 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_seed_pool_notes(
}
}

/// Resolve a wallet without requiring shielded coordinator configuration.
///
/// Cached capacity preflight needs only the wallet's Platform Payment account;
/// requiring a bound/configured shielded coordinator here would turn an
/// otherwise valid balance query into a structural setup error.
fn resolve_wallet(
handle: Handle,
wallet_id: &[u8; 32],
) -> Result<std::sync::Arc<platform_wallet::PlatformWallet>, PlatformWalletFFIResult> {
let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| {
runtime().block_on(manager.get_wallet(wallet_id))
});
match option {
Some(Some(wallet)) => Ok(wallet),
Some(None) => Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("wallet not found: {}", hex::encode(wallet_id)),
)),
None => Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidHandle,
format!("invalid manager handle: {handle}"),
)),
}
}

/// Resolve both the wallet `Arc` and the network-scoped shielded
/// coordinator `Arc` for the given manager handle. Shielded
/// spend operations need the coordinator's shared store, so this
Expand Down Expand Up @@ -1575,6 +1670,31 @@ mod tests {
}
}

#[test]
fn shield_preflight_rejects_null_abi_pointers() {
unsafe {
let mut out = ShieldedShieldPreflightFFI::default();
let missing_wallet_id =
platform_wallet_manager_shielded_shield_preflight(0, std::ptr::null(), 0, &mut out);
assert_eq!(
missing_wallet_id.code,
PlatformWalletFFIResultCode::ErrorNullPointer
);

let wallet_id = [0u8; 32];
let missing_out = platform_wallet_manager_shielded_shield_preflight(
0,
wallet_id.as_ptr(),
0,
std::ptr::null_mut(),
);
assert_eq!(
missing_out.code,
PlatformWalletFFIResultCode::ErrorNullPointer
);
}
}

/// Read the Rust-owned message out of an FFI result for assertions.
fn message_of(result: &PlatformWalletFFIResult) -> String {
assert!(
Expand Down Expand Up @@ -1686,4 +1806,37 @@ mod tests {
"expected nonce must render exactly: {msg}"
);
}

#[test]
fn map_spend_result_maps_shield_capacity_race_to_dedicated_code() {
let shield_result = map_spend_result(
Err(PlatformWalletError::PlatformShieldCapacityExceeded {
available: 3_623_849_220,
required: 3_623_849_221,
}),
"shielded shield",
);

assert_eq!(
shield_result.code,
PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance
);
let message = message_of(&shield_result);
assert!(message.contains("available 3623849220"));
assert!(message.contains("required 3623849221"));

let transfer_result = map_spend_result(
Err(PlatformWalletError::ShieldedInsufficientBalance {
available: 3_623_849_220,
required: 3_623_849_221,
}),
"shielded transfer",
);

assert_eq!(
transfer_result.code,
PlatformWalletFFIResultCode::ErrorWalletOperation,
"the dedicated code is a Platform-to-shielded contract only"
);
}
}
17 changes: 17 additions & 0 deletions packages/rs-platform-wallet-ffi/src/shielded_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@

use std::os::raw::c_char;

/// Cached Platform-to-shielded capacity for one payment account.
///
/// The Rust wallet planner computes every field from the same lexicographic
/// candidate set later used by the shield execution path, including the
/// versioned address-input cap. A normal no-capacity state is represented by
/// `can_shield == false`, not by an FFI error; the Success-coded result message
/// carries the optional explanation.
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ShieldedShieldPreflightFFI {
pub can_shield: bool,
pub account_balance_credits: u64,
pub usable_balance_credits: u64,
pub fee_reserve_credits: u64,
pub max_shieldable_credits: u64,
}

/// Per-wallet outcome from a completed shielded sync pass.
///
/// Mirrors
Expand Down
7 changes: 7 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,13 @@ pub enum PlatformWalletError {
#[error("Insufficient shielded balance: available {available}, required {required}")]
ShieldedInsufficientBalance { available: u64, required: u64 },

/// A Platform Payment-account shield cannot be represented from the
/// wallet's deterministic address-input set at the requested amount.
/// Distinct from [`ShieldedInsufficientBalance`](Self::ShieldedInsufficientBalance),
/// which refers exclusively to private note selection.
#[error("Platform shield capacity exceeded: available {available}, required {required}")]
PlatformShieldCapacityExceeded { available: u64, required: u64 },

#[error("Shielded build error: {0}")]
ShieldedBuildError(String),

Expand Down
Loading
Loading