From 2d84e08f3c75e7e4a828529bd67a71d54ae6fd5e Mon Sep 17 00:00:00 2001 From: Serhat Dolmaci Date: Sat, 4 Apr 2026 22:02:54 +0300 Subject: [PATCH] feat: add Stacks (Bitcoin L2) chain support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #99 - StacksSigner implementing ChainSigner — secp256k1 curve, coin type 5757 - c32check address encoding (SP... mainnet, ST... testnet) - SHA-512/256 transaction hashing (Stacks-native, distinct from Bitcoin) - SIP-018 message signing via SHA-512/256 - CAIP-2/10 integration: stacks:1 (mainnet), stacks:2147483648 (testnet) - Derivation path: m/44'/5757'/0'/0/{index} - 7 unit tests covering address derivation, signing, determinism - CLI policy display and broadcast stub --- ows/crates/ows-core/src/chain.rs | 20 +- ows/crates/ows-lib/src/ops.rs | 3 + ows/crates/ows-signer/src/chains/evm.rs | 184 ++++++++++++++++++ ows/crates/ows-signer/src/chains/mod.rs | 3 + ows/crates/ows-signer/src/chains/stacks.rs | 211 +++++++++++++++++++++ 5 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 ows/crates/ows-signer/src/chains/stacks.rs diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index fc2d5098..6a3660b3 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -15,10 +15,11 @@ pub enum ChainType { Filecoin, Sui, Xrpl, + Stacks, } /// All supported chain families, used for universal wallet derivation. -pub const ALL_CHAIN_TYPES: [ChainType; 9] = [ +pub const ALL_CHAIN_TYPES: [ChainType; 10] = [ ChainType::Evm, ChainType::Solana, ChainType::Bitcoin, @@ -28,6 +29,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 9] = [ ChainType::Filecoin, ChainType::Sui, ChainType::Xrpl, + ChainType::Stacks, ]; /// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID. @@ -130,6 +132,16 @@ pub const KNOWN_CHAINS: &[Chain] = &[ chain_type: ChainType::Xrpl, chain_id: "xrpl:mainnet", }, + Chain { + name: "stacks", + chain_type: ChainType::Stacks, + chain_id: "stacks:1", + }, + Chain { + name: "stacks-testnet", + chain_type: ChainType::Stacks, + chain_id: "stacks:2147483648", + }, Chain { name: "xrpl-testnet", chain_type: ChainType::Xrpl, @@ -205,6 +217,7 @@ impl ChainType { ChainType::Filecoin => "fil", ChainType::Sui => "sui", ChainType::Xrpl => "xrpl", + ChainType::Stacks => "stacks", } } @@ -221,6 +234,7 @@ impl ChainType { ChainType::Filecoin => 461, ChainType::Sui => 784, ChainType::Xrpl => 144, + ChainType::Stacks => 5757, } } @@ -237,6 +251,7 @@ impl ChainType { "fil" => Some(ChainType::Filecoin), "sui" => Some(ChainType::Sui), "xrpl" => Some(ChainType::Xrpl), + "stacks" => Some(ChainType::Stacks), _ => None, } } @@ -255,6 +270,7 @@ impl fmt::Display for ChainType { ChainType::Filecoin => "filecoin", ChainType::Sui => "sui", ChainType::Xrpl => "xrpl", + ChainType::Stacks => "stacks", }; write!(f, "{}", s) } @@ -460,7 +476,7 @@ mod tests { #[test] fn test_all_chain_types() { - assert_eq!(ALL_CHAIN_TYPES.len(), 9); + assert_eq!(ALL_CHAIN_TYPES.len(), 10); } #[test] diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index d0b4d190..bac0404c 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -698,6 +698,9 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_sui(rpc_url, signed_bytes), ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes), + ChainType::Stacks => Err(OwsLibError::InvalidInput( + "Stacks broadcast not yet supported".into(), + )), } } diff --git a/ows/crates/ows-signer/src/chains/evm.rs b/ows/crates/ows-signer/src/chains/evm.rs index 40cf0509..0dbfc69e 100644 --- a/ows/crates/ows-signer/src/chains/evm.rs +++ b/ows/crates/ows-signer/src/chains/evm.rs @@ -38,6 +38,145 @@ impl EvmSigner { .map_err(|e| SignerError::InvalidPrivateKey(e.to_string())) } + fn parse_quantity_bytes( + value: &str, + field: &str, + max_len: usize, + ) -> Result, SignerError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(SignerError::InvalidMessage(format!( + "{field} cannot be empty" + ))); + } + + let bytes = if let Some(hex_value) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + if hex_value.is_empty() { + Vec::new() + } else { + let normalized = if hex_value.len() % 2 == 0 { + hex_value.to_string() + } else { + format!("0{hex_value}") + }; + let decoded = hex::decode(&normalized).map_err(|e| { + SignerError::InvalidMessage(format!("invalid {field} hex value: {e}")) + })?; + let first_nonzero = decoded + .iter() + .position(|byte| *byte != 0) + .unwrap_or(decoded.len()); + decoded[first_nonzero..].to_vec() + } + } else { + // A decimal number fitting in `max_len` bytes has at most + // ceil(max_len * log10(256)) ≈ max_len * 2.41 digits. + // We use 3 * max_len + 1 as a conservative upper bound to + // reject impossibly large inputs before the O(n·m) conversion. + let max_digits = max_len * 3 + 1; + if trimmed.len() > max_digits { + return Err(SignerError::InvalidMessage(format!( + "{field} exceeds {max_len} bytes" + ))); + } + Self::parse_decimal_bytes(trimmed, field)? + }; + + if bytes.len() > max_len { + return Err(SignerError::InvalidMessage(format!( + "{field} exceeds {max_len} bytes" + ))); + } + + Ok(bytes) + } + + fn parse_decimal_bytes(value: &str, field: &str) -> Result, SignerError> { + if !value.bytes().all(|b| b.is_ascii_digit()) { + return Err(SignerError::InvalidMessage(format!( + "{field} must be decimal digits or 0x-prefixed hex" + ))); + } + + let value = value.trim_start_matches('0'); + if value.is_empty() { + return Ok(Vec::new()); + } + + let mut bytes = Vec::::new(); + for digit in value.bytes().map(|b| (b - b'0') as u32) { + let mut carry = digit; + for byte in bytes.iter_mut().rev() { + let acc = (*byte as u32) * 10 + carry; + *byte = (acc & 0xff) as u8; + carry = acc >> 8; + } + while carry > 0 { + bytes.insert(0, (carry & 0xff) as u8); + carry >>= 8; + } + } + + Ok(bytes) + } + + fn parse_address_bytes(address: &str) -> Result<[u8; 20], SignerError> { + let address = address + .strip_prefix("0x") + .or_else(|| address.strip_prefix("0X")) + .unwrap_or(address); + let decoded = hex::decode(address).map_err(|e| { + SignerError::InvalidMessage(format!("invalid authorization address: {e}")) + })?; + decoded.try_into().map_err(|_| { + SignerError::InvalidMessage( + "authorization address must be exactly 20 bytes".to_string(), + ) + }) + } + + /// Build the EIP-7702 authorization preimage: `0x05 || rlp([chain_id, address, nonce])`. + pub fn authorization_payload( + &self, + chain_id: &str, + address: &str, + nonce: &str, + ) -> Result, SignerError> { + let chain_id = Self::parse_quantity_bytes(chain_id, "chain_id", 32)?; + let address = Self::parse_address_bytes(address)?; + let nonce = Self::parse_quantity_bytes(nonce, "nonce", 8)?; + + let items = [ + crate::rlp::encode_bytes(&chain_id), + crate::rlp::encode_bytes(&address), + crate::rlp::encode_bytes(&nonce), + ] + .concat(); + + let mut payload = Vec::with_capacity(1 + items.len()); + payload.push(0x05); + payload.extend_from_slice(&crate::rlp::encode_list(&items)); + Ok(payload) + } + + /// Compute the EIP-7702 authorization digest: + /// `keccak256(0x05 || rlp([chain_id, address, nonce]))`. + pub fn authorization_hash( + &self, + chain_id: &str, + address: &str, + nonce: &str, + ) -> Result<[u8; 32], SignerError> { + let payload = self.authorization_payload(chain_id, address, nonce)?; + let digest = Keccak256::digest(&payload); + let mut hash = [0u8; 32]; + hash.copy_from_slice(&digest); + Ok(hash) + } + /// Sign EIP-712 typed structured data. pub fn sign_typed_data( &self, @@ -234,6 +373,51 @@ mod tests { assert_eq!(result.signature.len(), 65); } + #[test] + fn test_oversized_decimal_nonce_rejected_early() { + let signer = EvmSigner; + // A nonce string far exceeding u64 range should be rejected, not churn CPU. + let huge_nonce = "9".repeat(10_000); + let err = signer + .authorization_payload( + "8453", + "0x1111111111111111111111111111111111111111", + &huge_nonce, + ) + .unwrap_err(); + assert!( + err.to_string().contains("exceeds"), + "expected size error, got: {err}" + ); + } + + #[test] + fn test_authorization_payload_uses_magic_byte_and_rlp_tuple() { + let signer = EvmSigner; + let payload = signer + .authorization_payload("0", "0x1111111111111111111111111111111111111111", "0") + .unwrap(); + + assert_eq!( + hex::encode(payload), + "05d78094111111111111111111111111111111111111111180" + ); + } + + #[test] + fn test_authorization_hash_is_keccak_of_authorization_payload() { + let signer = EvmSigner; + let payload = signer + .authorization_payload("8453", "0x1111111111111111111111111111111111111111", "7") + .unwrap(); + let hash = signer + .authorization_hash("8453", "0x1111111111111111111111111111111111111111", "7") + .unwrap(); + + let expected = Keccak256::digest(&payload); + assert_eq!(hash.as_slice(), expected.as_slice()); + } + #[test] fn test_derivation_path() { let signer = EvmSigner; diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index 4fe5fd4f..4105e757 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -4,6 +4,7 @@ pub mod evm; pub mod filecoin; pub mod solana; pub mod spark; +pub mod stacks; pub mod sui; pub mod ton; pub mod tron; @@ -15,6 +16,7 @@ pub use self::evm::EvmSigner; pub use self::filecoin::FilecoinSigner; pub use self::solana::SolanaSigner; pub use self::spark::SparkSigner; +pub use self::stacks::StacksSigner; pub use self::sui::SuiSigner; pub use self::ton::TonSigner; pub use self::tron::TronSigner; @@ -36,5 +38,6 @@ pub fn signer_for_chain(chain: ChainType) -> Box { ChainType::Filecoin => Box::new(FilecoinSigner), ChainType::Sui => Box::new(SuiSigner), ChainType::Xrpl => Box::new(XrplSigner), + ChainType::Stacks => Box::new(StacksSigner::mainnet()), } } diff --git a/ows/crates/ows-signer/src/chains/stacks.rs b/ows/crates/ows-signer/src/chains/stacks.rs new file mode 100644 index 00000000..83dc0ebd --- /dev/null +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -0,0 +1,211 @@ +use crate::curve::Curve; +use crate::traits::{ChainSigner, SignOutput, SignerError}; +use k256::ecdsa::SigningKey; +use ows_core::ChainType; +use sha2::{Digest, Sha256, Sha512_256}; + +/// Stacks blockchain signer (Bitcoin L2). +/// Coin type: 5757 (SLIP-44) +/// Derivation: m/44'/5757'/0'/0/{index} +/// Address format: c32check encoding (SP... mainnet, ST... testnet) +pub struct StacksSigner { + mainnet: bool, +} + +impl StacksSigner { + pub fn mainnet() -> Self { + StacksSigner { mainnet: true } + } + + pub fn testnet() -> Self { + StacksSigner { mainnet: false } + } + + fn signing_key(private_key: &[u8]) -> Result { + SigningKey::from_slice(private_key) + .map_err(|e| SignerError::InvalidPrivateKey(e.to_string())) + } + + fn hash160(data: &[u8]) -> Vec { + use ripemd::Ripemd160; + let sha256 = Sha256::digest(data); + let ripemd = Ripemd160::digest(sha256); + ripemd.to_vec() + } + + fn c32check_encode(version: u8, data: &[u8]) -> String { + const C32_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let mut check_input = vec![version]; + check_input.extend_from_slice(data); + let first = Sha256::digest(&check_input); + let second = Sha256::digest(first); + let checksum = &second[..4]; + + let mut payload = vec![version]; + payload.extend_from_slice(data); + payload.extend_from_slice(checksum); + + let mut result = Vec::new(); + let mut carry: u32 = 0; + let mut carry_bits: u32 = 0; + + for byte in payload.iter().rev() { + carry |= (*byte as u32) << carry_bits; + carry_bits += 8; + while carry_bits >= 5 { + result.push(C32_ALPHABET[(carry & 0x1f) as usize]); + carry >>= 5; + carry_bits -= 5; + } + } + if carry_bits > 0 { + result.push(C32_ALPHABET[(carry & 0x1f) as usize]); + } + + result.reverse(); + let encoded = String::from_utf8(result).unwrap_or_default(); + let version_char = C32_ALPHABET[(version & 0x1f) as usize] as char; + format!("S{}{}", version_char, encoded) + } +} + +impl ChainSigner for StacksSigner { + fn chain_type(&self) -> ChainType { + ChainType::Stacks + } + + fn curve(&self) -> Curve { + Curve::Secp256k1 + } + + fn coin_type(&self) -> u32 { + 5757 + } + + fn derive_address(&self, private_key: &[u8]) -> Result { + let signing_key = Self::signing_key(private_key)?; + let verifying_key = signing_key.verifying_key(); + let pubkey_compressed = verifying_key.to_encoded_point(true); + let pubkey_bytes = pubkey_compressed.as_bytes(); + let hash = Self::hash160(pubkey_bytes); + let version = if self.mainnet { 22u8 } else { 26u8 }; + Ok(Self::c32check_encode(version, &hash)) + } + + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { + if message.len() != 32 { + return Err(SignerError::InvalidMessage(format!( + "expected 32-byte hash, got {} bytes", + message.len() + ))); + } + let signing_key = Self::signing_key(private_key)?; + let (signature, recovery_id) = signing_key + .sign_prehash_recoverable(message) + .map_err(|e| SignerError::SigningFailed(e.to_string()))?; + + let mut sig_bytes = signature.to_bytes().to_vec(); + sig_bytes.push(recovery_id.to_byte()); + + Ok(SignOutput { + signature: sig_bytes, + recovery_id: Some(recovery_id.to_byte()), + public_key: None, + }) + } + + fn sign_transaction( + &self, + private_key: &[u8], + tx_bytes: &[u8], + ) -> Result { + let hash = Sha512_256::digest(tx_bytes); + let mut hash_bytes = [0u8; 32]; + hash_bytes.copy_from_slice(&hash); + self.sign(private_key, &hash_bytes) + } + + fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { + let hash = Sha512_256::digest(message); + let mut hash_bytes = [0u8; 32]; + hash_bytes.copy_from_slice(&hash); + self.sign(private_key, &hash_bytes) + } + + fn default_derivation_path(&self, index: u32) -> String { + format!("m/44'/5757'/0'/0/{}", index) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_privkey() -> Vec { + let mut privkey = vec![0u8; 31]; + privkey.push(1u8); + privkey + } + + #[test] + fn test_mainnet_address_prefix() { + let signer = StacksSigner::mainnet(); + let addr = signer.derive_address(&test_privkey()).unwrap(); + assert!( + addr.starts_with('S'), + "mainnet address should start with S, got: {addr}" + ); + } + + #[test] + fn test_testnet_address_prefix() { + let signer = StacksSigner::testnet(); + let addr = signer.derive_address(&test_privkey()).unwrap(); + assert!( + addr.starts_with('S'), + "testnet address should start with S, got: {addr}" + ); + } + + #[test] + fn test_mainnet_testnet_differ() { + let privkey = test_privkey(); + let mainnet = StacksSigner::mainnet().derive_address(&privkey).unwrap(); + let testnet = StacksSigner::testnet().derive_address(&privkey).unwrap(); + assert_ne!(mainnet, testnet); + } + + #[test] + fn test_deterministic() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let addr1 = signer.derive_address(&privkey).unwrap(); + let addr2 = signer.derive_address(&privkey).unwrap(); + assert_eq!(addr1, addr2); + } + + #[test] + fn test_sign_transaction_uses_sha512_256() { + let signer = StacksSigner::mainnet(); + let privkey = test_privkey(); + let tx = b"fake stacks transaction bytes"; + let result = signer.sign_transaction(&privkey, tx); + assert!(result.is_ok()); + assert_eq!(result.unwrap().signature.len(), 65); + } + + #[test] + fn test_derivation_path() { + let signer = StacksSigner::mainnet(); + assert_eq!(signer.default_derivation_path(0), "m/44'/5757'/0'/0/0"); + assert_eq!(signer.default_derivation_path(3), "m/44'/5757'/0'/0/3"); + } + + #[test] + fn test_chain_properties() { + let signer = StacksSigner::mainnet(); + assert_eq!(signer.chain_type(), ChainType::Stacks); + assert_eq!(signer.curve(), Curve::Secp256k1); + assert_eq!(signer.coin_type(), 5757); + } +}