Skip to content

fix(wallet): address audit findings from PR #645 review#648

Merged
lklimek merged 10 commits into
v1.0-devfrom
fix/audit-findings-645
Feb 24, 2026
Merged

fix(wallet): address audit findings from PR #645 review#648
lklimek merged 10 commits into
v1.0-devfrom
fix/audit-findings-645

Conversation

@lklimek
Copy link
Copy Markdown
Contributor

@lklimek lklimek commented Feb 24, 2026

Summary

Addresses 6 findings from the code quality and security audit of PR #645:

  • Checked arithmetic in UTXO selection(amount + fee) as i64 replaced with checked_add + i64::try_from to prevent overflow/wrap on extreme values
  • Single-UTXO fee consistency — hardcoded let fee = 3_000; value - fee (panic-prone) replaced with calculate_asset_lock_fee(), matching the multi-input path with dust check and overflow safety
  • UTXO selection retry — new select_utxos_with_fee_retry() retries selection once with the computed fee when the initial 3000-duff estimate was insufficient, preventing false "insufficient funds" errors on wallets with many small UTXOs
  • Write-lock invariant documentedselect_unspent_utxos_for doc comment now warns that the caller must hold the wallet write lock continuously through remove_selected_utxos
  • Wallet lock error propagation.unwrap() on wallet write locks in register_identity and top_up_identity replaced with .map_err(|e| e.to_string())? for consistent error handling (prevents cascading panics on lock poisoning)
  • Database API visibilityDatabase::shared_connection() narrowed from pub to pub(crate)

Test plan

  • All existing unit tests pass (42 passed, 0 failed)
  • Clippy clean (-D warnings)
  • cargo +nightly fmt applied
  • Manual test scenarios from parent PR remain applicable

🤖 Generated with Claude Code

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • Bug Fixes

    • Improved wallet lock error handling to avoid panics and crashes.
    • Enhanced asset-lock fee calculation with a retry when initial fee is underestimated.
    • Added overflow-safe checks in transaction value computations.
  • New Features

    • Wallet now persists UTXO removals and address balance updates to the database for more reliable balances and consistency.

lklimek and others added 8 commits February 24, 2026 10:25
…ount

Replace hardcoded 3000 duff fee with dynamic fee calculation that accounts
for actual number of inputs. Estimates tx size using standard component
sizes (P2PKH input ~148B, output ~34B, header ~10B, payload ~60B) and
uses max(3000, estimated_size) to always meet the min relay fee.

Properly handles fee shortfall when allow_take_fee_from_amount is set,
and returns clear error messages for insufficient funds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d signed

Previously, `asset_lock_transaction_from_private_key` called
`take_unspent_utxos_for` which immediately removed selected UTXOs from
`Wallet.utxos`. Since fee recalculation and signing happen afterward,
any failure at those steps (fee shortfall, missing private key, change
address derivation error) would permanently drop UTXOs — especially
dangerous in SPV mode where there is no Core RPC reload fallback.

Fix:
- Add `select_unspent_utxos_for` (`&self`, non-mutating) that performs
  the same UTXO selection logic without removing anything.
- Add `remove_selected_utxos` (`&mut self`) for explicit removal.
- Refactor `take_unspent_utxos_for` to delegate to these two methods
  (no behavior change for existing callers).
- In `asset_lock_transaction_from_private_key`, use
  `select_unspent_utxos_for` for selection and only call
  `remove_selected_utxos` after the full tx is built and signed.

Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com>
# Conflicts:
#	docs/ai-design/2026-02-24-asset-lock-fee-fix/manual-test-scenarios.md
#	src/model/wallet/asset_lock_transaction.rs
…ce recalc into remove_selected_utxos

Previously, every backend task caller had to manually: (1) remove UTXOs
from the in-memory map, (2) drop them from the database, and (3)
recalculate affected address balances.  This was error-prone — the
payment transaction builders were missing the balance recalculation
entirely.

Now `remove_selected_utxos` accepts an optional `&AppContext` and
handles all three steps atomically.  The redundant cleanup blocks in
5 backend task callers are removed.  Also applies the safe
select-then-commit UTXO pattern to `build_standard_payment_transaction`
and `build_multi_recipient_payment_transaction`, fixing the same
UTXO-loss-on-signing-failure bug that was previously fixed only for
asset lock transactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add checked arithmetic to UTXO selection (amount + fee overflow safety)
- Replace hardcoded fee in single-UTXO path with calculate_asset_lock_fee
- Add UTXO selection retry when real fee exceeds initial estimate
- Document write-lock invariant on select_unspent_utxos_for
- Replace .unwrap() with .map_err() on wallet write locks
- Restrict Database::shared_connection visibility to pub(crate)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Base automatically changed from copilot/sub-pr-636 to v1.0-dev February 24, 2026 14:38
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

Wallet UTXO selection and asset-lock fee logic were refactored to add a retry path and DB-backed UTXO removal with balance persistence; wallet lock error handling was changed to propagate errors; a database helper's visibility was narrowed to crate scope; overflow-safe arithmetic and test updates were added.

Changes

Cohort / File(s) Summary
Identity task error handling
src/backend_task/identity/register_identity.rs, src/backend_task/identity/top_up_identity.rs
Replaced unwrap() on wallet.write() with `map_err(
Database visibility
src/database/mod.rs
Changed shared_connection() from pub fn to #[allow(dead_code)] pub(crate) fn, narrowing visibility to the crate.
Asset-lock fee selection & retry
src/model/wallet/asset_lock_transaction.rs
Added select_utxos_with_fee_retry() to iteratively select UTXOs and recompute fees when initial estimate is too low; refactored multi-input and single-input asset-lock flows to use this centralized selection and fee result; updated UTXO removal calls to pass DB and network context.
UTXO selection, removal, and DB integration
src/model/wallet/utxos.rs, src/model/wallet/mod.rs
Reworked UTXO selection to use overflow-safe checked_add() for amount+fee; removed in-memory-only wrapper; changed remove_selected_utxos signature to require db: &Database and network: Network and persist removals via db.drop_utxo; added recalculate_affected_address_balances_with_db helper and updated internal call sites and tests to use DB-backed balance recalculation.
Tests & helpers
src/model/wallet/mod.rs (tests)
Added test helpers (test_wallet_with_utxo, register_test_address) and migrated tests from in-memory selection/removal to DB-backed flows and the new select_unspent_utxos_for API.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client
participant Wallet
participant FeeCalc as "FeeCalculator"
participant DB
participant Network
Client->>Wallet: request asset-lock (amount, key/utxo)
Wallet->>FeeCalc: select_utxos_with_fee_retry(amount)
FeeCalc-->>Wallet: (selected_utxos, fee_result) or error
alt fee_estimate_too_low
FeeCalc->>FeeCalc: recompute higher estimate
FeeCalc-->>Wallet: updated (utxos, fee_result)
end
Wallet->>DB: remove_selected_utxos(db, network, selected_utxos)
DB-->>Wallet: persist drop_utxo, update balances
Wallet-->>Client: constructed transaction (signed) / error

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibbled at locks and numbers bright,
Retry hops fixed a fee's lost sight,
DB burrows now keep UTXOs sound,
No more unwraps that make me bound,
Hop on — the wallet's safe and light!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(wallet): address audit findings from PR #645 review' accurately captures the main purpose of the PR, which is addressing audit findings from a previous review, and follows conventional commit formatting.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/audit-findings-645

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/model/wallet/asset_lock_transaction.rs`:
- Around line 113-161: The retry currently in select_utxos_with_fee_retry only
loops twice and can still return Err when a second selection increases
num_inputs and raises the fee again; change the logic to iterate until the fee
estimate stabilizes or no new inputs are selected: repeatedly call
select_unspent_utxos_for with the current fee_estimate, compute
total_input_value and num_inputs from utxos, call calculate_asset_lock_fee, and
if it Err and the new estimated tx size (estimate_tx_size(num_inputs, 2) as u64)
produces a larger fee_estimate update fee_estimate and continue; stop and return
Ok((utxos, fee_result)) when calculate_asset_lock_fee returns Ok, or return Err
only when fee_estimate no longer changes and selection fails or no additional
UTXOs are added; keep references to MIN_ASSET_LOCK_FEE,
select_unspent_utxos_for, estimate_tx_size, calculate_asset_lock_fee, and utxos
to locate the changes.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 21fd7e3 and 88c7cb8.

📒 Files selected for processing (5)
  • src/backend_task/identity/register_identity.rs
  • src/backend_task/identity/top_up_identity.rs
  • src/database/mod.rs
  • src/model/wallet/asset_lock_transaction.rs
  • src/model/wallet/utxos.rs

Comment thread src/model/wallet/asset_lock_transaction.rs
…Network directly

Replace Option<&AppContext> with concrete dependencies (&Database, Network),
removing the need for take_unspent_utxos_for. Extract balance recalculation
into a private helper reused by both remove_selected_utxos and the existing
AppContext-based wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/model/wallet/asset_lock_transaction.rs (1)

113-161: Retry loop is bounded at 2 iterations — edge case with many tiny UTXOs.

The retry only fires once (when fee_estimate == MIN_ASSET_LOCK_FEE). If the second selection pulls in additional UTXOs whose marginal cost increases the fee beyond the second estimate, the function will return Err even though more UTXOs might be available. In practice this requires a wallet composed almost entirely of dust-adjacent UTXOs, so the risk is low, but be aware of this limitation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/wallet/asset_lock_transaction.rs` around lines 113 - 161, The retry
is currently limited to two iterations in select_utxos_with_fee_retry which can
fail when successive selections add more inputs and raise the fee again; change
the loop to iterate until the computed fee_result stabilizes or no new inputs
are added (or until a higher reasonable max iteration count, e.g., 10) instead
of hardcoding 2 tries: call select_unspent_utxos_for and
calculate_asset_lock_fee repeatedly, track the last fee_estimate and last
total_input_value/num_inputs (or a loop counter) and break/return once
calculate_asset_lock_fee succeeds or when the estimate no longer increases (or
max attempts reached), using the same MIN_ASSET_LOCK_FEE, estimate_tx_size,
select_unspent_utxos_for, and calculate_asset_lock_fee symbols to locate and
modify the logic.
🧹 Nitpick comments (1)
src/model/wallet/utxos.rs (1)

43-43: tx_out.value as i64 cast is safe for Dash values but lacks a guard.

While Dash's total supply ensures tx_out.value fits in i64 in practice, this cast sits right next to the new overflow-safe checked_add/try_from code, creating an inconsistency in defensive posture. Consider whether a TryFrom guard here would be worth the consistency.

♻️ Optional: add overflow guard for consistency
-                required -= tx_out.value as i64;
+                required -= i64::try_from(tx_out.value).ok()?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/model/wallet/utxos.rs` at line 43, The direct cast "tx_out.value as i64"
next to other checked conversions is inconsistent and could silently overflow;
replace the cast with a fallible conversion (use i64::try_from(tx_out.value) or
the project's TryFrom helper) and propagate or handle the Err consistently (same
error type/path used by the surrounding checked_add/try_from logic) before
subtracting from required, i.e., obtain a validated i64 amount from tx_out.value
and then do required -= amount.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/model/wallet/asset_lock_transaction.rs`:
- Around line 113-161: The retry is currently limited to two iterations in
select_utxos_with_fee_retry which can fail when successive selections add more
inputs and raise the fee again; change the loop to iterate until the computed
fee_result stabilizes or no new inputs are added (or until a higher reasonable
max iteration count, e.g., 10) instead of hardcoding 2 tries: call
select_unspent_utxos_for and calculate_asset_lock_fee repeatedly, track the last
fee_estimate and last total_input_value/num_inputs (or a loop counter) and
break/return once calculate_asset_lock_fee succeeds or when the estimate no
longer increases (or max attempts reached), using the same MIN_ASSET_LOCK_FEE,
estimate_tx_size, select_unspent_utxos_for, and calculate_asset_lock_fee symbols
to locate and modify the logic.

---

Nitpick comments:
In `@src/model/wallet/utxos.rs`:
- Line 43: The direct cast "tx_out.value as i64" next to other checked
conversions is inconsistent and could silently overflow; replace the cast with a
fallible conversion (use i64::try_from(tx_out.value) or the project's TryFrom
helper) and propagate or handle the Err consistently (same error type/path used
by the surrounding checked_add/try_from logic) before subtracting from required,
i.e., obtain a validated i64 amount from tx_out.value and then do required -=
amount.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 88c7cb8 and 0b7d7ab.

📒 Files selected for processing (3)
  • src/model/wallet/asset_lock_transaction.rs
  • src/model/wallet/mod.rs
  • src/model/wallet/utxos.rs

@lklimek lklimek merged commit 79e1479 into v1.0-dev Feb 24, 2026
5 checks passed
@lklimek lklimek deleted the fix/audit-findings-645 branch February 24, 2026 15:56
@thepastaclaw
Copy link
Copy Markdown
Collaborator

Good catch. The bounded for _ in 0..2 loop can theoretically leave funds on the table for wallets with many tiny UTXOs near dust value, where adding inputs to cover the higher fee estimate raises the fee again.

In practice this is an extreme edge case — each additional P2PKH input only adds ~148 duffs to the fee, so it would only matter when UTXOs are barely above dust — but the suggested loop-until-stabilization approach is technically more correct and still guaranteed to terminate (finite UTXO pool + monotonically increasing fees).

Since this PR is already merged, I'll open a follow-up issue to track this improvement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants