Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7e764d0
feat(ui): unify shield screens into single ShieldScreen with address …
lklimek Mar 26, 2026
7b757ce
Merge branch 'v1.0-dev' into feat/unified-shield-screen
lklimek Mar 26, 2026
40e2614
feat: fetch epoch info on connection sync to populate protocol version
lklimek Mar 26, 2026
ded7612
fix(ui): invalidate cached tx indices when transaction list changes
lklimek Mar 26, 2026
b1aeb41
fix(shielded): add fee headroom to note selection (#795 item 1)
lklimek Mar 26, 2026
c4d3a56
fix(shielded): reduce fee headroom from 5 DASH to 0.1 DASH
lklimek Mar 26, 2026
a255582
fix: resolve clippy and fmt CI failures
lklimek Mar 27, 2026
74e2114
refactor: address code review findings from PR #801
lklimek Mar 27, 2026
5551c0f
refactor(send): replace inverted if-let guard with idiomatic !matches!
lklimek Mar 27, 2026
43485fb
fix(review): address PR #801 review findings
lklimek Mar 27, 2026
893f770
docs(shielded): consolidate duplicate doc comment summary lines
lklimek Mar 27, 2026
bf17628
refactor(shielded): extract restrict_utxos closure into a named helpe…
lklimek Mar 27, 2026
8c8b077
feat(shielded): replace hardcoded fee headroom with dynamic estimation
lklimek Mar 27, 2026
4d76762
refactor(wallet): thread source_address filter through UTXO selection
lklimek Mar 27, 2026
ab7da8a
refactor(shielded): replace fee headroom estimation with iterative no…
lklimek Mar 27, 2026
1e9e4fe
fix(shielded): harden shield screen with graceful locks, theme colors…
lklimek Mar 27, 2026
7d677bf
fix(ui): use theme-aware colors in shield screen for dark mode support
lklimek Mar 27, 2026
9a6f832
refactor(fees): centralize shield-from-core fee estimation in fee_est…
lklimek Mar 27, 2026
b22f370
docs: note fee estimation centralization rule in CLAUDE.md
lklimek Mar 27, 2026
063623e
fix(shielded): address review findings — fee guard, L1 fee, lock safe…
lklimek Mar 27, 2026
d6af76b
fix(shielded): apply fee multiplier, freeze batch inputs, preserve co…
lklimek Mar 27, 2026
46a274a
fix(shielded): skip stale-nonce items in parallel batch instead of ca…
lklimek Mar 27, 2026
67344ca
docs: add typed error matching rule to CLAUDE.md
lklimek Mar 27, 2026
fcc5ffb
fix(ui): restore vibrant progress bar fill colors
lklimek Mar 27, 2026
c03ccf1
fix(shielded): don't cascade-fail on any nonce mismatch in parallel b…
lklimek Mar 27, 2026
66227ac
fix(shielded): handle nonce error via Protocol path, not just Broadca…
lklimek Mar 27, 2026
c5b9a91
fix(ui): use shared fee estimation and theme colors in send screen
lklimek Mar 27, 2026
d7b5dbc
fix(shielded): require block confirmation between parallel batch broa…
lklimek Mar 27, 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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ scripts/safe-cargo.sh +nightly fmt --all
* When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`.
* Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites.
* **i18n-ready strings**: All user-facing strings (labels, messages, tooltips, errors) must be simple, complete sentences. Avoid concatenating fragments, positional assumptions, or grammar that breaks in other languages. Each string should be extractable as a single translation unit with named placeholders for dynamic values and no logic in the text itself. Current code uses standard Rust format specifiers (`{name}`, `{max}`). When i18n extraction happens later, these will become Fluent-style placeholders (`{ $name }`, `{ $max }`).
* **Never parse error strings** to extract information. Always use the typed error chain (downcast, match on variants, access structured fields). If no typed variant exists for the information you need, define a new `TaskError` variant or extend the existing error type. String parsing is fragile, breaks on message changes, and bypasses the type system.

### Error messages

Expand Down Expand Up @@ -96,7 +97,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow
- **app.rs** - `AppState`: owns all screens, polls task results each frame, dispatches to visible screen
- **ui/** - Screens and reusable components (`ui/components/`)
- **backend_task/** - Async business logic, one submodule per domain (identity, wallet, contract, etc.)
- **model/** - Data types (amounts, fees, settings, wallet/identity models)
- **model/** - Data types (amounts, fees, settings, wallet/identity models). **All fee estimation logic must be centralized in `model/fee_estimation.rs`** — both platform state transition fees and shielded fee calculations. Never inline fee math in UI or backend task code.
- **database/** - SQLite persistence (rusqlite), one module per domain
- **context/** - `AppContext`: network config, SDK client, database, wallets, settings cache (split into submodules: `identity_db.rs`, `wallet_lifecycle.rs`, `settings_db.rs`, etc.)
- **spv/** - Simplified Payment Verification for light wallet support
Expand Down
11 changes: 10 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,16 @@ impl AppState {
self.connection_banner_handle = Some(handle);
}
OverallConnectionState::Synced => {
// No banner needed for fully synced state
// No banner needed for fully synced state.
// Fetch epoch info on first sync to populate protocol version
// and fee multiplier — needed for feature gating (e.g., shielded
// tab requires protocol version >= 12).
if state_changed {
let task = BackendTask::PlatformInfo(
crate::backend_task::platform_info::PlatformInfoTaskRequestType::CurrentEpochInfo,
);
self.handle_backend_task(task);
Comment thread
lklimek marked this conversation as resolved.
}
}
}
self.previous_connection_state = Some(current_state);
Expand Down
2 changes: 2 additions & 0 deletions src/backend_task/core/create_asset_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ impl AppContext {
amount_duffs,
allow_take_fee_from_amount,
identity_index,
None,
)
.map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?
};
Expand Down Expand Up @@ -78,6 +79,7 @@ impl AppContext {
allow_take_fee_from_amount,
identity_index,
top_up_index,
None,
)
.map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?
};
Expand Down
2 changes: 2 additions & 0 deletions src/backend_task/identity/register_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ impl AppContext {
amount,
true,
identity_index,
None,
) {
Ok(transaction) => transaction,
Err(e) => {
Expand All @@ -119,6 +120,7 @@ impl AppContext {
amount,
true,
identity_index,
None,
)
.map_err(|e| TaskError::AssetLockTransactionBuildFailed {
detail: e,
Expand Down
2 changes: 2 additions & 0 deletions src/backend_task/identity/top_up_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ impl AppContext {
true,
identity_index,
top_up_index,
None,
) {
Ok(transaction) => transaction,
Err(e) => {
Expand All @@ -129,6 +130,7 @@ impl AppContext {
true,
identity_index,
top_up_index,
None,
)
.map_err(|e| TaskError::AssetLockTransactionBuildFailed {
detail: e,
Expand Down
139 changes: 109 additions & 30 deletions src/backend_task/shielded/bundle.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::backend_task::error::{TaskError, shielded_broadcast_error, shielded_build_error};
use crate::context::AppContext;
use crate::context::shielded::get_proving_key;
use crate::model::fee_estimation::format_credits_as_dash;
use crate::model::fee_estimation::{format_credits_as_dash, shielded_fee_for_actions};
use crate::model::wallet::WalletSeedHash;
use crate::model::wallet::shielded::ShieldedWalletState;
use dash_sdk::dpp::address_funds::{
Expand All @@ -13,6 +13,7 @@ use dash_sdk::dpp::shielded::builder::{
OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition,
build_shielded_withdrawal_transition, build_unshield_transition,
};
use dash_sdk::dpp::version::PlatformVersion;
use dash_sdk::dpp::withdrawal::Pooling;
use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey};
use dash_sdk::platform::transition::broadcast::BroadcastStateTransition;
Expand Down Expand Up @@ -246,13 +247,18 @@ pub async fn shielded_transfer(
let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes)
.map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?;

let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?;
let change_amount = total_input_value.saturating_sub(amount);
let (spendable_notes, total_input_value, exact_fee) =
select_notes_with_fee(shielded_state, amount, 2, sdk.version())?;
let change_amount = total_input_value
.saturating_sub(amount)
.saturating_sub(exact_fee);

tracing::info!(
"Shielded transfer: sending {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)",
"Shielded transfer: sending {} ({} credits), fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)",
format_credits_as_dash(amount),
amount,
format_credits_as_dash(exact_fee),
exact_fee,
spendable_notes.len(),
format_credits_as_dash(total_input_value),
total_input_value,
Expand Down Expand Up @@ -302,7 +308,7 @@ pub async fn shielded_transfer(
anchor,
&prover,
[0u8; 36],
None,
Some(exact_fee),
sdk.version(),
)
.map_err(|e| shielded_build_error(e.to_string()))?;
Expand Down Expand Up @@ -339,13 +345,18 @@ pub async fn unshield_credits(
key: get_proving_key(),
};

let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?;
let change_amount = total_input_value.saturating_sub(amount);
let (spendable_notes, total_input_value, exact_fee) =
select_notes_with_fee(shielded_state, amount, 1, sdk.version())?;
let change_amount = total_input_value
.saturating_sub(amount)
.saturating_sub(exact_fee);

tracing::info!(
"Unshield credits: {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)",
"Unshield credits: {} ({} credits), fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)",
format_credits_as_dash(amount),
amount,
format_credits_as_dash(exact_fee),
exact_fee,
spendable_notes.len(),
format_credits_as_dash(total_input_value),
total_input_value,
Expand Down Expand Up @@ -395,7 +406,7 @@ pub async fn unshield_credits(
anchor,
&prover,
[0u8; 36],
None,
Some(exact_fee),
sdk.version(),
)
.map_err(|e| shielded_build_error(e.to_string()))?;
Expand Down Expand Up @@ -427,6 +438,7 @@ pub async fn shield_from_asset_lock(
seed_hash: &WalletSeedHash,
shielded_state: &ShieldedWalletState,
amount_duffs: u64,
source_address: Option<&Address>,
) -> Result<u64, TaskError> {
use dash_sdk::dashcore_rpc::RpcApi;
use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF;
Expand All @@ -437,11 +449,9 @@ pub async fn shield_from_asset_lock(

let proving_key = crate::context::shielded::get_proving_key();

let platform_fee_credits = app_context
let (platform_fee_duffs, _l1_fee_duffs) = app_context
.fee_estimator()
.min_fees()
.address_funding_asset_lock_cost;
let platform_fee_duffs = (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100;
.estimate_shield_from_core_fees_duffs();
let asset_lock_duffs = amount_duffs.saturating_add(platform_fee_duffs);

// Step 1: Create the asset lock transaction
Expand All @@ -458,29 +468,33 @@ pub async fn shield_from_asset_lock(
.write()
.map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?;

match wallet.generic_asset_lock_transaction(
let first_result = wallet.generic_asset_lock_transaction(
app_context.as_ref(),
app_context.network,
asset_lock_duffs,
false,
) {
Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos),
source_address,
);

let (tx, private_key, address, _change, utxos) = match first_result {
Ok(ok) => ok,
Err(_) => {
wallet
.reload_utxos(app_context.as_ref())
.map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?;

let (tx, private_key, address, _change, utxos) = wallet
wallet
.generic_asset_lock_transaction(
app_context.as_ref(),
app_context.network,
asset_lock_duffs,
false,
source_address,
)
.map_err(shielded_build_error)?;
(tx, private_key, address, utxos)
.map_err(shielded_build_error)?
}
}
};

(tx, private_key, address, utxos)
};

let tx_id = asset_lock_transaction.txid();
Expand All @@ -498,7 +512,9 @@ pub async fn shield_from_asset_lock(
app_context
.core_client
.read()
.expect("Core client lock was poisoned")
.map_err(|_| TaskError::LockPoisoned {
resource: "core_client",
})?
.send_raw_transaction(&asset_lock_transaction)?;
Comment thread
lklimek marked this conversation as resolved.

// Step 4: Remove used UTXOs from wallet
Expand Down Expand Up @@ -639,13 +655,18 @@ pub async fn shielded_withdrawal(

let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes());

let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?;
let change_amount = total_input_value.saturating_sub(amount);
let (spendable_notes, total_input_value, exact_fee) =
select_notes_with_fee(shielded_state, amount, 1, sdk.version())?;
let change_amount = total_input_value
.saturating_sub(amount)
.saturating_sub(exact_fee);

tracing::info!(
"Shielded withdrawal: {} ({} credits) to core address, spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)",
"Shielded withdrawal: {} ({} credits) to core address, fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)",
format_credits_as_dash(amount),
amount,
format_credits_as_dash(exact_fee),
exact_fee,
spendable_notes.len(),
format_credits_as_dash(total_input_value),
total_input_value,
Expand Down Expand Up @@ -697,7 +718,7 @@ pub async fn shielded_withdrawal(
anchor,
&prover,
[0u8; 36],
None,
Some(exact_fee),
sdk.version(),
)
.map_err(|e| shielded_build_error(e.to_string()))?;
Expand All @@ -718,22 +739,80 @@ pub async fn shielded_withdrawal(
Ok(spent_nullifiers)
}

/// Select notes to cover the requested amount using a greedy algorithm.
/// Select notes sufficient to cover `amount` plus the exact shielded fee.
///
/// Uses an iterative approach:
/// 1. Estimate fee for `min_actions` (the builder's minimum action count)
/// 2. Select notes for amount + estimated fee
/// 3. Compute exact fee from actual note count
/// 4. If insufficient, re-select with exact fee; repeat (converges in 2-3 iterations)
///
/// Returns the selected notes, total input value, and the exact fee.
fn select_notes_with_fee<'a>(
shielded_state: &'a ShieldedWalletState,
amount: u64,
min_actions: usize,
platform_version: &PlatformVersion,
) -> Result<
(
Vec<&'a crate::model::wallet::shielded::ShieldedNote>,
u64,
u64,
),
TaskError,
> {
let mut fee_estimate = shielded_fee_for_actions(min_actions, platform_version);

for _ in 0..5 {
let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?;
let num_actions = notes.len().max(min_actions);
let exact_fee = shielded_fee_for_actions(num_actions, platform_version);

if total >= amount.saturating_add(exact_fee) {
return Ok((notes, total, exact_fee));
}

fee_estimate = exact_fee;
}

// Final attempt with last computed fee
let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?;
let num_actions = notes.len().max(min_actions);
let exact_fee = shielded_fee_for_actions(num_actions, platform_version);
if total < amount.saturating_add(exact_fee) {
return Err(TaskError::ShieldedInsufficientBalance {
available: total,
required: amount.saturating_add(exact_fee),
});
}
Ok((notes, total, exact_fee))
Comment thread
lklimek marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Select unspent notes to cover `amount + fee_headroom` using a greedy algorithm.
///
/// The `fee_headroom` ensures selected inputs cover both the send amount
/// and the transition fee. Without it, sending the full balance fails
/// because the DPP builder adds fees on top of the selected amount.
///
/// The `required` amount in error messages includes the fee so the user
/// understands the total cost.
fn select_notes_for_amount(
shielded_state: &ShieldedWalletState,
amount: u64,
fee_headroom: u64,
) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), TaskError> {
let unspent: Vec<_> = shielded_state.unspent_notes();

if unspent.is_empty() {
return Err(TaskError::ShieldedNoUnspentNotes);
}

let required = amount.saturating_add(fee_headroom);
let total_available: u64 = unspent.iter().map(|n| n.value).sum();
if total_available < amount {
if total_available < required {
return Err(TaskError::ShieldedInsufficientBalance {
available: total_available,
required: amount,
required,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

Expand All @@ -746,7 +825,7 @@ fn select_notes_for_amount(
for note in sorted {
selected.push(note);
accumulated += note.value;
if accumulated >= amount {
if accumulated >= required {
break;
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/backend_task/shielded/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub enum ShieldedTask {
ShieldFromAssetLock {
seed_hash: WalletSeedHash,
amount_duffs: u64,
/// If set, restrict UTXO selection to this Core address.
source_address: Option<Address>,
},

/// Withdraw from the shielded pool directly to a core L1 address (Type 19)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ impl AppContext {
self.network,
asset_lock_amount,
allow_take_fee_from_amount,
None,
) {
Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos),
Err(e) => {
Expand All @@ -73,6 +74,7 @@ impl AppContext {
self.network,
asset_lock_amount,
allow_take_fee_from_amount,
None,
)
.map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?;
(tx, private_key, address, utxos)
Expand Down
Loading
Loading