diff --git a/docs/07-supported-chains.md b/docs/07-supported-chains.md index 2ebab67d..d7542798 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, XRP, BTC, ATOM, TRX, TON, etc.). +The `native` token refers to the chain's native currency (ETH, SOL, SUI, XRP, BTC, ATOM, TRX, TON, STX, etc.). ## Chain Families @@ -39,6 +39,7 @@ OWS groups chains into families that share a cryptographic curve and address der | XRPL | secp256k1 | 144 | `m/44'/144'/0'/0/{index}` | Base58Check (`r...`) | `xrpl` | | 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 @@ -73,6 +74,7 @@ Each network has a canonical chain identifier. Endpoint discovery and transport | XRPL | `xrpl: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. @@ -104,6 +106,7 @@ xrpl-testnet → xrpl:testnet xrpl-devnet → xrpl:devnet 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. @@ -127,7 +130,8 @@ Master Seed (512 bits via PBKDF2) ├── m/44'/784'/0'/0'/0' → Sui Account 0 ├── m/44'/144'/0'/0/0 → XRPL 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 ``` For mnemonic-based wallets, a single mnemonic derives accounts across all supported chains. Those wallet files store the encrypted mnemonic, and the signer derives the appropriate private key using each chain's coin type and derivation path. Wallets imported from raw private keys instead store encrypted curve-key material directly. diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index 1f741115..015ce2cf 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -15,12 +15,13 @@ pub enum ChainType { Spark, Filecoin, Sui, + Stacks, Xrpl, Nano, } /// 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, @@ -29,6 +30,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 10] = [ ChainType::Ton, ChainType::Filecoin, ChainType::Sui, + ChainType::Stacks, ChainType::Xrpl, ChainType::Nano, ]; @@ -160,6 +162,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", + }, Chain { name: "xrpl", chain_type: ChainType::Xrpl, @@ -277,6 +284,7 @@ impl ChainType { ChainType::Spark => "spark", ChainType::Filecoin => "fil", ChainType::Sui => "sui", + ChainType::Stacks => "stacks", ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", } @@ -294,6 +302,7 @@ impl ChainType { ChainType::Spark => 8797555, ChainType::Filecoin => 461, ChainType::Sui => 784, + ChainType::Stacks => 5757, ChainType::Xrpl => 144, ChainType::Nano => 165, } @@ -311,6 +320,7 @@ impl ChainType { "spark" => Some(ChainType::Spark), "fil" => Some(ChainType::Filecoin), "sui" => Some(ChainType::Sui), + "stacks" => Some(ChainType::Stacks), "xrpl" => Some(ChainType::Xrpl), "nano" => Some(ChainType::Nano), _ => None, @@ -330,6 +340,7 @@ impl fmt::Display for ChainType { ChainType::Spark => "spark", ChainType::Filecoin => "filecoin", ChainType::Sui => "sui", + ChainType::Stacks => "stacks", ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", }; @@ -351,6 +362,7 @@ impl FromStr for ChainType { "spark" => Ok(ChainType::Spark), "filecoin" => Ok(ChainType::Filecoin), "sui" => Ok(ChainType::Sui), + "stacks" => Ok(ChainType::Stacks), "xrpl" => Ok(ChainType::Xrpl), "nano" => Ok(ChainType::Nano), _ => Err(format!("unknown chain type: {}", s)), @@ -383,6 +395,7 @@ mod tests { (ChainType::Spark, "\"spark\""), (ChainType::Filecoin, "\"filecoin\""), (ChainType::Sui, "\"sui\""), + (ChainType::Stacks, "\"stacks\""), (ChainType::Xrpl, "\"xrpl\""), (ChainType::Nano, "\"nano\""), ] { @@ -404,6 +417,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"); assert_eq!(ChainType::Xrpl.namespace(), "xrpl"); assert_eq!(ChainType::Nano.namespace(), "nano"); } @@ -419,6 +433,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); assert_eq!(ChainType::Xrpl.default_coin_type(), 144); assert_eq!(ChainType::Nano.default_coin_type(), 165); } @@ -437,6 +452,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("xrpl"), Some(ChainType::Xrpl)); assert_eq!(ChainType::from_namespace("nano"), Some(ChainType::Nano)); assert_eq!(ChainType::from_namespace("unknown"), None); @@ -619,7 +635,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 abc8a87e..b5b548ba 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -64,6 +64,10 @@ impl Config { "sui:mainnet".into(), "https://fullnode.mainnet.sui.io:443".into(), ); + rpc.insert( + "stacks:1".into(), + "https://api.hiro.so".into(), + ); rpc.insert("xrpl:mainnet".into(), "https://s1.ripple.com:51234".into()); rpc.insert( "xrpl:testnet".into(), @@ -264,7 +268,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(), 21); + assert_eq!(config.rpc.len(), 22); 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 f4a4fee3..50707f12 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -701,6 +701,7 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_sui(rpc_url, signed_bytes), + ChainType::Stacks => broadcast_stacks(rpc_url, signed_bytes), ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes), ChainType::Nano => broadcast_nano(rpc_url, signed_bytes), } @@ -849,6 +850,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 broadcast_nano(rpc_url: &str, signed_bytes: &[u8]) -> Result { const STATE_BLOCK_LEN: usize = 176; const SIGNATURE_LEN: usize = 64; diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index b1488772..5767bceb 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -5,6 +5,7 @@ pub mod filecoin; pub mod nano; pub mod solana; pub mod spark; +pub mod stacks; pub mod sui; pub mod ton; pub mod tron; @@ -17,6 +18,7 @@ pub use self::filecoin::FilecoinSigner; pub use self::nano::NanoSigner; 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,6 +38,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), ChainType::Xrpl => Box::new(XrplSigner), ChainType::Nano => Box::new(NanoSigner), 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..00e75071 --- /dev/null +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -0,0 +1,525 @@ +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, 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). + 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) + } + + /// 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())) + } + + /// 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 + result.extend(std::iter::repeat_n('0', leading_zeros)); + + 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 { + // 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_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); + + 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 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()))?; + + // 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, + recovery_id: Some(recovery_id.to_byte()), + public_key: None, + }) + } + + fn sign_transaction( + &self, + private_key: &[u8], + tx_bytes: &[u8], + ) -> Result { + // 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) + Self::validate_single_sig_tx(tx_bytes)?; + + 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(), + )); + } + 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(); + 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 { + // 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) + } + + 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_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"); + } + + #[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_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(); + // 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(); + let signer = StacksSigner::mainnet(); + let result = signer.sign(&privkey, b"too short"); + 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()); + } + +} diff --git a/ows/crates/ows-signer/src/lib.rs b/ows/crates/ows-signer/src/lib.rs index ee4c2603..b18bf37c 100644 --- a/ows/crates/ows-signer/src/lib.rs +++ b/ows/crates/ows-signer/src/lib.rs @@ -139,6 +139,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(); @@ -181,6 +192,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); let xrpl_addr = derive_address_for_chain(&mnemonic, ChainType::Xrpl); // All addresses should be different @@ -193,6 +205,7 @@ mod integration_tests { &ton_addr, &spark_addr, &fil_addr, + &stx_addr, &xrpl_addr, ]; for i in 0..addrs.len() { @@ -221,6 +234,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); @@ -263,6 +277,7 @@ mod integration_tests { ChainType::Ton, ChainType::Spark, ChainType::Filecoin, + ChainType::Stacks, ChainType::Xrpl, ] { let signer = signer_for_chain(chain);