Skip to content

Conversation

erickcestari
Copy link
Contributor

@erickcestari erickcestari commented Mar 14, 2025

The bip32_sign_schnorr function was previously only attempting to retrieve private keys using KeyRequest::Bip32, which limited the ability to sign Taproot inputs with key maps that don't support BIP32 derivation paths.

Changes

  • Added new KeyRequest::XOnlyPubkey variant to support direct retrieval using XOnly public keys
  • Implemented GetKey for HashMap<XOnlyPublicKey, PrivateKey> for more efficient Taproot key management
  • Modified HashMap<PublicKey, PrivateKey> implementation to handle XOnlyPublicKey requests by checking both even and odd parity variants
  • Added comprehensive tests for both key map implementations

These improvements enable wallet implementations to store keys indexed by either PublicKey or XOnlyPublicKey and successfully sign PSBTs.

Closes #4150

@github-actions github-actions bot added the C-bitcoin PRs modifying the bitcoin crate label Mar 14, 2025
@coveralls
Copy link

coveralls commented Mar 14, 2025

Pull Request Test Coverage Report for Build 13994039737

Details

  • 116 of 134 (86.57%) changed or added relevant lines in 2 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.5%) to 83.633%

Changes Missing Coverage Covered Lines Changed/Added Lines %
bitcoin/src/psbt/mod.rs 109 127 85.83%
Totals Coverage Status
Change from base Build 13992036628: 0.5%
Covered Lines: 22264
Relevant Lines: 26621

💛 - Coveralls

sk
} else if let Ok(Some(sk)) = k
.get_key(&KeyRequest::Pubkey(xonly.public_key(secp256k1::Parity::Odd).into()), secp)
{
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is unfortunate. I wouldn't mind this in a backport but how about we modify GetKey trait to also have get_by_x_only?

I'd also prefer to provide HashMap<XOnlyPublicKey, PrivateKey> and do the retrieval by ignoring the parity (because both keys can be computed).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is unfortunate. I wouldn't mind this in a backport but how about we modify GetKey trait to also have get_by_x_only?

I'd also prefer to provide HashMap<XOnlyPublicKey, PrivateKey> and do the retrieval by ignoring the parity (because both keys can be computed).

What about adding a new KeyRequest variant for XOnlyPubKey? I think this would better follow the established pattern for adding new key retrieval methods:

pub enum KeyRequest {
    /// Request a private key using the associated public key.
    Pubkey(PublicKey),
    /// Request a private key using BIP-32 fingerprint and derivation path.
    Bip32(KeySource),
    /// Request a private key using the associated x-only public key.
    XOnlyPubkey(XOnlyPublicKey),
}

This approach seems cleaner than modifying the GetKey trait to add a separate get_by_x_only method, and it maintains consistency with how other key types are handled.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, yes, I forgot that get_key takes an enum (which is somewhat weird and possibly slightly slower than necessary but not really broken).

Still, I think having a map data structure that ignores parity when retrieving keys would make a lot of sense.

@erickcestari erickcestari force-pushed the hashmap-sign-taproot-psbt branch from e36f06c to 9193b00 Compare March 17, 2025 13:01
@erickcestari erickcestari changed the title Fix HashMap<PublicKey, PrivateKey> signing with Taproot inputs Add XOnlyPublicKey support for PSBT key retrieval and improve Taproot signing Mar 17, 2025
@erickcestari erickcestari requested a review from Kixunil March 17, 2025 14:27
Copy link
Collaborator

@Kixunil Kixunil left a comment

Choose a reason for hiding this comment

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

Cool, aside from the missing negation bug it looks great!

KeyRequest::XOnlyPubkey(xonly) => Ok(self.get(xonly).cloned()),
KeyRequest::Pubkey(pk) => {
let xonly = XOnlyPublicKey::from(pk.inner);
Ok(self.get(&xonly).cloned())
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's one slight issue: after retrieving the key we need to check the parity of pk and negate the private key if it's Odd.

@erickcestari erickcestari force-pushed the hashmap-sign-taproot-psbt branch 2 times, most recently from 6ac1002 to 4f28d1c Compare March 18, 2025 15:36
};

return Ok(Some(negated_priv_key));
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

IIRC the parity doesn't matter for x-only keys because it's checked when signing and flipped on-demand. But I like this code conceptually because it may lead to less surprising result if anyone uses it.

}

let pubkey_odd = PublicKey::new(xonly.public_key(secp256k1::Parity::Odd));
if let Some(priv_key) = self.get(&pubkey_odd).cloned() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

.copied would've been a bit nicer but not a big deal.

return Ok(None);
},
KeyRequest::Pubkey(pk) => {
let xonly = XOnlyPublicKey::from(pk.inner);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Sadly, this is still incorrect - if pk has odd parity this will return even parity. We need to do the check here against pk's parity.

Also thinking about it, it'd be nicer to have a private fn make_parity(&self, parity: Parity) -> Self on PrivateKey since it's already used in two places.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, now I think I got it. We need to account for the parity of the public key in the request. When we retrieve a private key from the map using just the x-only part, we must verify if that private key generates a public key with the same parity as the original request. If the parity doesn't match, we need to negate the private key before returning it.

My fault, for the misunderstanding.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Correct.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sadly, this is still incorrect - if pk has odd parity this will return even parity. We need to do the check here against pk's parity.

Also thinking about it, it'd be nicer to have a private fn make_parity(&self, parity: Parity) -> Self on PrivateKey since it's already used in two places.

Instead of a make_parity function that requires checking the current parity again, maybe we could just add a simple negate function to PrivateKey? Since we already know when we need to negate the key in our code, this would be more direct and avoid redundant public key generation and parity checks.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh, sorry, I forgot parity is implied. I have a horrible day today.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No worries! Sorry to hear you're having a rough day. Hope things improve!

@erickcestari erickcestari force-pushed the hashmap-sign-taproot-psbt branch 2 times, most recently from 05c2f45 to d2f55a6 Compare March 18, 2025 18:10
@erickcestari erickcestari requested a review from Kixunil March 18, 2025 18:10
Kixunil
Kixunil previously approved these changes Mar 18, 2025
Copy link
Collaborator

@Kixunil Kixunil left a comment

Choose a reason for hiding this comment

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

ACK d2f55a6

/// but with the opposite y-coordinate parity. This is useful for ensuring compatibility
/// with specific public key formats and BIP-340 requirements.
#[inline]
pub fn negate(&self) -> Self {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wanted to make it private but given it doesn't require secp256k1 context, I'm not too worried about making it public.

pubkey_map.insert(pk, priv_key);

let req_result = pubkey_map.get_key(&KeyRequest::XOnlyPubkey(xonly), &secp).unwrap();
assert!(req_result.is_some(), "Should have found a key for odd parity XOnlyPubkey request");
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not needed since you're unwrapping below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed!

Kixunil
Kixunil previously approved these changes Mar 19, 2025
Copy link
Collaborator

@Kixunil Kixunil left a comment

Choose a reason for hiding this comment

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

ACK 3d87885

apoelstra
apoelstra previously approved these changes Mar 20, 2025
Copy link
Member

@apoelstra apoelstra left a comment

Choose a reason for hiding this comment

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

ACK 3d87885; successfully ran local tests

let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
psbt.inputs[0].tap_internal_key = Some(internal_key);
psbt.inputs[0].witness_utxo = Some(transaction::TxOut {
value: Amount::from_sat_unchecked(10),
Copy link
Member

Choose a reason for hiding this comment

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

In 3d87885:

Sorry, you need to rebase because of this (the merge commit does not pass my local CI). from_sat_unchecked was removed. You can use from_sat_u32 instead.

let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
psbt.inputs[0].tap_internal_key = Some(internal_key);
psbt.inputs[0].witness_utxo = Some(transaction::TxOut {
value: Amount::from_sat_unchecked(10),
Copy link
Member

Choose a reason for hiding this comment

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

In 3d87885:

Same here.

… signing

This commit enhances PSBT signing functionality by:

1. Added new KeyRequest::XOnlyPubkey variant to support direct retrieval using XOnly public keys
2. Implemented GetKey for HashMap<XOnlyPublicKey, PrivateKey> for more efficient Taproot key management
3. Modified HashMap<PublicKey, PrivateKey> implementation to handle XOnlyPublicKey requests by checking both even and odd parity variants

These changes allow for more flexible key management in Taproot transactions.
Specifically, wallet implementations can now store keys indexed by either
PublicKey or XOnlyPublicKey and successfully sign PSBTs with Taproot inputs.

Added tests for both implementations to verify correct behavior.

Added test for odd parity key retrieval.

Closes rust-bitcoin#4150
@erickcestari erickcestari dismissed stale reviews from apoelstra and Kixunil via 069d2fd March 21, 2025 14:29
@erickcestari erickcestari force-pushed the hashmap-sign-taproot-psbt branch from 3d87885 to 069d2fd Compare March 21, 2025 14:29
Copy link
Collaborator

@Kixunil Kixunil left a comment

Choose a reason for hiding this comment

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

ACK 069d2fd

Copy link
Member

@apoelstra apoelstra left a comment

Choose a reason for hiding this comment

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

ACK 069d2fd; successfully ran local tests

@apoelstra apoelstra merged commit 8788995 into rust-bitcoin:master Mar 24, 2025
24 checks passed
@jaonoctus
Copy link

Well done @erickcestari 🥂

apoelstra added a commit that referenced this pull request May 8, 2025
…val and improve Taproot signing

95eb255 Add XOnlyPublicKey support for PSBT key retrieval and improve Taproot signing (Erick Cestari)
2858b6c Support GetKey where the Xpriv is a direct child of the looked up KeySource (Nadav Ivgi)
d005ddd Refactor GetKey for sets to internally use Xpriv::get_key() (Nadav Ivgi)
b75b2e3 Fix GetKey for sets to properly compare the fingerprint (Nadav Ivgi)

Pull request description:

  Backport two PRs:

  - #3356
  - #4238

  The first includes a bug fix in a `GetKey` impl and the second is a feature we want to release. And the three refactor patches touch code that #4238 builds on so I figured we should backport them all.

  All 5 patches required a small amount of massaging to get in. The first 4 was just adding a `?` to the calls to `derive_priv`. The last patch needed a few calls in the unit test changing.

ACKs for top commit:
  storopoli:
    ACK 95eb255
  apoelstra:
    ACK 95eb255; successfully ran local tests

Tree-SHA512: 5b73c4cd3ddfeef5d4e64a6e236c905c39c560dc704dba8d925f636d3c9b12c0706f27c3e4b29d92ab9e2d15ddbee8bbe5d0955836529d72461d6ee505d899c5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C-bitcoin PRs modifying the bitcoin crate
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Psbt::sign ok with Xpriv but fails with HashMap<PublicKey, PrivateKey>
5 participants