Fix descriptor export for Sparrow compatibility#609
Conversation
📝 WalkthroughWalkthroughAdds descriptor normalization, checksum recomputation, BIP-389 multipath generation and export formatting to the descriptor extension; updates wallet export to use the new export string; adds tests and maps a new NotMatchingPair error into the internal parse error conversion. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR fixes a Sparrow wallet compatibility issue by replacing the raw BDK descriptor output (which uses Key changes:
Confidence Score: 5/5
Last reviewed commit: f7d380a |
0b2d055 to
13fa7ee
Compare
|
@greptile-apps re-review |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
rust/crates/cove-bdk/src/descriptor_ext.rs (1)
93-96:⚠️ Potential issue | 🟠 MajorValidate and use
internalbefore producing the<0;1>descriptor.Line 93 currently ignores
internal, so mismatched external/internal descriptors are accepted silently and can yield an invalid combined export.Proposed fix
- fn to_multipath_string(external: &Self, _internal: &Self) -> String { - let body = format!("{external:#}").replace('\'', "h").replace("/0/*)", "/<0;1>/*)"); + fn to_multipath_string(external: &Self, internal: &Self) -> String { + let ext_body = format!("{external:#}").replace('\'', "h"); + let int_body = format!("{internal:#}").replace('\'', "h"); + + let expected_internal = ext_body.replacen("/0/*)", "/1/*)", 1); + assert_eq!( + int_body, expected_internal, + "external/internal descriptors are not a matching keychain pair" + ); + + let body = ext_body.replacen("/0/*)", "/<0;1>/*)", 1); let checksum = desc_checksum(&body).expect("valid descriptor body"); format!("{body}#{checksum}") }As per coding guidelines,
rust/**/*.rs:Verify correct functionality and logic consistency.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@rust/crates/cove-bdk/src/descriptor_ext.rs` around lines 93 - 96, The to_multipath_string function currently ignores the provided internal descriptor; change it to accept and use the internal descriptor (remove the leading underscore from the parameter) and validate that external and internal are consistent before generating the combined export. Specifically, in to_multipath_string(external: &Self, internal: &Self) produce the body using the internal descriptor where the <0;1> replacement is needed (e.g., format!("{internal:#}") or construct the multipath body from both descriptors), and add a sanity check (compare structure or relevant fields of external and internal, panic or return an error if they mismatch) before computing desc_checksum and returning the final "{body}#{checksum}" string.
🧹 Nitpick comments (1)
rust/src/keys.rs (1)
488-495: Make checksum helper parsing strict withsplit_once('#').
extract_checksum/compute_checksumcurrently accept missing#implicitly. Requiring a separator gives clearer, immediate test failures.Proposed refactor
fn extract_checksum(desc_str: &str) -> &str { - desc_str.rsplit('#').next().unwrap() + desc_str + .split_once('#') + .map(|(_, checksum)| checksum) + .expect("descriptor must include checksum") } /// Helper: compute expected checksum from descriptor body (before '#') fn compute_checksum(desc_str: &str) -> String { - let body = desc_str.split('#').next().unwrap(); + let (body, _) = + desc_str.split_once('#').expect("descriptor must include checksum"); bdk_wallet::miniscript::descriptor::checksum::desc_checksum(body).unwrap() }As per coding guidelines,
rust/**/*.rs:Check for idiomatic Rust usage and performance best practices.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@rust/src/keys.rs` around lines 488 - 495, The helpers extract_checksum and compute_checksum should require the '#' separator explicitly: change extract_checksum to use desc_str.split_once('#') (or rsplit_once) and .expect(...) to return the checksum slice only if the separator exists, and change compute_checksum to use desc_str.split_once('#').expect(...) to obtain the descriptor body before calling bdk_wallet::miniscript::descriptor::checksum::desc_checksum(body).unwrap(); this makes parsing strict and yields clear failures when '#' is missing; reference functions: extract_checksum and compute_checksum.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@rust/crates/cove-bdk/src/descriptor_ext.rs`:
- Around line 93-96: The to_multipath_string function currently ignores the
provided internal descriptor; change it to accept and use the internal
descriptor (remove the leading underscore from the parameter) and validate that
external and internal are consistent before generating the combined export.
Specifically, in to_multipath_string(external: &Self, internal: &Self) produce
the body using the internal descriptor where the <0;1> replacement is needed
(e.g., format!("{internal:#}") or construct the multipath body from both
descriptors), and add a sanity check (compare structure or relevant fields of
external and internal, panic or return an error if they mismatch) before
computing desc_checksum and returning the final "{body}#{checksum}" string.
---
Nitpick comments:
In `@rust/src/keys.rs`:
- Around line 488-495: The helpers extract_checksum and compute_checksum should
require the '#' separator explicitly: change extract_checksum to use
desc_str.split_once('#') (or rsplit_once) and .expect(...) to return the
checksum slice only if the separator exists, and change compute_checksum to use
desc_str.split_once('#').expect(...) to obtain the descriptor body before
calling
bdk_wallet::miniscript::descriptor::checksum::desc_checksum(body).unwrap(); this
makes parsing strict and yields clear failures when '#' is missing; reference
functions: extract_checksum and compute_checksum.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
rust/crates/cove-bdk/src/descriptor_ext.rsrust/src/keys.rsrust/src/manager/wallet_manager.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- rust/src/manager/wallet_manager.rs
13fa7ee to
f7d380a
Compare
|
@greptile-apps re-review |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
rust/crates/cove-bdk/src/descriptor_ext.rs (1)
106-119: Consider usingreplacenfor precise single replacement.The current implementation uses
replace()which replaces all occurrences of/0/*)and/1/*). While standard single-key descriptors typically have only one such pattern, usingreplacen(..., 1)would be more defensive and align with the previous review suggestion.♻️ Proposed refinement
fn to_multipath_string(external: &Self, internal: &Self) -> Result<String> { let ext_body = format!("{external:#}").replace('\'', "h"); let int_body = format!("{internal:#}").replace('\'', "h"); // both descriptors must be identical except for the keychain index (0 vs 1) - let expected_internal = ext_body.replace("/0/*)", "/1/*)"); + let expected_internal = ext_body.replacen("/0/*)", "/1/*)", 1); if int_body != expected_internal { return Err(Error::NotMatchingPair); } - let body = ext_body.replace("/0/*)", "/<0;1>/*)"); + let body = ext_body.replacen("/0/*)", "/<0;1>/*)", 1); let checksum = desc_checksum(&body).expect("valid descriptor body"); Ok(format!("{body}#{checksum}")) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@rust/crates/cove-bdk/src/descriptor_ext.rs` around lines 106 - 119, In to_multipath_string, the code uses ext_body.replace("/0/*)", "/1/*)") and ext_body.replace("/0/*)", "/<0;1>/*)") which will replace all occurrences; change both calls to use replacen(..., 1) so only the first occurrence is replaced (e.g., expected_internal = ext_body.replacen("/0/*)", "/1/*)", 1) and body = ext_body.replacen("/0/*)", "/<0;1>/*)", 1)), keeping the comparisons with int_body and subsequent desc_checksum the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@rust/crates/cove-bdk/src/descriptor_ext.rs`:
- Around line 106-119: In to_multipath_string, the code uses
ext_body.replace("/0/*)", "/1/*)") and ext_body.replace("/0/*)", "/<0;1>/*)")
which will replace all occurrences; change both calls to use replacen(..., 1) so
only the first occurrence is replaced (e.g., expected_internal =
ext_body.replacen("/0/*)", "/1/*)", 1) and body = ext_body.replacen("/0/*)",
"/<0;1>/*)", 1)), keeping the comparisons with int_body and subsequent
desc_checksum the same.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
rust/crates/cove-bdk/src/descriptor_ext.rsrust/src/keys.rsrust/src/manager/wallet_manager.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- rust/src/keys.rs
f7d380a to
6327aff
Compare
Export descriptors as a single BIP-389 multipath descriptor with <0;1> notation and h-hardened notation, matching Coldcard's default format. BDK's miniscript uses ' (apostrophe) for hardened steps, but Sparrow normalizes to h before validating the checksum, which causes a mismatch. The new to_normalized_string() and to_multipath_string() methods on DescriptorExt produce h-notation strings with correctly recomputed checksums.
Add a unit test that verifies the alternate Display format ({:#}) for Descriptor omits the checksum. The test parses a wpkh descriptor, compares normal and alternate formatting, and asserts that recomputing the checksum from the alternate body reproduces the normal output. This guards against upstream changes to miniscript/display behavior.
6327aff to
7a078df
Compare
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
rust/src/keys.rs (1)
336-344:⚠️ Potential issue | 🔴 CriticalFix moved-value usage in
From<...Error>conversion.Line 343 uses
error.to_string()insidematch error { ... }, buterroris consumed by the match and inaccessible in theE::NotMatchingPairarm.Proposed fix
impl From<cove_bdk::descriptor_ext::Error> for DescriptorKeyParseError { fn from(error: cove_bdk::descriptor_ext::Error) -> Self { use cove_bdk::descriptor_ext::Error as E; match error { E::NoOrigin => Self::NoOrigin, E::UnsupportedDescriptor(s) => Self::UnsupportedDescriptor(s), E::UnsupportedDescriptorType(s) => Self::UnsupportedDescriptorType(s), - E::NotMatchingPair => Self::UnsupportedDescriptor(error.to_string()), + e @ E::NotMatchingPair => Self::UnsupportedDescriptor(e.to_string()), } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@rust/src/keys.rs` around lines 336 - 344, In the From<cove_bdk::descriptor_ext::Error> for DescriptorKeyParseError impl the match arm for E::NotMatchingPair tries to call error.to_string() after error is moved by the match; fix by computing the error string before matching (e.g. let err_str = error.to_string();) and then match on error, returning Self::UnsupportedDescriptor(err_str) in the E::NotMatchingPair arm; update the E::UnsupportedDescriptor/E::UnsupportedDescriptorType arms to keep using their captured s variables unchanged.
♻️ Duplicate comments (1)
rust/crates/cove-bdk/src/descriptor_ext.rs (1)
106-128:⚠️ Potential issue | 🟠 MajorPrevent false-positive multipath conversion when
/0/*)is not present.Line 111 and Line 116 use global
replace, and a no-op replacement can still returnOk(...). That letsto_export_string(Line 122) emit a single-line export even when no valid external/internal pair was collapsed.Proposed fix
fn to_multipath_string(external: &Self, internal: &Self) -> Result<String> { let external_body = format!("{external:#}").replace('\'', "h"); let internal_body = format!("{internal:#}").replace('\'', "h"); - // both descriptors must be identical except for the keychain index (0 vs 1) - let expected_internal = external_body.replace("/0/*)", "/1/*)"); - if internal_body != expected_internal { + // both descriptors must be identical except for exactly one keychain index (0 vs 1) + let expected_internal = external_body.replacen("/0/*)", "/1/*)", 1); + let body = external_body.replacen("/0/*)", "/<0;1>/*)", 1); + + // require that a replacement actually happened + if expected_internal == external_body || body == external_body || internal_body != expected_internal { return Err(Error::NotMatchingPair); } - let body = external_body.replace("/0/*)", "/<0;1>/*)"); let checksum = desc_checksum(&body).expect("valid descriptor body"); Ok(format!("{body}#{checksum}")) }As per coding guidelines:
rust/**/*.rs: "Verify correct functionality and logic consistency."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@rust/crates/cove-bdk/src/descriptor_ext.rs` around lines 106 - 128, The current to_multipath_string uses global replace that can be a no-op and still produce Ok; update to_multipath_string to first verify that external_body contains the exact substring "/0/*)" and internal_body contains "/1/*)" before performing the replacement and checksum logic (return Err(Error::NotMatchingPair) if those substrings are absent), so the expected_internal check only runs when a real index substitution is possible; adjust references to external_body, internal_body, and expected_internal accordingly and keep to_export_string behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@rust/src/keys.rs`:
- Around line 336-344: In the From<cove_bdk::descriptor_ext::Error> for
DescriptorKeyParseError impl the match arm for E::NotMatchingPair tries to call
error.to_string() after error is moved by the match; fix by computing the error
string before matching (e.g. let err_str = error.to_string();) and then match on
error, returning Self::UnsupportedDescriptor(err_str) in the E::NotMatchingPair
arm; update the E::UnsupportedDescriptor/E::UnsupportedDescriptorType arms to
keep using their captured s variables unchanged.
---
Duplicate comments:
In `@rust/crates/cove-bdk/src/descriptor_ext.rs`:
- Around line 106-128: The current to_multipath_string uses global replace that
can be a no-op and still produce Ok; update to_multipath_string to first verify
that external_body contains the exact substring "/0/*)" and internal_body
contains "/1/*)" before performing the replacement and checksum logic (return
Err(Error::NotMatchingPair) if those substrings are absent), so the
expected_internal check only runs when a real index substitution is possible;
adjust references to external_body, internal_body, and expected_internal
accordingly and keep to_export_string behavior unchanged.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
rust/crates/cove-bdk/src/descriptor_ext.rsrust/src/keys.rsrust/src/manager/wallet_manager.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- rust/src/manager/wallet_manager.rs
* Fix descriptor export for Sparrow compatibility
Export descriptors as a single BIP-389 multipath descriptor with <0;1>
notation and h-hardened notation, matching Coldcard's default format.
BDK's miniscript uses ' (apostrophe) for hardened steps, but Sparrow
normalizes to h before validating the checksum, which causes a mismatch.
The new to_normalized_string() and to_multipath_string() methods on
DescriptorExt produce h-notation strings with correctly recomputed
checksums.
* Add test for descriptor Display omitting checksum
Add a unit test that verifies the alternate Display format ({:#}) for Descriptor omits the checksum. The test parses a wpkh descriptor, compares normal and alternate formatting, and asserts that recomputing the checksum from the alternate body reproduces the normal output. This guards against upstream changes to miniscript/display behavior.
* Fix fee tier calculation inflating rates in low-fee environments (#605) The old logic added +1.1 sat/vb offsets between tiers that cascaded, turning API values of (0.1, 0.5, 1.0) into displayed rates of (1.2, 2.3, 3.4). Replace with a 1.0 sat/vb floor and 0.1 minimum tier gaps so low fees correctly show (1.0, 1.1, 1.2) while high fees pass through unchanged. * Respect API minimum_fee in fee rates Use the API-provided minimum_fee as a floor (maxed with MIN_FEE_RATE) when computing slow/derived fee tiers so rates never fall below the API minimum. Update assertions to use to_sat_per_kwu() conversions and add unit tests covering: minimum_fee above the hardcoded floor and minimum_fee lifting the slow tier above the raw midpoint. * Fix descriptor export for Sparrow compatibility (#609) * Fix descriptor export for Sparrow compatibility Export descriptors as a single BIP-389 multipath descriptor with <0;1> notation and h-hardened notation, matching Coldcard's default format. BDK's miniscript uses ' (apostrophe) for hardened steps, but Sparrow normalizes to h before validating the checksum, which causes a mismatch. The new to_normalized_string() and to_multipath_string() methods on DescriptorExt produce h-notation strings with correctly recomputed checksums. * Add test for descriptor Display omitting checksum Add a unit test that verifies the alternate Display format ({:#}) for Descriptor omits the checksum. The test parses a wpkh descriptor, compares normal and alternate formatting, and asserts that recomputing the checksum from the alternate body reproduces the normal output. This guards against upstream changes to miniscript/display behavior. * Set iOS target and switch reqwest feature Add IPHONEOS_DEPLOYMENT_TARGET = "18.0" to rust/.cargo/config.toml to set the minimum iOS deployment target. Change reqwest feature from "rustls" to "rustls-no-provider" in rust/Cargo.toml (both direct and workspace entries) and update Cargo.lock accordingly. * Remove cove-cspp from workspace deps * Use rand::RngExt in cove-util Adapt cove-util to the updated rand API by importing rand::RngExt and calling rand::rng().fill(&mut chain_code) directly (removing the intermediate rng binding). Cargo.lock was also updated/regen'd to reflect dependency changes. * Fix rand 0.10 trait imports across crates Replace rand::Rng with rand::RngExt for methods like random_range, random_bool, random, and fill. Replace rand::RngCore with rand::Rng for next_u32 in deterministic_random_draw. * Fix Android HotWalletKeyMissing nav loop, button order, and manager cleanup - Use loadAndReset instead of pushRoute for import word buttons to prevent infinite alert loop on back press - Swap "Use as Watch Only" and "Use with Hardware Wallet" button order to match iOS - Remove close() call in clearWalletManager to avoid cancelling in-flight reconcile messages, matching iOS behavior - Remove dead code in iOS alertButtons catch-all branch - Regenerate UniFFI Swift bindings * Add rustls and install ring provider Add rustls dependency to Cargo.toml (including workspace entry) and enable the ring feature so a crypto backend is available for rustls. Initialize the ring provider at startup by calling rustls::crypto::ring::default_provider().install_default().ok() in init_on_start to satisfy reqwest builds that use rustls-no-provider. * Bump app version to 1.2.2 Release version bump to 1.2.2 across platforms: Android app versionCode incremented 15->16 and versionName 1.2.1->1.2.2 (android/app/build.gradle.kts); iOS MARKETING_VERSION set to 1.2.2 and CURRENT_PROJECT_VERSION incremented 62->63 (ios/Cove.xcodeproj/project.pbxproj); Rust crate version updated to 1.2.2 in Cargo.toml and reflected in Cargo.lock (rust/). This prepares the codebase for the 1.2.2 release. * Use webpki-roots for reqwest TLS instead of rustls-platform-verifier Fixes Android crash where rustls-platform-verifier panics without JNI init, poisoning the FIAT_CLIENT/FEE_CLIENT LazyLock singletons. Build reqwest clients with a pre-configured rustls ClientConfig using webpki-roots for cert verification. Also move ring provider install to App::new() so it runs before any LazyLock client initialization. * Improve scroll-based toolbar color tracking with stable coordinates Replace onScrollGeometryChange (which depends on contentInsets.top that fluctuates during iOS 26 Liquid Glass transitions) with onGeometryChange on the header view, measuring position in stable global coordinates. Add 10pt hysteresis dead zone to prevent threshold oscillation. * Fix sidebar not closing when tapping buttons with slight finger movement The simultaneousGesture on SidebarView could set isDragging=true from a slight finger movement during a button tap, causing the onChange handler to skip the close animation. Also guard onDragEnded against stale gesture data reopening a programmatically closed sidebar. * Defer route reset & fix color normalization * Replace .principal toolbar with UIKit titleView to fix iOS 26 freeze SwiftUI has a bug where ToolbarPlacementEnvironment.updateValue() enters an infinite loop during _UINavigationParallaxTransition when a .principal toolbar item exists at accessibilityExtraExtraLarge dynamic type size. Bypass .principal entirely by hosting the SwiftUI title content inside a UIHostingController assigned to UIKit's navigationItem.titleView. This preserves the centered title appearance while avoiding the SwiftUI toolbar placement system. * Replace remaining .principal toolbar items with .navigationTitle Use .navigationTitle with .toolbarColorScheme(.dark) for screens with plain static titles on dark backgrounds. Add .navigationTitleView { } View extension for screens needing custom title content (shield icon, context menu). Document the iOS 26 .principal toolbar bug in CLAUDE.md. * Bump project CURRENT_PROJECT_VERSION to 65 * Fix Android app for GrapheneOS and degoogled phones Switch ML Kit barcode scanning from the play-services thin client to the bundled variant (com.google.mlkit:barcode-scanning) which embeds the ML model in the APK and has zero GMS runtime dependency. Add StrongBox fallback in KeychainAccessor: check device capability via PackageManager before requesting StrongBox, and retry with TEE-only key if StrongBox-backed initialization fails. If StrongBox was not used, the exception propagates unchanged (fail-closed). * Propagate initial wallet load state from Rust * Use UIColor.label for popover tint * Bump iOS project version to 66 Increment CURRENT_PROJECT_VERSION from 65 to 66 in ios/Cove.xcodeproj/project.pbxproj for the affected build configurations to update the app build number. * Update proguard-rules.pro * Update build.gradle.kts * Fix wallet manager startup and teardown * Fix validated review findings * Enforce bootstrap-first app initialization * Update Cargo.lock: bump deps and uniffi rev * Use factory function for TapSignerReader FFI creation
Export descriptors as a single BIP-389 multipath descriptor with <0;1> notation and h-hardened notation, matching Coldcard's default format.
BDK's miniscript uses ' (apostrophe) for hardened steps, but Sparrow normalizes to h before validating the checksum, which causes a mismatch. The new to_normalized_string() and to_multipath_string() methods on DescriptorExt produce h-notation strings with correctly recomputed checksums.
Summary by CodeRabbit
Bug Fixes
New Features
Tests
Documentation