signer_spark/lib.rs
1//! Spark (Bitcoin L2) transaction signer built on secp256k1 ECDSA.
2//!
3//! Shares Bitcoin's cryptographic primitives (double-SHA256 sighash and
4//! [BIP-137](https://github.com/bitcoin/bips/blob/master/bip-0137.mediawiki)
5//! message signing) but derives its own `spark1…` bech32m address via the
6//! hash160 of the compressed public key.
7//!
8//! # Address derivation
9//!
10//! `Signer::address` emits the canonical `spark1…` bech32m address:
11//! `bech32m(hrp="spark", RIPEMD160(SHA256(compressed_pubkey)))`. This matches
12//! the address format expected by Spark L2 nodes and produced by
13//! `kobe-spark`.
14//!
15//! # Message signing
16//!
17//! [`SignMessage::sign_message`] signs with the BIP-137 header byte for a
18//! **compressed P2PKH** address (`v = 31 | 32`), matching the on-wire format
19//! of Bitcoin Core's `signmessage` so the resulting signature round-trips
20//! through any BIP-137 verifier.
21
22#![cfg_attr(not(feature = "std"), no_std)]
23
24extern crate alloc;
25
26use alloc::{string::String, vec::Vec};
27
28use bech32::{Bech32m, Hrp};
29use ripemd::Ripemd160;
30use sha2::{Digest, Sha256};
31use signer_btc::bitcoin_message_digest;
32pub use signer_primitives::{self, Sign, SignError, SignMessage, SignOutput};
33use signer_primitives::{Secp256k1Signer, delegate_secp256k1_ctors};
34
35/// BIP-137 header offset for a compressed P2PKH address (`27 + 4`).
36const BIP137_COMPRESSED_P2PKH_OFFSET: u8 = 31;
37
38/// Spark bech32m address HRP.
39const SPARK_HRP: &str = "spark";
40
41/// Spark transaction signer.
42///
43/// Newtype over [`Secp256k1Signer`]. The inner key is zeroized on drop.
44#[derive(Debug)]
45pub struct Signer(Secp256k1Signer);
46
47impl Signer {
48 delegate_secp256k1_ctors!();
49
50 /// Spark bech32m address (`spark1…`).
51 ///
52 /// Derivation: `bech32m(hrp="spark", RIPEMD160(SHA256(compressed_pubkey)))`.
53 ///
54 /// # Panics
55 ///
56 /// Panics only if bech32m encoding of a fixed 20-byte payload fails,
57 /// which is impossible given the hard-coded HRP and payload length.
58 #[must_use]
59 pub fn address(&self) -> String {
60 let pubkey = self.0.compressed_public_key();
61 let hash160 = Ripemd160::digest(Sha256::digest(&pubkey));
62 let hrp = Hrp::parse_unchecked(SPARK_HRP);
63 #[allow(
64 clippy::expect_used,
65 reason = "HRP and 20-byte hash160 are always valid bech32m inputs"
66 )]
67 bech32::encode::<Bech32m>(hrp, &hash160).expect("valid bech32m")
68 }
69
70 /// Compressed public key (33 bytes).
71 #[must_use]
72 pub fn public_key_bytes(&self) -> Vec<u8> {
73 self.0.compressed_public_key()
74 }
75
76 /// Compressed public key as hex (66 chars, no `0x` prefix).
77 #[must_use]
78 pub fn public_key_hex(&self) -> String {
79 hex::encode(self.0.compressed_public_key())
80 }
81
82 /// Verify an ECDSA signature against a 32-byte pre-hashed digest.
83 ///
84 /// Accepts 64-byte (`r || s`) or 65-byte (`r || s || v`) input;
85 /// the `v` byte is ignored for verification.
86 ///
87 /// # Errors
88 ///
89 /// Returns [`SignError::InvalidSignature`] on malformed input or
90 /// failed verification.
91 pub fn verify_hash(&self, hash: &[u8; 32], signature: &[u8]) -> Result<(), SignError> {
92 self.0.verify_prehash_any(hash, signature)
93 }
94
95 /// Sign a Spark transaction sighash preimage (Bitcoin-compatible).
96 ///
97 /// Hashes the input with `double_SHA256` and signs the digest. Returns a
98 /// [`SignOutput::Ecdsa`] with raw `v` (`0 | 1`).
99 ///
100 /// # Errors
101 ///
102 /// Returns an error if signing fails.
103 pub fn sign_transaction(&self, tx_bytes: &[u8]) -> Result<SignOutput, SignError> {
104 let digest: [u8; 32] = Sha256::digest(Sha256::digest(tx_bytes)).into();
105 self.0.sign_prehash_recoverable(&digest)
106 }
107}
108
109impl Sign for Signer {
110 type Error = SignError;
111
112 fn sign_hash(&self, hash: &[u8; 32]) -> Result<SignOutput, SignError> {
113 self.0.sign_prehash_recoverable(hash)
114 }
115}
116
117impl SignMessage for Signer {
118 /// **Framing**: BIP-137 Bitcoin signed message for the **compressed
119 /// P2PKH** address type — `double_SHA256("\x18Bitcoin Signed Message:\n"
120 /// || CompactSize(len) || message)`.
121 ///
122 /// Returns a 65-byte [`SignOutput::Ecdsa`] with `v = 31 | 32`, directly
123 /// consumable by any BIP-137 verifier.
124 fn sign_message(&self, message: &[u8]) -> Result<SignOutput, SignError> {
125 let digest = bitcoin_message_digest(message);
126 Ok(self
127 .0
128 .sign_prehash_recoverable(&digest)?
129 .with_v_offset(BIP137_COMPRESSED_P2PKH_OFFSET))
130 }
131}
132
133#[cfg(feature = "kobe")]
134impl Signer {
135 /// Create from a [`kobe_spark::DerivedAccount`].
136 ///
137 /// # Errors
138 ///
139 /// Returns an error if the private key is invalid.
140 pub fn from_derived(account: &kobe_spark::DerivedAccount) -> Result<Self, SignError> {
141 Self::from_bytes(account.private_key_bytes())
142 }
143}
144
145#[cfg(test)]
146mod tests;