From 2aa0ca5842df4008631827fcd30f6539ef6062a6 Mon Sep 17 00:00:00 2001 From: Antonio Date: Tue, 24 Mar 2026 19:14:11 -0600 Subject: [PATCH 1/7] [FEATURE] Add stacks chain support --- ows/crates/ows-core/src/chain.rs | 20 +- ows/crates/ows-lib/src/ops.rs | 3 + ows/crates/ows-signer/src/chains/mod.rs | 3 + ows/crates/ows-signer/src/chains/stacks.rs | 299 +++++++++++++++++++++ ows/crates/ows-signer/src/lib.rs | 15 ++ 5 files changed, 338 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 c857d860..263b70f2 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -14,10 +14,11 @@ pub enum ChainType { Spark, Filecoin, Sui, + Stacks, } /// All supported chain families, used for universal wallet derivation. -pub const ALL_CHAIN_TYPES: [ChainType; 8] = [ +pub const ALL_CHAIN_TYPES: [ChainType; 9] = [ ChainType::Evm, ChainType::Solana, ChainType::Bitcoin, @@ -26,6 +27,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 8] = [ ChainType::Ton, ChainType::Filecoin, ChainType::Sui, + ChainType::Stacks, ]; /// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID. @@ -113,6 +115,11 @@ pub const KNOWN_CHAINS: &[Chain] = &[ chain_type: ChainType::Sui, chain_id: "sui:mainnet", }, + Chain { + name: "stacks", + chain_type: ChainType::Stacks, + chain_id: "stacks:1", + }, ]; /// Parse a chain string into a `Chain`. Accepts: @@ -177,6 +184,7 @@ impl ChainType { ChainType::Spark => "spark", ChainType::Filecoin => "fil", ChainType::Sui => "sui", + ChainType::Stacks => "stacks", } } @@ -192,6 +200,7 @@ impl ChainType { ChainType::Spark => 8797555, ChainType::Filecoin => 461, ChainType::Sui => 784, + ChainType::Stacks => 5757, } } @@ -207,6 +216,7 @@ impl ChainType { "spark" => Some(ChainType::Spark), "fil" => Some(ChainType::Filecoin), "sui" => Some(ChainType::Sui), + "stacks" => Some(ChainType::Stacks), _ => None, } } @@ -224,6 +234,7 @@ impl fmt::Display for ChainType { ChainType::Spark => "spark", ChainType::Filecoin => "filecoin", ChainType::Sui => "sui", + ChainType::Stacks => "stacks", }; write!(f, "{}", s) } @@ -243,6 +254,7 @@ impl FromStr for ChainType { "spark" => Ok(ChainType::Spark), "filecoin" => Ok(ChainType::Filecoin), "sui" => Ok(ChainType::Sui), + "stacks" => Ok(ChainType::Stacks), _ => Err(format!("unknown chain type: {}", s)), } } @@ -273,6 +285,7 @@ mod tests { (ChainType::Spark, "\"spark\""), (ChainType::Filecoin, "\"filecoin\""), (ChainType::Sui, "\"sui\""), + (ChainType::Stacks, "\"stacks\""), ] { let json = serde_json::to_string(&chain).unwrap(); assert_eq!(json, expected); @@ -292,6 +305,7 @@ mod tests { assert_eq!(ChainType::Spark.namespace(), "spark"); assert_eq!(ChainType::Filecoin.namespace(), "fil"); assert_eq!(ChainType::Sui.namespace(), "sui"); + assert_eq!(ChainType::Stacks.namespace(), "stacks"); } #[test] @@ -305,6 +319,7 @@ mod tests { assert_eq!(ChainType::Spark.default_coin_type(), 8797555); assert_eq!(ChainType::Filecoin.default_coin_type(), 461); assert_eq!(ChainType::Sui.default_coin_type(), 784); + assert_eq!(ChainType::Stacks.default_coin_type(), 5757); } #[test] @@ -321,6 +336,7 @@ mod tests { assert_eq!(ChainType::from_namespace("spark"), Some(ChainType::Spark)); assert_eq!(ChainType::from_namespace("fil"), Some(ChainType::Filecoin)); assert_eq!(ChainType::from_namespace("sui"), Some(ChainType::Sui)); + assert_eq!(ChainType::from_namespace("stacks"), Some(ChainType::Stacks)); assert_eq!(ChainType::from_namespace("unknown"), None); } @@ -372,7 +388,7 @@ mod tests { #[test] fn test_all_chain_types() { - assert_eq!(ALL_CHAIN_TYPES.len(), 8); + assert_eq!(ALL_CHAIN_TYPES.len(), 9); } #[test] diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 6eca56b0..f993d394 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -688,6 +688,9 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_sui(rpc_url, signed_bytes), + ChainType::Stacks => Err(OwsLibError::InvalidInput( + "broadcast not yet supported for Stacks".into(), + )), } } diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index b73d8a24..949c6fad 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; @@ -14,6 +15,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; @@ -32,6 +34,7 @@ pub fn signer_for_chain(chain: ChainType) -> Box { ChainType::Ton => Box::new(TonSigner), ChainType::Spark => Box::new(SparkSigner), ChainType::Filecoin => Box::new(FilecoinSigner), + ChainType::Stacks => Box::new(StacksSigner::mainnet()), ChainType::Sui => Box::new(SuiSigner), } } 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..dc7de0c4 --- /dev/null +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -0,0 +1,299 @@ +use crate::curve::Curve; +use crate::traits::{ChainSigner, SignOutput, SignerError}; +use k256::ecdsa::SigningKey; +use ows_core::ChainType; +use ripemd::Ripemd160; +use sha2::{Digest, Sha256}; + +/// Crockford Base32 alphabet used by c32check encoding. +const C32_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// Stacks chain signer (c32check addresses, secp256k1). +pub struct StacksSigner { + /// Address version byte: 22 for mainnet (SP), 26 for testnet (ST). + version: u8, +} + +impl StacksSigner { + pub fn new(version: u8) -> Self { + StacksSigner { version } + } + + pub fn mainnet() -> Self { + Self::new(22) + } + + pub fn testnet() -> Self { + Self::new(26) + } + + fn signing_key(private_key: &[u8]) -> Result { + SigningKey::from_slice(private_key) + .map_err(|e| SignerError::InvalidPrivateKey(e.to_string())) + } + + /// Hash160: RIPEMD160(SHA256(data)) + fn hash160(data: &[u8]) -> Vec { + let sha256 = Sha256::digest(data); + let ripemd = Ripemd160::digest(sha256); + ripemd.to_vec() + } + + /// Encode bytes using c32check encoding with the given version byte. + /// + /// 1. Compute checksum: first 4 bytes of SHA256(SHA256(version || data)) + /// 2. Encode (version || data || checksum) as a big integer in Crockford Base32 + /// 3. Prepend 'S' prefix + fn c32check_encode(version: u8, data: &[u8]) -> String { + // Compute checksum + let mut check_data = Vec::with_capacity(1 + data.len()); + check_data.push(version); + check_data.extend_from_slice(data); + let checksum = &Sha256::digest(Sha256::digest(&check_data))[..4]; + + // Build the payload: version + data + checksum + // But c32check encodes (data + checksum) as big integer, then prepends c32-encoded version + let mut payload = Vec::with_capacity(data.len() + 4); + payload.extend_from_slice(data); + payload.extend_from_slice(checksum); + + // Encode payload as big integer in base32 + let c32_chars = Self::c32_encode(&payload); + + // Encode version character + let version_char = C32_ALPHABET[version as usize % 32] as char; + + // Prepend version and 'S' prefix + let mut result = String::with_capacity(2 + c32_chars.len()); + result.push('S'); + result.push(version_char); + result.push_str(&c32_chars); + + result + } + + /// Encode a byte slice as a big integer in Crockford Base32. + /// Preserves leading zero bytes as '0' characters. + fn c32_encode(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + + // Count leading zeros + let leading_zeros = data.iter().take_while(|&&b| b == 0).count(); + + // Convert bytes to base32 by treating as big integer + // Work with the bytes as a big-endian unsigned integer + let mut result = Vec::new(); + + // Use repeated division by 32 on a mutable byte array + let mut digits: Vec = data.to_vec(); + + loop { + if digits.is_empty() || (digits.len() == 1 && digits[0] == 0) { + break; + } + + // Remove leading zeros from working digits + while digits.len() > 1 && digits[0] == 0 { + digits.remove(0); + } + + if digits.len() == 1 && digits[0] == 0 { + break; + } + + // Divide the big integer (in base-256) by 32, collecting remainder + let mut remainder: u32 = 0; + let mut new_digits = Vec::with_capacity(digits.len()); + + for &d in &digits { + let acc = remainder * 256 + d as u32; + new_digits.push((acc / 32) as u8); + remainder = acc % 32; + } + + result.push(C32_ALPHABET[remainder as usize] as char); + + // Remove leading zeros from quotient + while new_digits.len() > 1 && new_digits[0] == 0 { + new_digits.remove(0); + } + + digits = new_digits; + + if digits.len() == 1 && digits[0] == 0 { + break; + } + } + + // Add leading zero characters + for _ in 0..leading_zeros { + result.push('0'); + } + + result.reverse(); + result.into_iter().collect() + } +} + +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 address = Self::c32check_encode(self.version, &hash); + + Ok(address) + } + + 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 = Sha256::digest(Sha256::digest(tx_bytes)); + self.sign(private_key, &hash) + } + + fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { + let hash = Sha256::digest(Sha256::digest(message)); + self.sign(private_key, &hash) + } + + 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_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); + } + + #[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_address_starts_with_sp_mainnet() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let address = signer.derive_address(&privkey).unwrap(); + assert!( + address.starts_with("SP"), + "mainnet address should start with SP, got: {}", + address + ); + } + + #[test] + fn test_address_starts_with_st_testnet() { + let privkey = test_privkey(); + let signer = StacksSigner::testnet(); + let address = signer.derive_address(&privkey).unwrap(); + assert!( + address.starts_with("ST"), + "testnet address should start with ST, got: {}", + address + ); + } + + #[test] + fn test_known_address_generator_point() { + // Private key = 1 (secp256k1 generator point) + // Verified against reference c32check implementation + let privkey = test_privkey(); + let address = StacksSigner::mainnet().derive_address(&privkey).unwrap(); + assert_eq!(address, "SP1THWXQ8368SDN2MJGE4BMDKMCHZ2GSVTS1X0BPM"); + } + + #[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_message() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let result = signer.sign_message(&privkey, b"hello stacks").unwrap(); + assert!(!result.signature.is_empty()); + assert!(result.recovery_id.is_some()); + } + + #[test] + fn test_sign_transaction() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let result = signer.sign_transaction(&privkey, b"fake tx data").unwrap(); + assert!(!result.signature.is_empty()); + assert!(result.recovery_id.is_some()); + } + + #[test] + fn test_sign_requires_32_byte_hash() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let result = signer.sign(&privkey, b"too short"); + assert!(result.is_err()); + } +} diff --git a/ows/crates/ows-signer/src/lib.rs b/ows/crates/ows-signer/src/lib.rs index 4ee8cfb5..f1eb810a 100644 --- a/ows/crates/ows-signer/src/lib.rs +++ b/ows/crates/ows-signer/src/lib.rs @@ -123,6 +123,17 @@ mod integration_tests { ); } + #[test] + fn test_full_pipeline_stacks() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let address = derive_address_for_chain(&mnemonic, ChainType::Stacks); + assert!( + address.starts_with("SP"), + "Stacks address should start with SP, got: {}", + address + ); + } + #[test] fn test_spark_uses_bitcoin_derivation_path() { let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); @@ -165,6 +176,7 @@ mod integration_tests { let ton_addr = derive_address_for_chain(&mnemonic, ChainType::Ton); let spark_addr = derive_address_for_chain(&mnemonic, ChainType::Spark); let fil_addr = derive_address_for_chain(&mnemonic, ChainType::Filecoin); + let stx_addr = derive_address_for_chain(&mnemonic, ChainType::Stacks); // All addresses should be different let addrs = [ @@ -176,6 +188,7 @@ mod integration_tests { &ton_addr, &spark_addr, &fil_addr, + &stx_addr, ]; for i in 0..addrs.len() { for j in (i + 1)..addrs.len() { @@ -203,6 +216,7 @@ mod integration_tests { ChainType::Tron, ChainType::Spark, ChainType::Filecoin, + ChainType::Stacks, ] { let signer = signer_for_chain(chain); let path = signer.default_derivation_path(0); @@ -245,6 +259,7 @@ mod integration_tests { ChainType::Ton, ChainType::Spark, ChainType::Filecoin, + ChainType::Stacks, ] { let signer = signer_for_chain(chain); assert_eq!(signer.chain_type(), chain); From 8b24ab3e6749595ec6a419ffe619b1d90fca1cb8 Mon Sep 17 00:00:00 2001 From: Antonio Date: Tue, 24 Mar 2026 19:18:50 -0600 Subject: [PATCH 2/7] [FEATURE] Update stacks chain support --- ows/crates/ows-signer/src/chains/stacks.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/stacks.rs b/ows/crates/ows-signer/src/chains/stacks.rs index dc7de0c4..4ab779ad 100644 --- a/ows/crates/ows-signer/src/chains/stacks.rs +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -128,9 +128,7 @@ impl StacksSigner { } // Add leading zero characters - for _ in 0..leading_zeros { - result.push('0'); - } + result.extend(std::iter::repeat_n('0', leading_zeros)); result.reverse(); result.into_iter().collect() From 92a18a12d1c74d073768b3b156a770fcbf7b7483 Mon Sep 17 00:00:00 2001 From: Antonio Date: Thu, 26 Mar 2026 00:23:21 -0600 Subject: [PATCH 3/7] [FEATURE] Add Stacks chain support with signing and broadcast --- ows/crates/ows-core/src/config.rs | 6 +- ows/crates/ows-lib/src/ops.rs | 60 ++++++- ows/crates/ows-signer/src/chains/stacks.rs | 195 +++++++++++++++++++-- 3 files changed, 243 insertions(+), 18 deletions(-) diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 3d45a2d1..55323e8b 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -63,6 +63,10 @@ impl Config { "sui:mainnet".into(), "https://fullnode.mainnet.sui.io:443".into(), ); + rpc.insert( + "stacks:1".into(), + "https://api.hiro.so".into(), + ); rpc } } @@ -242,7 +246,7 @@ mod tests { fn test_load_or_default_nonexistent() { let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json")); // Should have all default RPCs - assert_eq!(config.rpc.len(), 14); + assert_eq!(config.rpc.len(), 15); assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com")); } diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index f993d394..aa2b7057 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -688,9 +688,7 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_sui(rpc_url, signed_bytes), - ChainType::Stacks => Err(OwsLibError::InvalidInput( - "broadcast not yet supported for Stacks".into(), - )), + ChainType::Stacks => broadcast_stacks(rpc_url, signed_bytes), } } @@ -805,6 +803,62 @@ fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result Result { + // Hiro API: POST /v2/transactions with raw binary body. + // curl needs a file for --data-binary with raw bytes. + let url = format!("{}/v2/transactions", rpc_url.trim_end_matches('/')); + + let random_suffix: u64 = rand::random(); + let tmp = std::env::temp_dir().join(format!("ows_stacks_tx_{random_suffix}.bin")); + std::fs::write(&tmp, signed_bytes) + .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to write temp file: {e}")))?; + + let file_arg = format!("@{}", tmp.display()); + let result = Command::new("curl") + .args([ + "-sSL", + "-X", + "POST", + "-H", + "Content-Type: application/octet-stream", + "--data-binary", + &file_arg, + &url, + ]) + .output(); + + // Always clean up the temp file, even if curl failed to launch. + let _ = std::fs::remove_file(&tmp); + + let output = + result.map_err(|e| OwsLibError::BroadcastFailed(format!("failed to run curl: {e}")))?; + + let resp = String::from_utf8_lossy(&output.stdout).to_string(); + + if let Ok(parsed) = serde_json::from_str::(&resp) { + if let Some(err) = parsed.get("error") { + let reason = parsed + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + return Err(OwsLibError::BroadcastFailed(format!( + "stacks broadcast error: {err} — {reason}" + ))); + } + if let Some(txid) = parsed.as_str() { + return Ok(txid.to_string()); + } + } + + if !output.status.success() { + return Err(OwsLibError::BroadcastFailed(format!( + "stacks broadcast failed: {resp}" + ))); + } + + Ok(resp.trim().trim_matches('"').to_string()) +} + fn curl_post_json(url: &str, body: &str) -> Result { let output = Command::new("curl") .args([ diff --git a/ows/crates/ows-signer/src/chains/stacks.rs b/ows/crates/ows-signer/src/chains/stacks.rs index 4ab779ad..f155af76 100644 --- a/ows/crates/ows-signer/src/chains/stacks.rs +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -3,11 +3,19 @@ use crate::traits::{ChainSigner, SignOutput, SignerError}; use k256::ecdsa::SigningKey; use ows_core::ChainType; use ripemd::Ripemd160; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256, Sha512_256}; /// Crockford Base32 alphabet used by c32check encoding. const C32_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +/// Byte offset of the 65-byte VRS signature in a serialized Stacks transaction. +/// Layout: version(1) + chain_id(4) + auth_type(1) + hash_mode(1) + signer(20) + +/// nonce(8) + fee(8) + key_encoding(1) = 44 +/// +/// This layout applies to standard single-sig (P2PKH) spending conditions only. +/// Multi-sig and sponsored transactions have a different auth structure. +pub const STACKS_SIG_OFFSET: usize = 44; + /// Stacks chain signer (c32check addresses, secp256k1). pub struct StacksSigner { /// Address version byte: 22 for mainnet (SP), 26 for testnet (ST). @@ -149,10 +157,18 @@ impl ChainSigner for StacksSigner { } fn derive_address(&self, private_key: &[u8]) -> Result { - let signing_key = Self::signing_key(private_key)?; + // Stacks convention: 33-byte key (trailing 0x01) → compressed pubkey, + // 32-byte key → uncompressed pubkey. Matches @stacks/transactions behavior. + let (key_bytes, compressed) = if private_key.len() == 33 && private_key[32] == 0x01 { + (&private_key[..32], true) + } else { + (private_key, false) + }; + + let signing_key = Self::signing_key(key_bytes)?; let verifying_key = signing_key.verifying_key(); - let pubkey_compressed = verifying_key.to_encoded_point(true); - let pubkey_bytes = pubkey_compressed.as_bytes(); + let pubkey_point = verifying_key.to_encoded_point(compressed); + let pubkey_bytes = pubkey_point.as_bytes(); let hash = Self::hash160(pubkey_bytes); let address = Self::c32check_encode(self.version, &hash); @@ -168,13 +184,22 @@ impl ChainSigner for StacksSigner { ))); } - let signing_key = Self::signing_key(private_key)?; + let key_bytes = if private_key.len() == 33 && private_key[32] == 0x01 { + &private_key[..32] + } else { + private_key + }; + + let signing_key = Self::signing_key(key_bytes)?; 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(); + // Stacks uses VRS format: recovery_id (1 byte) || r (32 bytes) || s (32 bytes) + let r_s = signature.to_bytes(); + let mut sig_bytes = Vec::with_capacity(65); sig_bytes.push(recovery_id.to_byte()); + sig_bytes.extend_from_slice(&r_s); Ok(SignOutput { signature: sig_bytes, @@ -188,12 +213,85 @@ impl ChainSigner for StacksSigner { private_key: &[u8], tx_bytes: &[u8], ) -> Result { - let hash = Sha256::digest(Sha256::digest(tx_bytes)); - self.sign(private_key, &hash) + // Stacks transaction signing matches @stacks/transactions: + // 1. Clear auth fields (nonce, fee, key_encoding, signature) to get "initial" form + // 2. initial_sighash = SHA-512/256(cleared_tx) + // 3. presign_hash = SHA-512/256(initial_sighash || auth_type || fee || nonce) + // 4. sign(presign_hash) + if tx_bytes.len() < STACKS_SIG_OFFSET + 65 { + return Err(SignerError::InvalidTransaction( + "stacks transaction too short".into(), + )); + } + + let auth_type = tx_bytes[5]; + let nonce = &tx_bytes[27..35]; + let fee = &tx_bytes[35..43]; + + // 1. Clear auth fields for initial sighash (matching intoInitialSighashAuth) + // clearCondition zeros: nonce, fee, signature. NOT key_encoding. + let mut cleared = tx_bytes.to_vec(); + // Zero nonce (bytes 27-34) + cleared[27..35].fill(0); + // Zero fee (bytes 35-42) + cleared[35..43].fill(0); + // key_encoding (byte 43) is NOT cleared — matches @stacks/transactions + // Zero signature (bytes 44-108) + cleared[STACKS_SIG_OFFSET..STACKS_SIG_OFFSET + 65].fill(0); + + // 2. Initial sighash + let initial_sighash = Sha512_256::digest(&cleared); + + // 3. Presign hash: SHA-512/256(sighash || auth_type || fee || nonce) + let mut presign_input = Vec::with_capacity(32 + 1 + 8 + 8); + presign_input.extend_from_slice(&initial_sighash); + presign_input.push(auth_type); + presign_input.extend_from_slice(fee); + presign_input.extend_from_slice(nonce); + let presign_hash = Sha512_256::digest(&presign_input); + + self.sign(private_key, &presign_hash) + } + + fn encode_signed_transaction( + &self, + tx_bytes: &[u8], + signature: &SignOutput, + ) -> Result, SignerError> { + if signature.signature.len() != 65 { + return Err(SignerError::InvalidTransaction( + "expected 65-byte VRS signature".into(), + )); + } + if tx_bytes.len() < STACKS_SIG_OFFSET + 65 { + return Err(SignerError::InvalidTransaction( + "stacks transaction too short".into(), + )); + } + + // Copy the unsigned tx and inject the VRS signature at the auth offset + let mut signed = tx_bytes.to_vec(); + signed[STACKS_SIG_OFFSET..STACKS_SIG_OFFSET + 65] + .copy_from_slice(&signature.signature); + Ok(signed) } fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { - let hash = Sha256::digest(Sha256::digest(message)); + // Stacks message signing: SHA-256 of prefixed message + // Format: 0x17 + "Stacks Signed Message:\n" + length_byte + message + if message.len() > 255 { + return Err(SignerError::InvalidMessage( + "stacks message signing supports max 255 bytes (single-byte length prefix)".into(), + )); + } + + let prefix = b"\x17Stacks Signed Message:\n"; + let mut data = Vec::with_capacity(prefix.len() + 1 + message.len()); + data.extend_from_slice(prefix); + data.push(message.len() as u8); + data.extend_from_slice(message); + + let hash = Sha256::digest(&data); self.sign(private_key, &hash) } @@ -252,11 +350,21 @@ mod tests { } #[test] - fn test_known_address_generator_point() { - // Private key = 1 (secp256k1 generator point) - // Verified against reference c32check implementation + fn test_known_address_generator_point_uncompressed() { + // Private key = 1 (secp256k1 generator point), 32 bytes → uncompressed pubkey + // Verified against @stacks/transactions getAddressFromPrivateKey let privkey = test_privkey(); let address = StacksSigner::mainnet().derive_address(&privkey).unwrap(); + assert_eq!(address, "SP28V4JZSYMM8ACMP1B38FAXG6M97P798MMKY9DW1"); + } + + #[test] + fn test_known_address_generator_point_compressed() { + // Private key = 1 with 0x01 suffix → compressed pubkey + // Verified against @stacks/transactions getAddressFromPrivateKey(key + '01') + let mut privkey = test_privkey(); + privkey.push(0x01); + let address = StacksSigner::mainnet().derive_address(&privkey).unwrap(); assert_eq!(address, "SP1THWXQ8368SDN2MJGE4BMDKMCHZ2GSVTS1X0BPM"); } @@ -278,15 +386,73 @@ mod tests { assert!(result.recovery_id.is_some()); } + #[test] + fn test_sign_message_hash_matches_stacks_js() { + // Verify the message hash matches @stacks/encryption hashMessage("hello stacks") + // hashMessage = SHA-256(0x17 + "Stacks Signed Message:\n" + len + message) + let prefix = b"\x17Stacks Signed Message:\n"; + let message = b"hello stacks"; + let mut data = Vec::new(); + data.extend_from_slice(prefix); + data.push(message.len() as u8); + data.extend_from_slice(message); + + let hash = Sha256::digest(&data); + let hash_hex = hex::encode(hash); + // This value was verified against @stacks/encryption hashMessage("hello stacks") + assert_eq!( + hash_hex, + "ce0bff208ed52c820b75cbe920554a6ae3eaba703182aa15051eb108ebdca4c4" + ); + } + #[test] fn test_sign_transaction() { let privkey = test_privkey(); let signer = StacksSigner::mainnet(); - let result = signer.sign_transaction(&privkey, b"fake tx data").unwrap(); - assert!(!result.signature.is_empty()); + // Minimal valid unsigned tx: 180 bytes with zeroed signature at offset 44 + let mut fake_tx = vec![0u8; 180]; + fake_tx[5] = 0x04; // auth_type = Standard + let result = signer.sign_transaction(&privkey, &fake_tx).unwrap(); + assert_eq!(result.signature.len(), 65); // VRS assert!(result.recovery_id.is_some()); } + #[test] + fn test_encode_signed_transaction() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let mut fake_tx = vec![0u8; 180]; + fake_tx[5] = 0x04; + let output = signer.sign_transaction(&privkey, &fake_tx).unwrap(); + let signed = signer + .encode_signed_transaction(&fake_tx, &output) + .unwrap(); + // Same length, signature injected at offset 44 + assert_eq!(signed.len(), 180); + assert_eq!(&signed[STACKS_SIG_OFFSET..STACKS_SIG_OFFSET + 65], &output.signature[..]); + // Rest of tx unchanged + assert_eq!(&signed[..STACKS_SIG_OFFSET], &fake_tx[..STACKS_SIG_OFFSET]); + assert_eq!(&signed[STACKS_SIG_OFFSET + 65..], &fake_tx[STACKS_SIG_OFFSET + 65..]); + } + + #[test] + fn test_sign_message_rejects_long_message() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let long_msg = vec![0x41u8; 256]; + let result = signer.sign_message(&privkey, &long_msg); + assert!(result.is_err()); + } + + #[test] + fn test_sign_transaction_rejects_short_input() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let result = signer.sign_transaction(&privkey, b"too short"); + assert!(result.is_err()); + } + #[test] fn test_sign_requires_32_byte_hash() { let privkey = test_privkey(); @@ -294,4 +460,5 @@ mod tests { let result = signer.sign(&privkey, b"too short"); assert!(result.is_err()); } + } From 8c9cb795bf2d1b5339647046ab8315f738ae5e75 Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 30 Mar 2026 23:39:17 -0600 Subject: [PATCH 4/7] fix(ows-signer): reject non-single-sig Stacks transactions Validate auth_type (0x04) and hash_mode (0x00) before signing or encoding Stacks transactions. The fixed byte offsets used for presign hashing and signature injection are only valid for standard P2PKH single-sig; multisig and sponsored layouts have different auth structures and would produce wrong signatures silently. Addresses review feedback on PR #115. --- ows/crates/ows-signer/src/chains/stacks.rs | 81 +++++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/ows/crates/ows-signer/src/chains/stacks.rs b/ows/crates/ows-signer/src/chains/stacks.rs index f155af76..00e75071 100644 --- a/ows/crates/ows-signer/src/chains/stacks.rs +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -35,6 +35,34 @@ impl StacksSigner { Self::new(26) } + /// Validate that the transaction uses standard single-sig (P2PKH) auth layout, + /// which is the only layout compatible with the fixed byte offsets in this signer. + fn validate_single_sig_tx(tx_bytes: &[u8]) -> Result<(), SignerError> { + if tx_bytes.len() < STACKS_SIG_OFFSET + 65 { + return Err(SignerError::InvalidTransaction( + "stacks transaction too short".into(), + )); + } + + let auth_type = tx_bytes[5]; + let hash_mode = tx_bytes[6]; + + if auth_type != 0x04 { + return Err(SignerError::InvalidTransaction(format!( + "unsupported stacks auth type 0x{:02x}: only standard auth (0x04) is supported", + auth_type + ))); + } + if hash_mode != 0x00 { + return Err(SignerError::InvalidTransaction(format!( + "unsupported stacks hash mode 0x{:02x}: only P2PKH single-sig (0x00) is supported", + hash_mode + ))); + } + + Ok(()) + } + fn signing_key(private_key: &[u8]) -> Result { SigningKey::from_slice(private_key) .map_err(|e| SignerError::InvalidPrivateKey(e.to_string())) @@ -218,11 +246,7 @@ impl ChainSigner for StacksSigner { // 2. initial_sighash = SHA-512/256(cleared_tx) // 3. presign_hash = SHA-512/256(initial_sighash || auth_type || fee || nonce) // 4. sign(presign_hash) - if tx_bytes.len() < STACKS_SIG_OFFSET + 65 { - return Err(SignerError::InvalidTransaction( - "stacks transaction too short".into(), - )); - } + Self::validate_single_sig_tx(tx_bytes)?; let auth_type = tx_bytes[5]; let nonce = &tx_bytes[27..35]; @@ -263,11 +287,7 @@ impl ChainSigner for StacksSigner { "expected 65-byte VRS signature".into(), )); } - if tx_bytes.len() < STACKS_SIG_OFFSET + 65 { - return Err(SignerError::InvalidTransaction( - "stacks transaction too short".into(), - )); - } + Self::validate_single_sig_tx(tx_bytes)?; // Copy the unsigned tx and inject the VRS signature at the auth offset let mut signed = tx_bytes.to_vec(); @@ -461,4 +481,45 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_sign_transaction_rejects_sponsored_auth() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let mut fake_tx = vec![0u8; 180]; + fake_tx[5] = 0x05; // auth_type = Sponsored (not Standard) + fake_tx[6] = 0x00; // hash_mode = P2PKH + let result = signer.sign_transaction(&privkey, &fake_tx); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("unsupported stacks auth type"), "got: {err}"); + } + + #[test] + fn test_sign_transaction_rejects_multisig_hash_mode() { + let privkey = test_privkey(); + let signer = StacksSigner::mainnet(); + let mut fake_tx = vec![0u8; 180]; + fake_tx[5] = 0x04; // auth_type = Standard + fake_tx[6] = 0x01; // hash_mode = P2SH multisig + let result = signer.sign_transaction(&privkey, &fake_tx); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("unsupported stacks hash mode"), "got: {err}"); + } + + #[test] + fn test_encode_signed_transaction_rejects_non_single_sig() { + let signer = StacksSigner::mainnet(); + let mut fake_tx = vec![0u8; 180]; + fake_tx[5] = 0x05; // Sponsored + fake_tx[6] = 0x00; + let fake_sig = SignOutput { + signature: vec![0u8; 65], + recovery_id: Some(0), + public_key: None, + }; + let result = signer.encode_signed_transaction(&fake_tx, &fake_sig); + assert!(result.is_err()); + } + } From eacf90981e5905bdb5923a2c4d71f5f7631dd01c Mon Sep 17 00:00:00 2001 From: Antonio Date: Mon, 30 Mar 2026 23:41:39 -0600 Subject: [PATCH 5/7] Add supported chain --- docs/07-supported-chains.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/07-supported-chains.md b/docs/07-supported-chains.md index 6a2961d6..d36f3524 100644 --- a/docs/07-supported-chains.md +++ b/docs/07-supported-chains.md @@ -21,7 +21,7 @@ type AssetId = `${ChainId}:${string}`; // e.g. "eip155:8453:native" (ETH on Base) ``` -The `native` token refers to the chain's native currency (ETH, SOL, SUI, BTC, ATOM, TRX, TON, etc.). +The `native` token refers to the chain's native currency (ETH, SOL, SUI, BTC, ATOM, TRX, TON, STX, etc.). ## Chain Families @@ -38,6 +38,7 @@ OWS groups chains into families that share a cryptographic curve and address der | Sui | ed25519 | 784 | `m/44'/784'/{index}'/0'/0'` | `0x` + BLAKE2b-256 hex (32 bytes) | `sui` | | Spark | secp256k1 | 8797555 | `m/84'/0'/0'/0/{index}` | `spark:` + compressed pubkey hex | `spark` | | Filecoin | secp256k1 | 461 | `m/44'/461'/0'/0/{index}` | `f1` + base32(blake2b-160) | `fil` | +| Stacks | secp256k1 | 5757 | `m/44'/5757'/0'/0/{index}` | c32check (`SP...`) | `stacks` | ## Known Networks @@ -67,6 +68,7 @@ Each network has a canonical chain identifier. Endpoint discovery and transport | Sui | `sui:mainnet` | | Spark | `spark:mainnet` | | Filecoin | `fil:mainnet` | +| Stacks | `stacks:1` | Implementations MAY ship convenience endpoint defaults, but those defaults are deployment choices rather than OWS interoperability requirements. @@ -90,6 +92,7 @@ ton → ton:mainnet sui → sui:mainnet spark → spark:mainnet filecoin → fil:mainnet +stacks → stacks:1 ``` Aliases MUST be resolved to full CAIP-2 identifiers before any processing. They MUST NOT appear in wallet files, policy files, or audit logs. @@ -112,7 +115,8 @@ Master Seed (512 bits via PBKDF2) ├── m/44'/607'/0' → TON Account 0 ├── m/44'/784'/0'/0'/0' → Sui Account 0 ├── m/84'/0'/0'/0/0 → Spark Account 0 - └── m/44'/461'/0'/0/0 → Filecoin Account 0 + ├── m/44'/461'/0'/0/0 → Filecoin Account 0 + └── m/44'/5757'/0'/0/0 → Stacks Account 0 ``` A single mnemonic derives accounts across all supported chains. The wallet file stores the encrypted mnemonic; the signer derives the appropriate private key using each chain's coin type and derivation path. From a69323cc682b1c80c1a81e2ac96b81657186d4e6 Mon Sep 17 00:00:00 2001 From: Antonio Date: Fri, 3 Apr 2026 10:33:57 -0600 Subject: [PATCH 6/7] [FIX] Tests --- ows/crates/ows-core/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 9b051bab..aa314087 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -248,7 +248,7 @@ mod tests { fn test_load_or_default_nonexistent() { let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json")); // Should have all default RPCs - assert_eq!(config.rpc.len(), 15); + assert_eq!(config.rpc.len(), 16); assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com")); } From e1753d544ff452d243e378976beaab3a814ace4b Mon Sep 17 00:00:00 2001 From: Antonio Date: Wed, 8 Apr 2026 16:12:37 -0600 Subject: [PATCH 7/7] Fix Tets --- ows/crates/ows-core/src/chain.rs | 4 ++-- ows/crates/ows-core/src/config.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index c9b4845b..2f56a2ec 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -21,7 +21,7 @@ pub enum ChainType { } /// All supported chain families, used for universal wallet derivation. -pub const ALL_CHAIN_TYPES: [ChainType; 10] = [ +pub const ALL_CHAIN_TYPES: [ChainType; 11] = [ ChainType::Evm, ChainType::Solana, ChainType::Bitcoin, @@ -593,7 +593,7 @@ mod tests { #[test] fn test_all_chain_types() { - assert_eq!(ALL_CHAIN_TYPES.len(), 10); + assert_eq!(ALL_CHAIN_TYPES.len(), 11); } #[test] diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 0c1f8cda..7323ef48 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -258,7 +258,7 @@ mod tests { fn test_load_or_default_nonexistent() { let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json")); // Should have all default RPCs - assert_eq!(config.rpc.len(), 19); + assert_eq!(config.rpc.len(), 20); assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com")); }