Skip to content

feat: watch-only import for wsh(sortedmulti) descriptors#702

Open
DhruvArvindSingh wants to merge 4 commits into
bitcoinppl:masterfrom
DhruvArvindSingh:feat/multisig-watch-only-491-clean
Open

feat: watch-only import for wsh(sortedmulti) descriptors#702
DhruvArvindSingh wants to merge 4 commits into
bitcoinppl:masterfrom
DhruvArvindSingh:feat/multisig-watch-only-491-clean

Conversation

@DhruvArvindSingh
Copy link
Copy Markdown

@DhruvArvindSingh DhruvArvindSingh commented Apr 23, 2026

Summary

Implements watch-only multisig wallet import via wsh(sortedmulti) descriptors, as scoped in issue #491.

Testing

  • Wallet::new_from_multisig_descriptor : creates and persists the BDK wallet
  • RustWalletManager::try_new_from_multisig_descriptor : UniFFI-exported entry point, mirrors the existing try_new_from_xpub pattern

The parse_multisig_descriptors helper accepts two formats:

  • Two newline-separated descriptors (external then internal), as exported by most hardware coordinators (Sparrow, Nunchuk, etc.)
  • A single BIP-389 multipath descriptor using <0;1> notation

I have't tested it, as their is no UI wired up yet

Platform Coverage

  • Tested on iOS device
  • Tested on Android device
  • Tested on iOS simulator
  • Tested on Android simulator
  • Not tested

Checklist

Summary by CodeRabbit

  • New Features

    • Added support for creating watch-only wallets directly from multisig descriptors
    • Implemented automatic duplicate wallet detection and prevention to avoid redundant configurations
    • Extended wallet initialization APIs with new descriptor-based constructor methods
  • Tests

    • Added comprehensive unit tests validating multisig descriptor parsing and wallet creation flows

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Warning

Rate limit exceeded

@DhruvArvindSingh has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 55 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 20 minutes and 55 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9c72f92a-07ea-4f58-a033-a1c45ec6007a

📥 Commits

Reviewing files that changed from the base of the PR and between 4a1b73a and 5352c96.

📒 Files selected for processing (1)
  • rust/src/wallet.rs
📝 Walkthrough

Walkthrough

Adds UniFFI-backed constructors to create multisig watch-only wallets from descriptor strings, with Kotlin bindings, Rust Wallet and WalletManager constructors, descriptor parsing/normalization, persistence of public descriptors, and actor initialization.

Changes

Cohort / File(s) Summary
Kotlin FFI Bindings
android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt
Added UniFFI external function declarations, checksum exports, and Kotlin wrapper methods tryNewFromMultisigDescriptor (RustWalletManager) and newFromMultisigDescriptor (Wallet) that convert descriptor to RustBuffer and propagate FFI errors.
Rust Wallet Manager
rust/src/manager/wallet_manager.rs
Added try_new_from_multisig_descriptor UniFFI constructor: initializes persisted multisig watch-only wallet, sets up channels, spawns wallet (and optional scanner) actors, creates label manager, and sets initial_load_state = Loading.
Rust Wallet Core & Parsing
rust/src/wallet.rs
Added try_new_persisted_multisig_watch_only, new_from_multisig_descriptor UniFFI export, and parse_multisig_descriptors helper that strictly supports wsh(sortedmulti(...)) multipath or two-line external/internal inputs; prevents duplicates, persists public descriptors and metadata, triggers backup notifications, and includes unit tests.

Sequence Diagram(s)

sequenceDiagram
    participant Kotlin as Kotlin Client
    participant UniFFI as UniFFI Bridge
    participant Manager as RustWalletManager
    participant Wallet as Wallet
    participant BDK as BDK Store
    participant Actors as Actor System

    Kotlin->>UniFFI: tryNewFromMultisigDescriptor(descriptor)
    UniFFI->>Manager: uniffi_fn_constructor_rustwalletmanager_try_new_from_multisig_descriptor(descriptor)
    Manager->>Wallet: try_new_persisted_multisig_watch_only(descriptor)
    Wallet->>Wallet: parse_multisig_descriptors(descriptor)
    Wallet->>BDK: create persisted wallet (Descriptors, empty KeyMaps)
    Wallet->>BDK: save public descriptors to keychain
    Wallet->>Wallet: persist WalletMetadata (WatchOnly)
    Wallet-->>Manager: return initialized Wallet
    Manager->>Actors: spawn wallet actor
    Manager->>Actors: spawn scanner actor (conditional)
    Manager-->>UniFFI: return RustWalletManager (Loading)
    UniFFI-->>Kotlin: return manager instance
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • praveenperera

🐰 I parsed the lines, I hopped with glee,

