Skip to main content

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;