Skip to content

Conversation

@mariocynicys
Copy link
Collaborator

@mariocynicys mariocynicys commented Apr 9, 2025

A p2pk utxo could have the scriptPub created with either a compressed 33-byte pubkey or an uncompressed 65-byte pubkey.

We store pubkeys internally in compressed format, so when we query electrum for unspent utxos, we construct the output script using that compressed 33-byte pubkey. But a p2pk output script could very well be constructed using an uncompressed 65-byte pubkey (e.g.). Both are valid and accepted by the blockchain.

Also while spending, we need to construct the output script as it's used in the sig_hash calcuation. We used to always construct the p2pk output script as OP_PUSHBYTES_33 33-BYTE-PUBKEY OP_CHECKSIG but now we also construct the OP_PUSHBYTES_65 65-BYTE-PUBKEY OP_CHECKSIG as well, and check which of them matches the previous script and that's what's used to sign the transaction.

p.s. lingo might be confusing, output_script = scriptPub = scriptPubkey = locking_script

@mariocynicys mariocynicys added status: pending review improvement: wallet priority: medium Moderately important tasks that should be completed but are not urgent. labels Apr 9, 2025
…ressed pubkeys

previously we did only one of them depending on the stored Public object (which apparently always seem to be compressed based on how we initialize it)
allow both compressed and uncompressed pubkeys in scriptPubkey of the utxo being spent
Copy link
Collaborator

@onur-ozkan onur-ozkan left a comment

Choose a reason for hiding this comment

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

Would you mind to add some test coverage on this logic if possible?

there seem to be no way to consume the vec to only take one value out of it.
this way looks the cleanst. indexing the first element by ref and cloning it.
borngraced
borngraced previously approved these changes Apr 17, 2025
Copy link

@borngraced borngraced left a comment

Choose a reason for hiding this comment

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

lgtm 🚀

@mariocynicys
Copy link
Collaborator Author

@onur-ozkan added some tests for balance and list unspents that interact with the uncompressed pubkey.

the address hash should dhash160 the compressed pubkey and not the uncompressed one. without this, 65-byte p2pk outputs show incorrect addresses in tx_history (address for a p2pk is just the p2pkh of the same pubkey, to get the hash, one should hash the compressed pubkey rather than the full 65-byte uncompressed one)
UtxoSignWithKeyPairError::InputIndexOutOfBound { .. } => UtxoSignTxError::Internal(error),
UtxoSignWithKeyPairError::UnspendableUTXO { script } => UtxoSignTxError::UnspendableUTXO { script },
UtxoSignWithKeyPairError::ErrorSigning(sign) => UtxoSignTxError::ErrorSigning(sign),
UtxoSignWithKeyPairError::InternalError(internal) => UtxoSignTxError::Internal(internal),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think as a general approach we should try to avoid using such general and unspecific errors like InternalError, with attached long string messages (which bloats code and are harder to document), in favour of specific error valiants which can be converted into documented top-level RPC errors.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

agree. the prompt for me to compress the unspendableutxo into internal error though was the fact that it is only spitted out once in a specific place. and with the addition of pubkey conversion, another error would be spitted out once in another specific place. maybe 2 specific errors are OK, but i feel if we keep adding such small errors variants that aren't generic and used in only one or two places, we will end up with large error structs, and subsequently large error conversion code (which will compress everything to internal error at the later stages :/, as they are too specific to have their own error variants on user-facing/RPC level - though we could have error variants containing other errors, that's more clean and information preserving).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link

@borngraced borngraced left a comment

Choose a reason for hiding this comment

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

some notes

Comment on lines 59 to 62
_ => MmError::err(UtxoSignWithKeyPairError::InternalError(format!(
"Can't spend the UTXO with script = '{}'. This script format isn't supported",
input.prev_script
))),

Choose a reason for hiding this comment

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

not an InternalError more like UnspendableUTXO or UnsupportedScript

Copy link
Collaborator Author

@mariocynicys mariocynicys May 16, 2025

Choose a reason for hiding this comment

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

ummmm, none of these are error variants 🤔

oh i guess u meant it was removed like this #2410 (comment)

ok, will return it back 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Comment on lines 80 to 87
let pubkey = key_pair.public().to_secp256k1_pubkey().map_err(|e| {
UtxoSignWithKeyPairError::InternalError(format!("Couldn't get secp256k1 pubkey from keypair: {}", e))
})?;
// Build the scriptPubKey for both compressed and uncompressed public keys.
let possible_script_pubkeys = vec![
Builder::build_p2pk(&keys::Public::Compressed(pubkey.serialize().into())),
Builder::build_p2pk(&keys::Public::Normal(pubkey.serialize_uncompressed().into())),
];