From Kotlin to Rust my whiskers see,
Descriptors sorted, watch-only born,
Actors wake at early morn,
A multisig carrot for you and me 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes a summary of changes and testing details, but has notable issues: contains typos ('I have't' and 'their is'), lacks clarity on what testing was actually performed (stating no UI testing without explaining why unit tests pass), and doesn't adequately explain the significance of the feature or its relation to issue #491. Fix typos and add detail on unit test execution, especially for parse_multisig_descriptors and descriptor parsing logic. Clarify what testing was completed and why device testing is deferred.
Docstring Coverage ⚠️ Warning Docstring coverage is 38.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: implementing watch-only multisig wallet import for wsh(sortedmulti) descriptors, which is the core feature added across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 23, 2026

Greptile Summary

This PR implements watch-only multisig wallet import via wsh(sortedmulti) descriptors, adding Wallet::new_from_multisig_descriptor and RustWalletManager::try_new_from_multisig_descriptor with UniFFI bindings for both iOS and Android. The parse_multisig_descriptors helper handles both two-line and BIP-389 <0;1> multipath formats.

  • Missing duplicate-wallet guard: try_new_persisted_multisig_watch_only does not check whether the same descriptor has already been imported — every other hardware-import path (try_new_persisted_from_pubport, try_new_persisted_from_tap_signer) performs a fingerprint-level dedup. Importing the same multisig descriptor twice silently creates two wallets.

Confidence Score: 4/5

Safe to merge with low risk, but the missing duplicate-wallet guard should be addressed to match the behaviour of every other hardware-import path before shipping.

The core parsing and BDK wallet creation logic is sound and well-tested. However, the absence of a duplicate-check (a guard present on every comparable import path) is a real behaviour gap: importing the same descriptor twice silently creates two identical wallets, which is a concrete data-integrity issue rather than a theoretical concern.

rust/src/wallet.rs — specifically try_new_persisted_multisig_watch_only (no duplicate guard) and parse_multisig_descriptors (internal descriptor type not validated).

Important Files Changed

Filename Overview
rust/src/wallet.rs Adds try_new_persisted_multisig_watch_only and parse_multisig_descriptors; missing duplicate-wallet guard (present on all other hardware-import paths) means the same descriptor can be imported repeatedly, creating duplicate wallets.
rust/src/manager/wallet_manager.rs Adds try_new_from_multisig_descriptor constructor mirroring the xpub pattern; inherits the duplicate-detection gap from the underlying Wallet function but is otherwise structurally correct.
android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt Auto-generated UniFFI bindings for the two new constructors; checksum values and signatures match the Rust interface.
ios/CoveCore/Sources/CoveCore/generated/cove.swift Auto-generated UniFFI Swift bindings; checksum values and function signatures are consistent with the Kotlin bindings and Rust source.

Sequence Diagram

sequenceDiagram
    participant UI
    participant RustWalletManager
    participant Wallet
    participant parse_multisig_descriptors
    participant BdkStore
    participant Keychain
    participant Database

    UI->>RustWalletManager: try_new_from_multisig_descriptor(descriptor)
    RustWalletManager->>Wallet: try_new_persisted_multisig_watch_only(descriptor)
    Wallet->>parse_multisig_descriptors: parse(input)
    parse_multisig_descriptors-->>Wallet: (external, internal, name)
    Note over Wallet: No duplicate check here
    Wallet->>BdkStore: try_new(&id, network)
    Wallet->>BdkStore: create_wallet(external, internal)
    Wallet->>Keychain: save_public_descriptor(&id, external, internal)
    Wallet->>Database: save_new_wallet_metadata(metadata)
    Wallet-->>RustWalletManager: Self { id, metadata, ... }
    RustWalletManager->>RustWalletManager: spawn WalletActor + WalletScanner
    RustWalletManager-->>UI: Ok(RustWalletManager)
Loading

Reviews (1): Last reviewed commit: "Regenerate Swift and Kotlin bindings" | Re-trigger Greptile

Comment thread rust/src/wallet.rs
Comment on lines +364 to 397
pub fn try_new_persisted_multisig_watch_only(descriptor: String) -> Result<Self, WalletError> {
use bdk_wallet::keys::KeyMap;

let (external, internal, name) = parse_multisig_descriptors(&descriptor)?;

let keychain = Keychain::global();
let database = Database::global();
let network = database.global_config.selected_network();

let id = WalletId::new();
let mut metadata = WalletMetadata::new_for_hardware(id.clone(), name, None);
metadata.wallet_type = WalletType::WatchOnly;

let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?;

let wallet = Descriptors {
external: Descriptor { extended_descriptor: external.clone(), key_map: KeyMap::new() },
internal: Descriptor { extended_descriptor: internal.clone(), key_map: KeyMap::new() },
}
.into_create_params()
.network(network.into())
.create_wallet(&mut store.conn)
.map_err_str(WalletError::BdkError)?;

keychain.save_public_descriptor(&id, external, internal)?;
database.wallets.save_new_wallet_metadata(metadata.clone())?;
CLOUD_BACKUP_MANAGER.handle_wallet_set_change();

Ok(Self { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) })
}

pub fn try_new_persisted_from_tap_signer(
tap_signer: Arc<cove_tap_card::TapSigner>,
derive: DeriveInfo,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 No duplicate-wallet check before persisting

Every other hardware-import path guards against re-importing the same wallet. try_new_persisted_from_pubport finds an existing wallet by fingerprint (and upgrades it); try_new_persisted_from_tap_signer calls check_for_duplicate_wallet. The new multisig path omits both checks, so importing the same descriptor string twice silently creates two identical wallets in the database and keychain.

A multisig wallet has multiple cosigner fingerprints rather than a single master fingerprint, so a descriptor-level dedup is needed — e.g., hashing the canonical form of the external descriptor and comparing it against existing wallets' stored descriptors before calling BdkStore::try_new.

Comment thread rust/src/wallet.rs
Comment on lines +800 to +830
},
_ => {
return Err(WalletError::UnsupportedWallet(
"only wsh(sortedmulti) descriptors are supported".to_string(),
));
}
};

Ok((external, internal, name))
}

#[cfg(test)]
mod tests {
use super::*;

const XPUB1: &str = "xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM";
const XPUB2: &str = "xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz";

fn multisig_two_line() -> String {
let ext = format!(
"wsh(sortedmulti(2,[817e7be0/48h/0h/0h/2h]{XPUB1}/0/*,[a262308d/48h/0h/0h/2h]{XPUB2}/0/*))"
);
let int = format!(
"wsh(sortedmulti(2,[817e7be0/48h/0h/0h/2h]{XPUB1}/1/*,[a262308d/48h/0h/0h/2h]{XPUB2}/1/*))"
);
format!("{ext}\n{int}")
}

fn multisig_multipath() -> String {
format!(
"wsh(sortedmulti(2,[817e7be0/48h/0h/0h/2h]{XPUB1}/<0;1>/*,[a262308d/48h/0h/0h/2h]{XPUB2}/<0;1>/*))"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Internal descriptor type is not validated

parse_multisig_descriptors checks that external is Wsh(SortedMulti(...)) in order to derive the wallet name, but internal is never inspected. In the two-line format a caller could supply a valid wsh(sortedmulti) for line 1 and a completely different descriptor type (e.g. wpkh(...)) for line 2. BDK will likely reject this at create_wallet, but the error surface will be an opaque BdkError rather than the clear UnsupportedWallet message. Validating internal with the same match block (or asserting external and internal use the same k() / pks().len()) would give callers a much better error.

Comment thread rust/src/wallet.rs
Comment on lines +790 to +797
let name = match &external {
MiniscriptDescriptor::Wsh(wsh) => match wsh.as_inner() {
WshInner::SortedMulti(sm) => {
format!("{}-of-{} Multisig Watch-Only", sm.k(), sm.pks().len())
}
_ => {
return Err(WalletError::UnsupportedWallet(
"only wsh(sortedmulti) descriptors are supported".to_string(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 <0;1> expansion replaces all occurrences via plain string substitution

single.replace("<0;1>", "0") calls str::replace which replaces every occurrence of the substring. For the common case where every key in the descriptor has /<0;1>/* this produces the correct result, but a descriptor mixing <0;1> and a literal range like <2;3> would be mishandled, and any future caller passing an unusual BIP-389 form could get silently wrong output. Using a proper descriptor parser or at minimum validating that only <0;1> wildcards are present before expanding would make this more robust.

Copy link
Copy Markdown

@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: 4

🧹 Nitpick comments (1)
rust/src/wallet.rs (1)

770-772: Use map_err_str for this error conversion.

This matches the repository helper style and avoids the manual to_string() closure. As per coding guidelines, use cove_util::ResultExt::map_err_str instead of .map_err(|e| Error::Variant(e.to_string())).

         MiniscriptDescriptor::<DescriptorPublicKey>::parse_descriptor(&secp, s)
             .map(|(d, _)| d)
-            .map_err(|e| WalletError::DescriptorKeyParseError(e.to_string()))
+            .map_err_str(WalletError::DescriptorKeyParseError)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rust/src/wallet.rs` around lines 770 - 772, Replace the manual
error-to-string conversion on the parse_descriptor call by using the repository
helper map_err_str: change the tail of
MiniscriptDescriptor::<DescriptorPublicKey>::parse_descriptor(&secp, s).map(|(d,
_)| d).map_err(|e| WalletError::DescriptorKeyParseError(e.to_string())) to use
cove_util::ResultExt::map_err_str and construct
WalletError::DescriptorKeyParseError from the mapped str, i.e., call map_err_str
with the WalletError::DescriptorKeyParseError constructor so the error
conversion follows the project helper pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@rust/src/manager/wallet_manager.rs`:
- Around line 395-404: The code currently creates a persisted wallet
(Wallet::try_new_persisted_multisig_watch_only) and then spawns the scanner
before constructing the actor, which can leave a persisted wallet (and scanner)
behind if WalletActor::new fails; fix by constructing WalletActor::new(wallet,
sender.clone()) first, and only spawn the scanner
(WalletScanner::try_new(...).ok().map(spawn_actor)) after the actor has been
successfully created and task::spawn_actor called; if WalletActor::new returns
an error, delete the just-created persisted wallet (use the wallet id/metadata
to remove the persisted store) before returning the error to avoid leaving
orphaned state.

In `@rust/src/wallet.rs`:
- Around line 373-390: The create_wallet succeeded but subsequent persistence
calls (keychain.save_public_descriptor and
database.wallets.save_new_wallet_metadata) can fail leaving an orphaned BDK
store and partial keychain/metadata; wrap those persistence steps in a fallible
block and on any error perform cleanup: remove the saved public descriptor from
the keychain (call the corresponding remove method for the keychain using the
WalletId), delete the BDK store (use BdkStore removal API or a conn/cleanup
method on the created store identified by id), and remove any partially inserted
wallet metadata from database.wallets (use the delete/remove metadata API), then
return the original error; only call
CLOUD_BACKUP_MANAGER.handle_wallet_set_change() after all persistence succeeded.
Ensure you reference WalletId/id, BdkStore::try_new/store,
Descriptors::into_create_params().create_wallet,
keychain.save_public_descriptor, and database.wallets.save_new_wallet_metadata
when locating where to add the transactional/rollback logic.
- Around line 790-806: The code only validates the `external` descriptor as
`MiniscriptDescriptor::Wsh` with `WshInner::SortedMulti`, but the `internal`
descriptor must be validated the same way to prevent persisting mismatched or
unsupported internals; update the logic that builds `name` (and any persistence
path) to also match on the `internal` descriptor (e.g., check `internal` is
`MiniscriptDescriptor::Wsh` and its `as_inner()` yields
`WshInner::SortedMulti`), return `WalletError::UnsupportedWallet` with the same
message if the internal is invalid, and ensure any uses of
`sm.k()`/`sm.pks().len()` come from the validated descriptor(s) so change
outputs aren’t misclassified.
- Around line 779-782: The multipath expansion branch that handles strings
containing "<0;1>" must remove any trailing descriptor checksum before doing the
replace so parse(...) doesn't fail; in the branch matching
single.contains("<0;1>") first strip any "#..." suffix from single (e.g.,
compute let base = single.splitn(2, '#').next().unwrap_or(single);), then create
ext_str and int_str by replacing "<0;1>" on base (ext_str =
base.replace("<0;1>", "0") and int_str = base.replace("<0;1>", "1")), and
finally call (parse(&ext_str)?, parse(&int_str)?); this ensures
parse/parse_descriptor validation runs on checksum-free descriptors as
suggested.

---

Nitpick comments:
In `@rust/src/wallet.rs`:
- Around line 770-772: Replace the manual error-to-string conversion on the
parse_descriptor call by using the repository helper map_err_str: change the
tail of MiniscriptDescriptor::<DescriptorPublicKey>::parse_descriptor(&secp,
s).map(|(d, _)| d).map_err(|e|
WalletError::DescriptorKeyParseError(e.to_string())) to use
cove_util::ResultExt::map_err_str and construct
WalletError::DescriptorKeyParseError from the mapped str, i.e., call map_err_str
with the WalletError::DescriptorKeyParseError constructor so the error
conversion follows the project helper pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fdf113b9-1dc1-4515-b6f1-e74e924aa7fe

📥 Commits

Reviewing files that changed from the base of the PR and between fc4f6d3 and d881e69.

⛔ Files ignored due to path filters (1)
  • ios/CoveCore/Sources/CoveCore/generated/cove.swift is excluded by !**/generated/**
📒 Files selected for processing (3)
  • android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt
  • rust/src/manager/wallet_manager.rs
  • rust/src/wallet.rs

Comment thread rust/src/manager/wallet_manager.rs
Comment thread rust/src/wallet.rs
Comment on lines +373 to +390
let id = WalletId::new();
let mut metadata = WalletMetadata::new_for_hardware(id.clone(), name, None);
metadata.wallet_type = WalletType::WatchOnly;

let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?;

let wallet = Descriptors {
external: Descriptor { extended_descriptor: external.clone(), key_map: KeyMap::new() },
internal: Descriptor { extended_descriptor: internal.clone(), key_map: KeyMap::new() },
}
.into_create_params()
.network(network.into())
.create_wallet(&mut store.conn)
.map_err_str(WalletError::BdkError)?;

keychain.save_public_descriptor(&id, external, internal)?;
database.wallets.save_new_wallet_metadata(metadata.clone())?;
CLOUD_BACKUP_MANAGER.handle_wallet_set_change();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Roll back partially persisted multisig imports.

If save_public_descriptor or save_new_wallet_metadata fails after create_wallet, this leaves an orphaned BDK store and possibly keychain data. Wrap the persistence steps like the mnemonic path does and clean up keychain, BDK store, and wallet metadata on error.

🧹 Proposed rollback structure
-        let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?;
+        let create_wallet = || -> Result<Self, WalletError> {
+            let mut store = BdkStore::try_new(&id, network).map_err_str(WalletError::LoadError)?;

-        let wallet = Descriptors {
-            external: Descriptor { extended_descriptor: external.clone(), key_map: KeyMap::new() },
-            internal: Descriptor { extended_descriptor: internal.clone(), key_map: KeyMap::new() },
-        }
-        .into_create_params()
-        .network(network.into())
-        .create_wallet(&mut store.conn)
-        .map_err_str(WalletError::BdkError)?;
+            let wallet = Descriptors {
+                external: Descriptor {
+                    extended_descriptor: external.clone(),
+                    key_map: KeyMap::new(),
+                },
+                internal: Descriptor {
+                    extended_descriptor: internal.clone(),
+                    key_map: KeyMap::new(),
+                },
+            }
+            .into_create_params()
+            .network(network.into())
+            .create_wallet(&mut store.conn)
+            .map_err_str(WalletError::BdkError)?;

-        keychain.save_public_descriptor(&id, external, internal)?;
-        database.wallets.save_new_wallet_metadata(metadata.clone())?;
-        CLOUD_BACKUP_MANAGER.handle_wallet_set_change();
+            keychain.save_public_descriptor(&id, external, internal)?;
+            database.wallets.save_new_wallet_metadata(metadata.clone())?;
+            CLOUD_BACKUP_MANAGER.handle_wallet_set_change();

-        Ok(Self { id, metadata, network, bdk: wallet, db: Mutex::new(store.conn) })
+            Ok(Self { id: id.clone(), metadata, network, bdk: wallet, db: Mutex::new(store.conn) })
+        };
+
+        match create_wallet() {
+            Ok(wallet) => Ok(wallet),
+            Err(error) => {
+                keychain.delete_wallet_items(&id);
+                if let Err(cleanup_error) = delete_wallet_specific_data(&id) {
+                    warn!("clean up failed, failed to delete wallet data: {cleanup_error}");
+                }
+                if let Err(cleanup_error) = database.wallets.delete(&id) {
+                    warn!("clean up failed, failed to delete wallet metadata: {cleanup_error}");
+                }
+                Err(error)
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rust/src/wallet.rs` around lines 373 - 390, The create_wallet succeeded but
subsequent persistence calls (keychain.save_public_descriptor and
database.wallets.save_new_wallet_metadata) can fail leaving an orphaned BDK
store and partial keychain/metadata; wrap those persistence steps in a fallible
block and on any error perform cleanup: remove the saved public descriptor from
the keychain (call the corresponding remove method for the keychain using the
WalletId), delete the BDK store (use BdkStore removal API or a conn/cleanup
method on the created store identified by id), and remove any partially inserted
wallet metadata from database.wallets (use the delete/remove metadata API), then
return the original error; only call
CLOUD_BACKUP_MANAGER.handle_wallet_set_change() after all persistence succeeded.
Ensure you reference WalletId/id, BdkStore::try_new/store,
Descriptors::into_create_params().create_wallet,
keychain.save_public_descriptor, and database.wallets.save_new_wallet_metadata
when locating where to add the transactional/rollback logic.

Comment thread rust/src/wallet.rs
Comment on lines +779 to +782
[single] if single.contains("<0;1>") => {
let ext_str = single.replace("<0;1>", "0");
let int_str = single.replace("<0;1>", "1");
(parse(&ext_str)?, parse(&int_str)?)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does rust-miniscript or BDK parse_descriptor reject an output descriptor when the #checksum suffix no longer matches the descriptor body?

💡 Result:

Yes, both rust-miniscript and BDK reject output descriptors when the #checksum suffix no longer matches the descriptor body. For BDK, the descriptor error enum explicitly includes InvalidDescriptorChecksum: "The provided descriptor doesn't match its checksum" (result 11). BDK re-exports miniscript types and uses the same parsing logic (results 9, 61). For rust-miniscript (the miniscript crate), parsing uses FromStr on Descriptor which relies on the checksum module (result 3). Issue #554 discusses checksum validation during parsing and to_string, confirming parse checks the checksum and fails if mismatched (result 1). Docs show Descriptor::from_str exists and parsing involves checksum verification (results 17, 19, 60).

Citations:


🏁 Script executed:

# First, let's view the exact code at the specified lines
cat -n rust/src/wallet.rs | sed -n '770,790p'

Repository: bitcoinppl/cove

Length of output: 1058


🏁 Script executed:

# Find the parse function being used
rg "fn parse\(" rust/src/wallet.rs -A 3

Repository: bitcoinppl/cove

Length of output: 41


🏁 Script executed:

# Check for any checksum stripping or handling around this code
rg "split_once.*#|checksum|strip" rust/src/wallet.rs -B 2 -A 2

Repository: bitcoinppl/cove

Length of output: 41


🏁 Script executed:

# Look for callers of this function and surrounding context
rg "parse_descriptors\(" rust/src/wallet.rs -B 5 -A 5

Repository: bitcoinppl/cove

Length of output: 41


🏁 Script executed:

# Check if there's any checksum stripping in the file earlier
rg "split.*#|checksum" rust/src/ -r

Repository: bitcoinppl/cove

Length of output: 472


🏁 Script executed:

# Check if inputs are processed before reaching this code
head -n 800 rust/src/wallet.rs | tail -n 100

Repository: bitcoinppl/cove

Length of output: 3630


Strip descriptor checksums before multipath expansion.

When expanding <0;1> multipath notation on a checksummed descriptor (e.g., from Sparrow or Nunchuk exports), replacing <0;1> with 0/1 leaves the original #checksum attached to modified descriptor strings. Since parse_descriptor() validates checksums during parsing, both resulting descriptors fail validation and reject valid exports.

Strip checksums before replacement:

Fix
         [single] if single.contains("<0;1>") => {
-            let ext_str = single.replace("<0;1>", "0");
-            let int_str = single.replace("<0;1>", "1");
+            let descriptor = single.split_once('#').map_or(single, |(descriptor, _)| descriptor);
+            let ext_str = descriptor.replace("<0;1>", "0");
+            let int_str = descriptor.replace("<0;1>", "1");
             (parse(&ext_str)?, parse(&int_str)?)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[single] if single.contains("<0;1>") => {
let ext_str = single.replace("<0;1>", "0");
let int_str = single.replace("<0;1>", "1");
(parse(&ext_str)?, parse(&int_str)?)
[single] if single.contains("<0;1>") => {
let descriptor = single.split_once('#').map_or(single, |(descriptor, _)| descriptor);
let ext_str = descriptor.replace("<0;1>", "0");
let int_str = descriptor.replace("<0;1>", "1");
(parse(&ext_str)?, parse(&int_str)?)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rust/src/wallet.rs` around lines 779 - 782, The multipath expansion branch
that handles strings containing "<0;1>" must remove any trailing descriptor
checksum before doing the replace so parse(...) doesn't fail; in the branch
matching single.contains("<0;1>") first strip any "#..." suffix from single
(e.g., compute let base = single.splitn(2, '#').next().unwrap_or(single);), then
create ext_str and int_str by replacing "<0;1>" on base (ext_str =
base.replace("<0;1>", "0") and int_str = base.replace("<0;1>", "1")), and
finally call (parse(&ext_str)?, parse(&int_str)?); this ensures
parse/parse_descriptor validation runs on checksum-free descriptors as
suggested.

Comment thread rust/src/wallet.rs
Comment on lines +790 to +806
let name = match &external {
MiniscriptDescriptor::Wsh(wsh) => match wsh.as_inner() {
WshInner::SortedMulti(sm) => {
format!("{}-of-{} Multisig Watch-Only", sm.k(), sm.pks().len())
}
_ => {
return Err(WalletError::UnsupportedWallet(
"only wsh(sortedmulti) descriptors are supported".to_string(),
));
}
},
_ => {
return Err(WalletError::UnsupportedWallet(
"only wsh(sortedmulti) descriptors are supported".to_string(),
));
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate the internal descriptor too.

Only external is checked for wsh(sortedmulti). A two-line import with a valid external descriptor and an unsupported or mismatched internal descriptor can still be persisted, which breaks the function’s stated contract and can misclassify change outputs.

🔍 Suggested shape validation
-    let name = match &external {
-        MiniscriptDescriptor::Wsh(wsh) => match wsh.as_inner() {
-            WshInner::SortedMulti(sm) => {
-                format!("{}-of-{} Multisig Watch-Only", sm.k(), sm.pks().len())
-            }
-            _ => {
-                return Err(WalletError::UnsupportedWallet(
-                    "only wsh(sortedmulti) descriptors are supported".to_string(),
-                ));
-            }
-        },
-        _ => {
-            return Err(WalletError::UnsupportedWallet(
-                "only wsh(sortedmulti) descriptors are supported".to_string(),
-            ));
-        }
-    };
+    let sortedmulti_shape = |descriptor: &ExtendedDescriptor| match descriptor {
+        MiniscriptDescriptor::Wsh(wsh) => match wsh.as_inner() {
+            WshInner::SortedMulti(sm) => Ok((sm.k(), sm.pks().len())),
+            _ => Err(WalletError::UnsupportedWallet(
+                "only wsh(sortedmulti) descriptors are supported".to_string(),
+            )),
+        },
+        _ => Err(WalletError::UnsupportedWallet(
+            "only wsh(sortedmulti) descriptors are supported".to_string(),
+        )),
+    };
+
+    let external_shape = sortedmulti_shape(&external)?;
+    let internal_shape = sortedmulti_shape(&internal)?;
+    if external_shape != internal_shape {
+        return Err(WalletError::UnsupportedWallet(
+            "external and internal multisig descriptors must use the same threshold and signer count"
+                .to_string(),
+        ));
+    }
+
+    let name = format!("{}-of-{} Multisig Watch-Only", external_shape.0, external_shape.1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rust/src/wallet.rs` around lines 790 - 806, The code only validates the
`external` descriptor as `MiniscriptDescriptor::Wsh` with
`WshInner::SortedMulti`, but the `internal` descriptor must be validated the
same way to prevent persisting mismatched or unsupported internals; update the
logic that builds `name` (and any persistence path) to also match on the
`internal` descriptor (e.g., check `internal` is `MiniscriptDescriptor::Wsh` and
its `as_inner()` yields `WshInner::SortedMulti`), return
`WalletError::UnsupportedWallet` with the same message if the internal is
invalid, and ensure any uses of `sm.k()`/`sm.pks().len()` come from the
validated descriptor(s) so change outputs aren’t misclassified.

@Vineet1101
Copy link
Copy Markdown

@DhruvArvindSingh I think this is related to the project work and it should be done by the selected contributor for the project. What are your thoughts on this @praveenperera

Copy link
Copy Markdown

@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 `@rust/src/wallet.rs`:
- Around line 375-382: The duplicate-check currently swallows errors and
compares descriptors by string rendering; change get_all(network,
mode).unwrap_or_default() to propagate the error (return Err) instead of masking
it, call keychain.get_public_descriptor(&wm.id) and handle its Err by
propagating a failure (do not ignore with if let Ok), and parse external_str
once into an ExtendedDescriptor (or equivalent parsed type) then compare the
parsed stored_ext value to that parsed external descriptor using equality
(stored_ext == parsed_external) rather than stored_ext.to_string() ==
external_str so transient DB/keychain errors stop the import and descriptor
equivalence is robust.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0fa737eb-b0fb-4e42-8a35-78edb6a3ef00

📥 Commits

Reviewing files that changed from the base of the PR and between d881e69 and 4a1b73a.

📒 Files selected for processing (2)
  • rust/src/manager/wallet_manager.rs
  • rust/src/wallet.rs
✅ Files skipped from review due to trivial changes (1)
  • rust/src/manager/wallet_manager.rs

Comment thread rust/src/wallet.rs
Comment on lines +375 to +382
let existing = database.wallets.get_all(network, mode).unwrap_or_default();
for wm in existing {
if let Ok(Some((stored_ext, _))) = keychain.get_public_descriptor(&wm.id) {
if stored_ext.to_string() == external_str {
return Err(WalletError::WalletAlreadyExists(wm.id));
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Duplicate detection silently bypassed on errors, and string comparison is fragile.

Two concerns in this block:

  1. database.wallets.get_all(network, mode).unwrap_or_default() (line 375) and if let Ok(Some(...)) on keychain.get_public_descriptor(&wm.id) (line 377) swallow DB/keychain errors. If either call fails transiently, the duplicate check is skipped and the same multisig can be imported twice, creating two independent BDK stores for the same wallet.
  2. Comparing descriptors via stored_ext.to_string() == external_str depends on both sides rendering to byte-identical strings (including checksum). Comparing the parsed ExtendedDescriptor values directly is more robust than round-tripping through Display.
🛡️ Suggested fix
-        let external_str = external.to_string();
-        let existing = database.wallets.get_all(network, mode).unwrap_or_default();
-        for wm in existing {
-            if let Ok(Some((stored_ext, _))) = keychain.get_public_descriptor(&wm.id) {
-                if stored_ext.to_string() == external_str {
-                    return Err(WalletError::WalletAlreadyExists(wm.id));
-                }
-            }
-        }
+        let existing = database.wallets.get_all(network, mode)?;
+        for wm in existing {
+            let Some((stored_ext, _)) = keychain.get_public_descriptor(&wm.id)? else {
+                continue;
+            };
+            if stored_ext == external {
+                return Err(WalletError::WalletAlreadyExists(wm.id));
+            }
+        }

As per coding guidelines: "Suggest improvements to error handling, unwraps, and Result handling."

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

In `@rust/src/wallet.rs` around lines 375 - 382, The duplicate-check currently
swallows errors and compares descriptors by string rendering; change
get_all(network, mode).unwrap_or_default() to propagate the error (return Err)
instead of masking it, call keychain.get_public_descriptor(&wm.id) and handle
its Err by propagating a failure (do not ignore with if let Ok), and parse
external_str once into an ExtendedDescriptor (or equivalent parsed type) then
compare the parsed stored_ext value to that parsed external descriptor using
equality (stored_ext == parsed_external) rather than stored_ext.to_string() ==
external_str so transient DB/keychain errors stop the import and descriptor
equivalence is robust.

Comment thread rust/src/wallet.rs
Comment on lines +399 to +400
keychain.save_public_descriptor(&id, external, internal)?;
database.wallets.save_new_wallet_metadata(metadata.clone())?;
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.

If create_wallet succeeds but save_public_descriptor or save_new_wallet_metadata fails, this returns an error after creating the BDK store and possibly saving keychain data. That leaves orphaned wallet state behind even though the import is reported as failed. Wrap the create/persist sequence in a fallible block and, on any error after the new id is allocated, clean up with keychain.delete_wallet_items(&id), delete_wallet_specific_data(&id), and database.wallets.delete(&id) before returning the original error. Only call CLOUD_BACKUP_MANAGER.handle_wallet_set_change() after all persistence succeeds.

Comment on lines +399 to +400
let wallet_actor = WalletActor::new(wallet, sender.clone())
.map_err(|e| Error::DatabaseCorruption { id: id.clone(), error: e.to_string() })?;
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.

Wallet::try_new_persisted_multisig_watch_only has already persisted the wallet before WalletActor::new opens the wallet data DB. If WalletActor::new fails here, the constructor returns DatabaseCorruption while the imported wallet remains in BDK/keychain/database state, so the user sees a failed import but may be blocked from retrying by the duplicate check. Handle the WalletActor::new error explicitly and delete the just-created wallet data/keychain items/metadata before returning.

Comment thread rust/src/wallet.rs
)),
};

let name = match &external {
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.

Only the external descriptor is validated as wsh(sortedmulti(...)); the internal descriptor can be any parseable descriptor in the two-line format. That can persist a wallet whose change descriptor is unsupported or mismatched while still naming it from the external multisig shape. Validate internal with the same WshInner::SortedMulti check and reject mismatched threshold/signer count before returning the descriptors.

Comment thread rust/src/wallet.rs
let (external, internal) = match lines.as_slice() {
[ext, int] => (parse(ext)?, parse(int)?),
[single] if single.contains("<0;1>") => {
let ext_str = single.replace("<0;1>", "0");
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.

This expands <0;1> before parsing, but checksummed descriptors from common exports include a trailing #checksum. Replacing the multipath segment changes the descriptor body while leaving the old checksum attached, so valid checksummed multipath imports are rejected by descriptor parsing. Strip any #... suffix before building ext_str and int_str, then parse the checksum-free expanded descriptors. Add some failing tests that confirm this before fixing.

Comment thread rust/src/wallet.rs
let mode = database.global_config.wallet_mode();

let external_str = external.to_string();
let existing = database.wallets.get_all(network, mode).unwrap_or_default();
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.

The duplicate check silently falls back to an empty wallet list when get_all fails, and line 377 also ignores keychain read/parse errors. A transient DB/keychain error can therefore bypass duplicate detection and create another copy of the same watch-only wallet. Propagate these errors during import instead of treating them as “no duplicate”; compare the parsed stored descriptor to the parsed new external descriptor rather than relying on string rendering.

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