Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3493f71
fix(wallets): use selector ceiling for asset-lock Max
lklimek Jul 27, 2026
807bad5
docs: changelog entry for #929 Max fix
lklimek Jul 27, 2026
6bed93f
fix(wallets): use the real height watermark in the asset-lock ceiling…
lklimek Jul 27, 2026
c2abaa4
refactor(wallets): replace the asset-lock ceiling binary search with …
lklimek Jul 27, 2026
fcd4c0f
Merge branch 'v1.0-dev' into fix/929-max-shield-snapshot-balance
lklimek Jul 27, 2026
40938d1
fix(wallets): close asset-lock ceiling gaps found by CI review
lklimek Jul 27, 2026
e96f20f
fix(ui): migrate remaining asset-lock funding flows to the builder ce…
lklimek Jul 27, 2026
aa53542
docs: correct #929 changelog entry to match the fixed scope
lklimek Jul 27, 2026
e8d2c26
Merge remote-tracking branch 'origin/fix/929-max-shield-snapshot-bala…
lklimek Jul 27, 2026
8d6f1c9
Merge branch 'v1.0-dev' into fix/929-max-shield-snapshot-balance
lklimek Jul 28, 2026
7ec1505
docs(wallet_backend): document accepted risk from upstream rust-dashc…
lklimek Jul 28, 2026
d38b544
fix(wallet_backend): shrink asset-lock probe's write-lock scope, docu…
lklimek Jul 28, 2026
d02fd53
fix(ui): harden AssetLockBalanceCache against generation regression a…
lklimek Jul 28, 2026
64cd544
fix(ui): reserve fees for Identity sends, bound ReceiveDeposit by its…
lklimek Jul 28, 2026
48e457f
test(send_screen): seed the asset-lock cache in the Core->Platform ki…
lklimek Jul 28, 2026
0d0e762
docs: correct the Max-fix changelog entry and add its user story
lklimek Jul 28, 2026
c127b38
fix(ui): reserve the identity fee at dispatch-time validation, not ju…
lklimek Jul 29, 2026
45c5a0b
fix(ui): debounce the asset-lock probe on no-op events, stop dropping…
lklimek Jul 29, 2026
4d7a3e3
fix(ui): bound ReceiveDeposit dispatch by its address, fix the deboun…
lklimek Jul 29, 2026
85e9f46
fix(wallet_backend): bound the asset-lock probe's worst-case wait wit…
lklimek Jul 29, 2026
d652f8f
docs: note the asset-lock probe's timeout in the Max-fix changelog entry
lklimek Jul 29, 2026
33622bb
fix(wallet_backend): remove BnB timeout workaround, close stale-valid…
lklimek Jul 30, 2026
1bc0d96
chore(deps): bump dashpay/platform pin to include the BnB feasibility…
lklimek Jul 30, 2026
03ec120
docs: drop implementation-detail sentence from the Max-fix changelog …
lklimek Jul 30, 2026
8f5c140
fix(wallet_backend): correlate asset-lock probes by request id, disti…
lklimek Jul 30, 2026
4e826bc
fix(wallet): close asset-lock quote freshness gaps from round-5's loc…
lklimek Jul 30, 2026
792e50e
Merge remote-tracking branch 'origin/v1.0-dev' into fix/929-max-shiel…
lklimek Jul 30, 2026
bb5234a
Merge remote-tracking branch 'origin/v1.0-dev' into fix/929-max-shiel…
lklimek Jul 30, 2026
4a61f3e
fix(wallet): close round-8 blocking gaps in the asset-lock Max probe …
lklimek Jul 30, 2026
9a5105d
fix(wallet): keep asset-lock Max usable on dust wallets and stop the …
lklimek Jul 30, 2026
c4a985f
docs(wallet): correct stale reserved-outpoint claim in observation do…
lklimek Jul 30, 2026
8ea190d
Merge remote-tracking branch 'origin/v1.0-dev' into fix/929-max-shiel…
lklimek Jul 31, 2026
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **"Max" now matches what your Core wallet can actually send**: pressing
"Max" when shielding DASH, funding a Platform address through the Simple
builder-driven form, sending directly to an identity, or funding an identity
(creating or topping up, from your wallet balance or a received deposit)
could suggest an amount larger than the wallet could actually send, so the
transaction was rejected no matter how you adjusted it. Max and the amount
check now ask the wallet directly what it can send instead of estimating from
an on-screen balance, and both reserve room for the fee. The two derive from
the same wallet answer: if your spendable funds change after that answer,
Max steps back to "Checking the available amount…" and the amount check
waits for a fresh answer instead of accepting an outdated ceiling. The
Advanced manual-input Platform-address flow remains governed by
the Core inputs the user selects rather than this builder ceiling. Funding
from a received deposit is also now capped by what actually arrived at that
deposit address, never by unrelated funds elsewhere in the wallet. While the
check is running, the amount field shows "Checking the available amount…";
if it fails, "The available amount could not be checked." appears with a
"Retry available amount check" button, and you can still switch to a
different funding method at any point.

- **A saved voting key can now actually sign**: a voting key held on an
identity's own record — rather than on a separate voting identity — was saved
and shown as being on this device, but nothing could use it. Signing looked for
Expand Down
84 changes: 42 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ eframe = { version = "0.35.0", features = ["persistence", "wgpu"] }
base64 = "0.22.1"
# TODO: GHSA-7gcf-g7xr-8hxj (serde_with <3.21.0) is unfixable from here — the 2.x pin lives in
# dashcore-rpc-json (dashpay/rust-dashcore, rpc-json/Cargo.toml). Re-check when these pins move.
dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration", features = [
dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64", features = [
"core_key_wallet",
"core_key_wallet_manager",
"core_bincode",
Expand All @@ -30,12 +30,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", branch = "feat/platfor
"core_spv",
"shielded",
] }
rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration" }
platform-wallet = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration", features = [
rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64" }
platform-wallet = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64", features = [
"serde",
"shielded",
] }
platform-wallet-storage = { git = "https://github.com/dashpay/platform", branch = "feat/platform-wallet-storage-rehydration", features = [
platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "a18bd1586858ef680124e150caad6a7dc21d0b64", features = [
"shielded",
] }
zip32 = "0.2.0"
Expand Down
10 changes: 10 additions & 0 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,16 @@ As a developer, I want to transfer credits privately from my shielded pool to an
- Spending is paused until the shielded balance is verified, and the button is disabled with a clear reason while verification is in progress.
- Available only on Platform protocol v12 or later when Expert view or Developer view is selected.

### SND-017: "Max" is verified against what the wallet can actually build [Implemented]
**Persona:** Alex, Priya, Jordan

As a user, I want "Max" (and the amount check behind it) to reflect what my Core wallet can genuinely send when shielding DASH, funding a Platform address through the Simple builder-driven form, sending directly to an identity, or funding an identity from my wallet balance, so that the amount I'm offered — or type in myself — is not rejected for exceeding what the wallet can build.

- "Max" and amount validation both ask the wallet directly for its actual sendable ceiling, reserving the relevant operation's fee, instead of estimating from the on-screen balance — both derive from the same builder quote, including for an amount typed in by hand rather than produced by the Max button. When the wallet's spendable inputs change after a quote, Max steps back to "Checking the available amount…" and validation waits for a fresh quote rather than accepting a stale ceiling.
- While the check is running, the amount field shows "Checking the available amount…"; if it fails, "The available amount could not be checked." appears with a "Retry available amount check" button.
- Funding from a received deposit is capped by what actually arrived at that specific deposit address, never by unrelated funds elsewhere in the wallet — see SND-014 (Core-to-Core Max), which uses a separate, simpler network-fee-only calculation not covered by this story.
- The Advanced manual-input Platform-address flow validates against the Core inputs selected by the user and is not covered by this builder-ceiling story.

---

## Asset Locks (ALK)
Expand Down
11 changes: 11 additions & 0 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,17 @@ pub enum TaskError {
>,
},

/// The non-broadcasting asset-lock builder probe could not determine a safe Max.
#[error(
"The wallet's available amount could not be checked. Wait a moment and try again."
)]
AssetLockBalanceQueryFailed {
#[source]
source: Box<
dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::BuilderError,
>,
},

/// The payment would need more individual unspent outputs than fit in a
/// single standard transaction.
#[error(
Expand Down
65 changes: 65 additions & 0 deletions src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,12 @@ pub enum BackendTaskContext {
ScheduledVoteSweep { network: Network },
/// Receive-address derivation for one wallet's deposit flow.
GenerateReceiveAddress { seed_hash: WalletSeedHash },
/// Live asset-lock builder ceiling query for one wallet.
AssetLockMaxAmount {
seed_hash: WalletSeedHash,
snapshot_generation: u64,
request_id: u64,
},
/// One HD-wallet or imported-key alias update.
WalletRename(WalletTask),
/// The detection pass for one identity's legacy-recovery offer.
Expand Down Expand Up @@ -391,6 +397,17 @@ impl BackendTaskContext {
}
}

pub(crate) fn asset_lock_max_amount_request(&self) -> Option<(WalletSeedHash, u64, u64)> {
match self.operation() {
Self::AssetLockMaxAmount {
seed_hash,
snapshot_generation,
request_id,
} => Some((*seed_hash, *snapshot_generation, *request_id)),
_ => None,
}
}

pub(crate) fn wallet_rename_task(&self) -> Option<&WalletTask> {
match self.operation() {
Self::WalletRename(task) => Some(task),
Expand Down Expand Up @@ -458,6 +475,15 @@ impl From<&BackendTask> for BackendTaskContext {
seed_hash: *seed_hash,
}
}
BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount {
seed_hash,
snapshot_generation,
request_id,
}) => Self::AssetLockMaxAmount {
seed_hash: *seed_hash,
snapshot_generation: *snapshot_generation,
request_id: *request_id,
},
BackendTask::WalletTask(
task @ (WalletTask::RenameHdWallet { .. }
| WalletTask::RenameSingleKeyWallet { .. }),
Expand Down Expand Up @@ -625,6 +651,15 @@ pub enum BackendTaskSuccessResult {
seed_hash: WalletSeedHash,
locks: Vec<platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock>,
},
/// Largest asset-lock credit output the live upstream builder accepts.
AssetLockMaxAmount {
seed_hash: WalletSeedHash,
snapshot_generation: u64,
request_id: u64,
amount_duffs: u64,
observed_inputs: crate::wallet_backend::AssetLockInputState,
is_partial: bool,
},
/// Platform address balances fetched from Platform
PlatformAddressBalances {
seed_hash: WalletSeedHash,
Expand Down Expand Up @@ -1291,6 +1326,21 @@ impl AppContext {
.list_tracked_asset_locks(&seed_hash)
.await
.map(|locks| BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks }),
WalletTask::GetAssetLockMaxAmount {
seed_hash,
snapshot_generation,
request_id,
} => backend
.asset_lock_max_amount(&seed_hash)
.await
.map(|quote| BackendTaskSuccessResult::AssetLockMaxAmount {
seed_hash,
snapshot_generation,
request_id,
amount_duffs: quote.amount_duffs,
observed_inputs: quote.observed_inputs,
is_partial: quote.is_partial,
}),
WalletTask::FetchPlatformAddressBalances { seed_hash } => {
self.fetch_platform_address_balances(seed_hash).await
}
Expand Down Expand Up @@ -1716,6 +1766,21 @@ mod tests {
);
}

#[test]
fn backend_task_context_preserves_asset_lock_request_identity() {
let seed_hash = [0x39; 32];
let task = BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount {
seed_hash,
snapshot_generation: 7,
request_id: 42,
});

assert_eq!(
BackendTaskContext::from(&task).asset_lock_max_amount_request(),
Some((seed_hash, 7, 42))
);
}

#[test]
fn backend_task_context_identifies_network_database_clear() {
let task = BackendTask::SystemTask(SystemTask::ClearNetworkDatabase);
Expand Down
7 changes: 7 additions & 0 deletions src/backend_task/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ pub enum WalletTask {
ListTrackedAssetLocks {
seed_hash: WalletSeedHash,
},
/// Dry-run the live upstream asset-lock builder to obtain the largest
/// credit output its current UTXO set can fund.
GetAssetLockMaxAmount {
seed_hash: WalletSeedHash,
snapshot_generation: u64,
request_id: u64,
},
/// Fund Platform addresses from a tracked asset lock identified by its
/// credit-output outpoint. The proof and credit-output key are recovered
/// from the upstream `AssetLockManager` and the wallet's funding
Expand Down
17 changes: 17 additions & 0 deletions src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,23 @@ impl AppContext {
.unwrap_or_default()
}

/// Monotonic generation of the event-pushed snapshot for one wallet.
pub fn snapshot_generation(&self, seed_hash: &WalletSeedHash) -> u64 {
self.wallet_backend()
.map(|wb| wb.wallet_snapshot_generation(seed_hash))
.unwrap_or_default()
}

/// Snapshot generation, exact builder-input composition, and its revision.
pub fn asset_lock_probe_snapshot(
&self,
seed_hash: &WalletSeedHash,
) -> (u64, crate::wallet_backend::AssetLockInputState, u64) {
self.wallet_backend()
.map(|wallet_backend| wallet_backend.asset_lock_probe_snapshot(seed_hash))
.unwrap_or_default()
}

/// Number of UTXOs in the wallet's display snapshot. Used to estimate the
/// Core (L1) transaction fee for a "Max" send, which spends every UTXO.
///
Expand Down
18 changes: 16 additions & 2 deletions src/context/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,34 @@ use crate::wallet_backend::DetKv;
/// to exercise the settings read-modify-write and feature-gate paths.
pub(crate) fn test_app_context(dir: &Path) -> Arc<AppContext> {
let app_kv = AppContext::open_app_kv(dir).expect("open app k/v");
test_app_context_with_kv(dir, app_kv)
test_app_context_with_kv_and_network(dir, app_kv, Network::Testnet)
}

/// Build a throwaway [`AppContext`] for a specific network.
pub(crate) fn test_app_context_for_network(dir: &Path, network: Network) -> Arc<AppContext> {
let app_kv = AppContext::open_app_kv(dir).expect("open app k/v");
test_app_context_with_kv_and_network(dir, app_kv, network)
}

/// [`test_app_context`] with a caller-supplied app k/v store, so a test can
/// inject a fault-injecting backing store (see `kv_test_support::FailingKv`).
pub(crate) fn test_app_context_with_kv(dir: &Path, app_kv: Arc<DetKv>) -> Arc<AppContext> {
test_app_context_with_kv_and_network(dir, app_kv, Network::Testnet)
}

fn test_app_context_with_kv_and_network(
dir: &Path,
app_kv: Arc<DetKv>,
network: Network,
) -> Arc<AppContext> {
crate::app_dir::ensure_env_file(dir);
let db = Arc::new(crate::database::Database::new(dir.join("data.db")).expect("db"));
db.create_tables(true).expect("create tables");
db.set_default_version().expect("set version");
let secret_store = AppContext::open_secret_store(dir).expect("open secret store");
AppContext::new(
dir.to_path_buf(),
Network::Testnet,
network,
db,
Default::default(),
Default::default(),
Expand Down
51 changes: 51 additions & 0 deletions src/model/asset_lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Stateless asset-lock amount calculations shared by UI flows.

/// Why an asset-lock amount is outside the builder-derived ceiling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetLockAmountError {
/// Adding the operation-specific reserve exceeded the amount range.
Overflow,
/// The requested amount plus its reserve exceeds the builder ceiling.
ExceedsMaximum { maximum_amount_duffs: u64 },
}

/// Largest user-entered amount after reserving operation-specific fees.
///
/// Both arguments must use the same unit (duffs or Platform credits).
pub fn asset_lock_user_max_amount(builder_max: u64, reserve: u64) -> u64 {
builder_max.saturating_sub(reserve)
}

/// Validate a user-entered amount against the live builder-derived ceiling.
pub fn validate_asset_lock_amount(
amount_duffs: u64,
reserve_duffs: u64,
builder_max_duffs: u64,
) -> Result<(), AssetLockAmountError> {
let required_duffs = amount_duffs
.checked_add(reserve_duffs)
.ok_or(AssetLockAmountError::Overflow)?;
if required_duffs > builder_max_duffs {
return Err(AssetLockAmountError::ExceedsMaximum {
maximum_amount_duffs: asset_lock_user_max_amount(builder_max_duffs, reserve_duffs),
});
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::{AssetLockAmountError, asset_lock_user_max_amount, validate_asset_lock_amount};

#[test]
fn builder_ceiling_validation_reserves_operation_fee() {
assert_eq!(asset_lock_user_max_amount(10_000, 1_000), 9_000);
assert_eq!(validate_asset_lock_amount(9_000, 1_000, 10_000), Ok(()));
assert_eq!(
validate_asset_lock_amount(9_001, 1_000, 10_000),
Err(AssetLockAmountError::ExceedsMaximum {
maximum_amount_duffs: 9_000,
})
);
}
}
1 change: 1 addition & 0 deletions src/model/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod address;
pub mod amount;
pub mod asset_lock;
pub mod contested_name;
pub mod dashpay;
pub mod dashpay_derivation;
Expand Down
36 changes: 36 additions & 0 deletions src/ui/identities/add_new_identity_screen/by_receive_deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ impl AddNewIdentityScreen {
let mut action = AppAction::None;
self.reconcile_funding_deposit();
let step = *self.step.read_recover();
let seed_hash = self
.selected_wallet
.as_ref()
.and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash()));

if step == WalletFundedScreenStep::WaitingOnFunds {
ui.heading(format!(
Expand All @@ -171,6 +175,38 @@ impl AddNewIdentityScreen {
// committed to the pending transaction, so the input's max recomputes to
// 0 and would show a stale "exceeds maximum" error over a succeeding op.
if step == WalletFundedScreenStep::FundsReceived {
let Some(seed_hash) = seed_hash else {
if ui.button("Choose a different funding method").clicked() {
self.reset_to_choose_funding();
}
return action;
};
let failed = self.asset_lock_balance.is_failed(&seed_hash);
let loading = self.asset_lock_quote_is_loading(&seed_hash);
if failed || loading {
ui.label(if failed {
"The available amount could not be checked."
} else {
"Checking the available amount…"
});
if self.asset_lock_balance.should_offer_retry(&seed_hash)
&& ui.button("Retry available amount check").clicked()
{
self.asset_lock_balance.invalidate_one(&seed_hash);
}
if ui.button("Choose a different funding method").clicked() {
self.reset_to_choose_funding();
}
return action;
}
if self.asset_lock_balance.should_offer_retry(&seed_hash) {
ui.label(
"The amount shown is safe but may be lower than your full available amount.",
);
if ui.button("Retry available amount check").clicked() {
self.asset_lock_balance.invalidate_one(&seed_hash);
}
}
self.render_funding_amount_input(ui);

let has_valid_amount = self
Expand Down
Loading
Loading