Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ 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, 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, so the two stay in agreement.
While that check is running, the amount field shows "Checking the
available amount..."; if the check fails, a "Retry" button appears in its
place.

- **Wallet rename consistency**: renaming a wallet no longer overwrites other
saved wallet details when metadata cannot be read. Overlapping renames and
wallet removals also keep displayed aliases and deleted-wallet metadata
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 read-only 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
41 changes: 41 additions & 0 deletions src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@ 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,
},
/// One HD-wallet or imported-key alias update.
WalletRename(WalletTask),
/// A known backend task that needs no finer UI correlation.
Expand Down Expand Up @@ -387,6 +392,16 @@ impl BackendTaskContext {
}
}

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

pub(crate) fn wallet_rename_task(&self) -> Option<&WalletTask> {
match self.operation() {
Self::WalletRename(task) => Some(task),
Expand Down Expand Up @@ -434,6 +449,13 @@ impl From<&BackendTask> for BackendTaskContext {
seed_hash: *seed_hash,
}
}
BackendTask::WalletTask(WalletTask::GetAssetLockMaxAmount {
seed_hash,
snapshot_generation,
}) => Self::AssetLockMaxAmount {
seed_hash: *seed_hash,
snapshot_generation: *snapshot_generation,
},
BackendTask::WalletTask(
task @ (WalletTask::RenameHdWallet { .. }
| WalletTask::RenameSingleKeyWallet { .. }),
Expand Down Expand Up @@ -601,6 +623,12 @@ 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,
amount_duffs: u64,
},
/// Platform address balances fetched from Platform
PlatformAddressBalances {
seed_hash: WalletSeedHash,
Expand Down Expand Up @@ -1243,6 +1271,19 @@ impl AppContext {
.list_tracked_asset_locks(&seed_hash)
.await
.map(|locks| BackendTaskSuccessResult::TrackedAssetLocks { seed_hash, locks }),
WalletTask::GetAssetLockMaxAmount {
seed_hash,
snapshot_generation,
} => backend
.asset_lock_max_amount(&seed_hash)
.await
.map(
|amount_duffs| BackendTaskSuccessResult::AssetLockMaxAmount {
seed_hash,
snapshot_generation,
amount_duffs,
},
),
WalletTask::FetchPlatformAddressBalances { seed_hash } => {
self.fetch_platform_address_balances(seed_hash).await
}
Expand Down
6 changes: 6 additions & 0 deletions src/backend_task/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ 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,
},
/// 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
7 changes: 7 additions & 0 deletions src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,13 @@ 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()
}

/// 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
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
27 changes: 27 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,19 @@ 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 let Some(seed_hash) = seed_hash {
let snapshot_generation = self.app_context.snapshot_generation(&seed_hash);
if let Some(task) = self
.asset_lock_balance
.ensure_requested(seed_hash, snapshot_generation)
{
action |= AppAction::BackendTask(task);
}
}

if step == WalletFundedScreenStep::WaitingOnFunds {
ui.heading(format!(
Expand All @@ -171,6 +184,20 @@ 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 {
return action;
};
if self.asset_lock_balance.is_failed(&seed_hash) {
ui.label("The available amount could not be checked.");
if ui.button("Retry available amount check").clicked() {
self.asset_lock_balance.invalidate_one(&seed_hash);
}
return action;
}
if self.asset_lock_balance.get(&seed_hash).is_none() {
ui.label("Checking the available amount…");
return action;
}
self.render_funding_amount_input(ui);

let has_valid_amount = self
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 RUST-005 · MEDIUM — the request/status block and the validate/report block are copy-pasted across three screens
(add_new_identity_screen/by_using_unused_balance.rs:102-126; top_up_identity_screen/by_using_unused_balance.rs:93-117; plus a third variant in send_screen.rs)

Two verbatim duplications and a third partial one.

Block A — 25 lines, byte-identical apart from the selected_wallet/wallet field name: resolve seed hash → read snapshot_generation → ensure_requested → failed branch with label and "Retry available amount check" button → not-loaded branch with "Checking the available amount…". send_screen carries a third, structurally different variant of the same logic split across request_asset_lock_max_amount and render_asset_lock_balance_status. That structural difference is not incidental — it is precisely why the three screens behave differently when the ceiling isn't loaded (RUST-001). The copies have already diverged once.

Block B — ~28 lines, verbatim: validate_asset_lock_amount(amount, 0, max_amount) → match error { Overflow => max_amount, ExceedsMaximum { maximum_amount_duffs } => maximum_amount_duffs } → MessageBanner::set_global(..) with the identical "You can transfer up to {} right now." string, duplicated between add_new_identity_screen/mod.rs:1061-1085 and top_up_identity_screen/mod.rs:481-505, with a Result<_, String> variant of the same shape in send_screen.rs:1675-1686.

Note the Overflow => max_amount fallback is hand-written at all four sites — and is unreachable at three of them, since they pass reserve_duffs = 0 and amount.checked_add(0) cannot overflow. The variant has no test coverage. An error type whose every consumer must write the same collapse is the wrong shape.

Fix:

  • Extract Block A into one helper on AssetLockBalanceCache (or funding_common) taking &mut Ui and returning a small Ready(u64) | Pending | Failed enum — each screen then decides layout only. This is also the cheapest way to land RUST-001's stale-while-revalidate change once rather than three times.
  • Collapse Block B by having the model yield the user-facing maximum directly — e.g. make AssetLockAmountError always carry maximum_amount_duffs — so no caller re-derives it. Then delete the Overflow arm, or cover it with a test.

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.

Agreed this is duplicated across the three funding screens, and your diagnosis that it's directly why the three screens drifted in behavior is exactly right. Deferring the full extraction as a larger refactor rather than doing it under this PR's scope, but the round of fixes just pushed did bring the three screens' patterns back into alignment with each other, which reduces (doesn't eliminate) the risk you're flagging.

🤖 Co-authored by Claudius the Magnificent AI Agent

Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ impl AddNewIdentityScreen {
}
};

let spendable_balance: u64 = self
.app_context
.snapshot_balance(&wallet.seed_hash())
.spendable();
let seed_hash = wallet.seed_hash();
let spendable_balance = self
.asset_lock_balance
.get(&seed_hash)
.unwrap_or_else(|| self.app_context.snapshot_balance(&seed_hash).spendable());

let dash_balance = spendable_balance as f64 * 1e-8; // Convert to DASH units

Expand All @@ -45,10 +46,7 @@ impl AddNewIdentityScreen {
fn render_insufficient_wallet_balance_banner(&self, ui: &mut egui::Ui) -> Option<AppAction> {
let selected_wallet = self.selected_wallet.as_ref()?;
let spendable_duffs = match selected_wallet.read() {
Ok(w) => self
.app_context
.snapshot_balance(&w.seed_hash())
.spendable(),
Ok(w) => self.asset_lock_balance.get(&w.seed_hash())?,
Err(_) => {
ui.label("Wallet is busy. Try again in a moment.");
return Some(AppAction::None);
Expand Down Expand Up @@ -102,8 +100,35 @@ impl AddNewIdentityScreen {
self.show_wallet_balance(ui);
ui.add_space(5.0);

let seed_hash = self
.selected_wallet
.as_ref()
.and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash()));
let Some(seed_hash) = seed_hash else {
return action;
};
let snapshot_generation = self.app_context.snapshot_generation(&seed_hash);
if let Some(task) = self
.asset_lock_balance
.ensure_requested(seed_hash, snapshot_generation)
{
action |= AppAction::BackendTask(task);
}
if self.asset_lock_balance.is_failed(&seed_hash) {
ui.label("The available amount could not be checked.");
if ui.button("Retry available amount check").clicked() {
self.asset_lock_balance.invalidate_one(&seed_hash);
}
return action;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
if self.asset_lock_balance.get(&seed_hash).is_none() {
ui.label("Checking the available amount…");
return action;
}

if let Some(insufficient_action) = self.render_insufficient_wallet_balance_banner(ui) {
return insufficient_action;
action |= insufficient_action;
return action;
}

self.render_funding_amount_input(ui);
Expand Down
Loading
Loading