Choose a reason for hiding this comment

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

why can't output_scripts_p2pk be used here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

because it's in another crate the depends on this one. but i prefer the verbosity here anyway tbh.

Comment on lines +95 to +100
pub fn to_secp256k1_pubkey(&self) -> Result<PublicKey, SecpError> {
match self {
Public::Compressed(public) => PublicKey::from_slice(&**public),
Public::Normal(public) => PublicKey::from_slice(&**public),
}
}

Choose a reason for hiding this comment

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

Suggested change
pub fn to_secp256k1_pubkey(&self) -> Result<PublicKey, SecpError> {
match self {
Public::Compressed(public) => PublicKey::from_slice(&**public),
Public::Normal(public) => PublicKey::from_slice(&**public),
}
}
pub fn to_secp256k1_pubkey(&self) -> Result<PublicKey, SecpError> { PublicKey::from_slice(self) }

Choose a reason for hiding this comment

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

pretty sure new rustc will complain and suggest similar code

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

#2410 (comment)

we can let this throw for now and the other PR will fix it. but i can fix it here if u want too.

Comment on lines +40 to +51
pub fn address_hash(&self) -> H160 {
match self {
Public::Compressed(public) => dhash160(public.as_slice()),
// If the public key isn't compressed, we wanna compress it then get the hash.
// No body uses the uncompressed form to get an address hash.
Public::Normal(public) => match PublicKey::from_slice(public.as_slice()) {
Ok(public) => dhash160(&public.serialize()),
// This should never happen, as then the public key would be invalid. If so, return a dummy value.
Err(_) => H160::default(),
},
}
}

Choose a reason for hiding this comment

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

Suggested change
pub fn address_hash(&self) -> H160 {
match self {
Public::Compressed(public) => dhash160(public.as_slice()),
// If the public key isn't compressed, we wanna compress it then get the hash.
// No body uses the uncompressed form to get an address hash.
Public::Normal(public) => match PublicKey::from_slice(public.as_slice()) {
Ok(public) => dhash160(&public.serialize()),
// This should never happen, as then the public key would be invalid. If so, return a dummy value.
Err(_) => H160::default(),
},
}
}
pub fn address_hash(&self) -> H160 {
match self {
Public::Compressed(public) => dhash160(public.as_slice()),
// If the public key isn't compressed, we wanna compress it then get the hash.
// No body uses the uncompressed form to get an address hash.
Public::Normal(public) => PublicKey::from_slice(public.as_slice())
.map(|public| dhash160(&public.serialize()))
.unwrap_or_default(),
}
}

Comment on lines +43 to +44
// If the public key isn't compressed, we wanna compress it then get the hash.
// No body uses the uncompressed form to get an address hash.

Choose a reason for hiding this comment

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

It's better to have this as doc comment i guess.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

i think it's gonna be confusing, esp that this is a very legacy edge case that doesn't happen in day to day.

Comment on lines +47 to +48
// This should never happen, as then the public key would be invalid. If so, return a dummy value.
Err(_) => H160::default(),
Copy link
Collaborator

Choose a reason for hiding this comment

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

This looks fishy. If this should never happen then put unreachable!() here. Let's say it did happen, with the current approach we will most likely miss it. If it never happens, then unreachable will not be harmful anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

it indeed looks fishy 😂

i just prefer errors over panics, but this one is a utility func that's used everywhere and hard to be made fallible.
returning such invalid/dummy data tho will screw whatever operation being done down the call stack.

there is only this place https://github.com/KomodoPlatform/komodo-defi-framework/blob/5e697997585e70a8bbc01dbc756d330f7b1e5f68/mm2src/mm2_bitcoin/script/src/script.rs#L415
where a public key is provided not by us, but from the blockchain (or a lying rpc server), so it's not completely safe to panic given that there is an exterior entity that could provide that pubkey. otherwise, all pubkeys delt with within mm2 should be always valid.

Copy link
Collaborator

Choose a reason for hiding this comment

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

On KDF I do prefer errors over panics as well, but the comment says this should never happen, so either the comment is wrong or the code needs to change.

I would create 2 functions instead of trying to handle infallible/fallible calls from a single function: address_hash_unchecked that panics and address_hash with Result (or address_hash that panics try_address_hash with Result.).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

seams reasonable 👍

@shamardy shamardy marked this pull request as draft June 11, 2025 03:02
@shamardy shamardy removed the request for review from dimxy January 3, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement: wallet priority: medium Moderately important tasks that should be completed but are not urgent. status: pending review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants