diff --git a/bindings/node/__test__/index.spec.mjs b/bindings/node/__test__/index.spec.mjs index 420f11f5..f367f129 100644 --- a/bindings/node/__test__/index.spec.mjs +++ b/bindings/node/__test__/index.spec.mjs @@ -54,7 +54,7 @@ describe('@open-wallet-standard/core', () => { it('derives addresses for all chains', () => { const phrase = generateMnemonic(12); - for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'nano', 'near']) { + for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'spark', 'filecoin', 'stacks', 'xrpl', 'nano', 'near']) { const addr = deriveAddress(phrase, chain); assert.ok(addr.length > 0, `address should be non-empty for ${chain}`); } @@ -62,10 +62,10 @@ describe('@open-wallet-standard/core', () => { // ---- Universal wallet lifecycle ---- - it('creates a universal wallet with 12 accounts', () => { + it('creates a universal wallet with 13 accounts', () => { const wallet = createWallet('lifecycle-test', undefined, 12, vaultDir); assert.equal(wallet.name, 'lifecycle-test'); - assert.equal(wallet.accounts.length, 12); + assert.equal(wallet.accounts.length, 13); const chainIds = wallet.accounts.map((a) => a.chainId); assert.ok(chainIds.some((c) => c.startsWith('eip155:'))); @@ -77,6 +77,7 @@ describe('@open-wallet-standard/core', () => { assert.ok(chainIds.some((c) => c.startsWith('ton:'))); assert.ok(chainIds.some((c) => c.startsWith('spark:'))); assert.ok(chainIds.some((c) => c.startsWith('fil:'))); + assert.ok(chainIds.some((c) => c.startsWith('stacks:'))); assert.ok(chainIds.some((c) => c.startsWith('xrpl:'))); assert.ok(chainIds.some((c) => c.startsWith('nano:'))); assert.ok(chainIds.some((c) => c.startsWith('near:'))); @@ -113,7 +114,7 @@ describe('@open-wallet-standard/core', () => { const wallet = importWalletMnemonic('mn-import', phrase, undefined, undefined, vaultDir); assert.equal(wallet.name, 'mn-import'); - assert.equal(wallet.accounts.length, 12); + assert.equal(wallet.accounts.length, 13); const evmAcct = wallet.accounts.find((a) => a.chainId.startsWith('eip155:')); assert.equal(evmAcct.address, expectedEvm); @@ -126,12 +127,12 @@ describe('@open-wallet-standard/core', () => { // ---- Private key import (secp256k1) ---- - it('imports a secp256k1 private key with all 12 accounts', () => { + it('imports a secp256k1 private key with all 13 accounts', () => { const privkey = '4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318'; const wallet = importWalletPrivateKey('pk-secp', privkey, undefined, vaultDir, 'evm'); assert.equal(wallet.name, 'pk-secp'); - assert.equal(wallet.accounts.length, 12, 'should have all 12 chain accounts'); + assert.equal(wallet.accounts.length, 13, 'should have all 13 chain accounts'); // Sign on EVM (provided key's curve) const evmSig = signMessage('pk-secp', 'evm', 'hello', undefined, undefined, undefined, vaultDir); @@ -151,11 +152,11 @@ describe('@open-wallet-standard/core', () => { // ---- Private key import (ed25519) ---- - it('imports an ed25519 private key with all 12 accounts', () => { + it('imports an ed25519 private key with all 13 accounts', () => { const privkey = '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'; const wallet = importWalletPrivateKey('pk-ed', privkey, undefined, vaultDir, 'solana'); - assert.equal(wallet.accounts.length, 12); + assert.equal(wallet.accounts.length, 13); // Sign on Solana (provided key) const solSig = signMessage('pk-ed', 'solana', 'hello', undefined, undefined, undefined, vaultDir); @@ -183,7 +184,7 @@ describe('@open-wallet-standard/core', () => { ); assert.equal(wallet.name, 'pk-both'); - assert.equal(wallet.accounts.length, 12, 'should have all 12 chain accounts'); + assert.equal(wallet.accounts.length, 13, 'should have all 13 chain accounts'); // Sign on EVM (secp256k1 key) const evmSig = signMessage('pk-both', 'evm', 'hello', undefined, undefined, undefined, vaultDir); @@ -209,7 +210,7 @@ describe('@open-wallet-standard/core', () => { // XRPL and Nano are excluded here because their signers explicitly do not // support generic off-chain message signing without a defined convention. // NEAR's V1 sign_message is raw ed25519 over the bytes (NEP-413 is a follow-up). - for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'near']) { + for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'spark', 'filecoin', 'stacks', 'near']) { const result = signMessage('all-chain-signer', chain, 'test', undefined, undefined, undefined, vaultDir); assert.ok(result.signature.length > 0, `signature should be non-empty for ${chain}`); } @@ -238,14 +239,16 @@ describe('@open-wallet-standard/core', () => { // NEAR transactions have no envelope; signer hashes via sha256 then ed25519 // signs the digest. Any non-empty bytes verify the signing pipeline. const nearTxHex = '42'.repeat(80); + const stacksTxHex = '00'.repeat(5) + '04' + '00'.repeat(174); const txHexByChain = { solana: solTxHex, nano: nanoTxHex, near: nearTxHex, + stacks: stacksTxHex, }; - for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'filecoin', 'xrpl', 'nano', 'near']) { + for (const chain of ['evm', 'solana', 'sui', 'bitcoin', 'cosmos', 'tron', 'ton', 'spark', 'filecoin', 'stacks', 'xrpl', 'nano', 'near']) { const hex = txHexByChain[chain] ?? txHex; const result = signTransaction('tx-signer', chain, hex, undefined, undefined, vaultDir); assert.ok(result.signature.length > 0, `signature should be non-empty for ${chain}`); diff --git a/bindings/python/tests/test_bindings.py b/bindings/python/tests/test_bindings.py index b56fab02..58ba05b3 100644 --- a/bindings/python/tests/test_bindings.py +++ b/bindings/python/tests/test_bindings.py @@ -40,7 +40,7 @@ def test_derive_address_ethereum(): def test_derive_address_all_supported_chains(): phrase = ows.generate_mnemonic(12) - for chain in ["evm", "solana", "sui", "bitcoin", "cosmos", "tron", "ton", "filecoin", "xrpl", "nano", "near"]: + for chain in ["evm", "solana", "sui", "bitcoin", "cosmos", "tron", "ton", "spark", "filecoin", "stacks", "xrpl", "nano", "near"]: address = ows.derive_address(phrase, chain) assert len(address) > 0 @@ -49,7 +49,7 @@ def test_create_and_list_wallets(vault_dir): wallet = ows.create_wallet("test-wallet", vault_path_opt=vault_dir) assert wallet["name"] == "test-wallet" assert isinstance(wallet["accounts"], list) - assert len(wallet["accounts"]) == 12 + assert len(wallet["accounts"]) == 13 # Verify each chain family is present chain_ids = [a["chain_id"] for a in wallet["accounts"]] @@ -62,6 +62,7 @@ def test_create_and_list_wallets(vault_dir): assert any(c.startswith("ton:") for c in chain_ids) assert any(c.startswith("spark:") for c in chain_ids) assert any(c.startswith("fil:") for c in chain_ids) + assert any(c.startswith("stacks:") for c in chain_ids) assert any(c.startswith("xrpl:") for c in chain_ids) assert any(c.startswith("nano:") for c in chain_ids) assert any(c.startswith("near:") for c in chain_ids) @@ -111,7 +112,7 @@ def test_import_wallet_mnemonic(vault_dir): "imported", phrase, vault_path_opt=vault_dir ) assert wallet["name"] == "imported" - assert len(wallet["accounts"]) == 12 + assert len(wallet["accounts"]) == 13 # EVM account should match derived address evm_account = next(a for a in wallet["accounts"] if a["chain_id"].startswith("eip155:")) diff --git a/docs/07-supported-chains.md b/docs/07-supported-chains.md index f9e8c665..c23976c2 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` | | NEAR | ed25519 | 397 | `m/44'/397'/{index}'` | 64-char lowercase hex of pubkey (implicit account) | `near` | ## Known Networks @@ -74,6 +75,7 @@ Each network has a canonical chain identifier. Endpoint discovery and transport | XRPL | `xrpl:mainnet` | | Spark | `spark:mainnet` | | Filecoin | `fil:mainnet` | +| Stacks | `stacks:1` | | NEAR | `near:mainnet` | | NEAR (testnet) | `near:testnet` | @@ -107,6 +109,7 @@ xrpl-testnet → xrpl:testnet xrpl-devnet → xrpl:devnet spark → spark:mainnet filecoin → fil:mainnet +stacks → stacks:1 near → near:mainnet near-testnet → near:testnet ``` @@ -133,6 +136,7 @@ Master Seed (512 bits via PBKDF2) ├── 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'/5757'/0'/0/0 → Stacks Account 0 └── m/44'/397'/0' → NEAR Account 0 ``` diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index cbe9380d..48c6057f 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -15,13 +15,14 @@ pub enum ChainType { Spark, Filecoin, Sui, + Stacks, Xrpl, Nano, Near, } /// All supported chain families, used for universal wallet derivation. -pub const ALL_CHAIN_TYPES: [ChainType; 12] = [ +pub const ALL_CHAIN_TYPES: [ChainType; 13] = [ ChainType::Evm, ChainType::Solana, ChainType::Bitcoin, @@ -31,6 +32,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 12] = [ ChainType::Spark, ChainType::Filecoin, ChainType::Sui, + ChainType::Stacks, ChainType::Xrpl, ChainType::Nano, ChainType::Near, @@ -163,6 +165,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, @@ -290,6 +297,7 @@ impl ChainType { ChainType::Spark => "spark", ChainType::Filecoin => "fil", ChainType::Sui => "sui", + ChainType::Stacks => "stacks", ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", ChainType::Near => "near", @@ -308,6 +316,7 @@ impl ChainType { ChainType::Spark => 8797555, ChainType::Filecoin => 461, ChainType::Sui => 784, + ChainType::Stacks => 5757, ChainType::Xrpl => 144, ChainType::Nano => 165, ChainType::Near => 397, @@ -326,6 +335,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), "near" => Some(ChainType::Near), @@ -346,6 +356,7 @@ impl fmt::Display for ChainType { ChainType::Spark => "spark", ChainType::Filecoin => "filecoin", ChainType::Sui => "sui", + ChainType::Stacks => "stacks", ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", ChainType::Near => "near", @@ -368,6 +379,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), "near" => Ok(ChainType::Near), @@ -401,6 +413,7 @@ mod tests { (ChainType::Spark, "\"spark\""), (ChainType::Filecoin, "\"filecoin\""), (ChainType::Sui, "\"sui\""), + (ChainType::Stacks, "\"stacks\""), (ChainType::Xrpl, "\"xrpl\""), (ChainType::Nano, "\"nano\""), (ChainType::Near, "\"near\""), @@ -423,6 +436,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"); assert_eq!(ChainType::Near.namespace(), "near"); @@ -439,6 +453,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); assert_eq!(ChainType::Near.default_coin_type(), 397); @@ -458,6 +473,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("near"), Some(ChainType::Near)); @@ -641,7 +657,7 @@ mod tests { #[test] fn test_all_chain_types() { - assert_eq!(ALL_CHAIN_TYPES.len(), 12); + assert_eq!(ALL_CHAIN_TYPES.len(), 13); } #[test] diff --git a/ows/crates/ows-core/src/config.rs b/ows/crates/ows-core/src/config.rs index 29c92dfb..284aceca 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -64,6 +64,7 @@ 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(), @@ -266,7 +267,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(), 23); + assert_eq!(config.rpc.len(), 24); assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com")); assert_eq!( config.rpc_url("near:mainnet"), diff --git a/ows/crates/ows-lib/src/ops.rs b/ows/crates/ows-lib/src/ops.rs index 1fad696a..fb12ed93 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -817,6 +817,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), ChainType::Near => crate::near_rpc::broadcast_tx_commit(rpc_url, signed_bytes), @@ -966,6 +967,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; @@ -1182,7 +1239,8 @@ mod tests { fn derive_address_all_chains() { let phrase = generate_mnemonic(12).unwrap(); let chains = [ - "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "sui", "xrpl", "nano", "near", + "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "filecoin", "sui", + "stacks", "xrpl", "nano", "near", ]; for chain in &chains { let addr = derive_address(&phrase, chain, None).unwrap(); @@ -1267,7 +1325,8 @@ mod tests { // support generic off-chain message signing without a defined convention. // NEAR's V1 sign_message is raw ed25519 (NEP-413 follow-up tracked). let chains = [ - "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "near", + "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "filecoin", "sui", + "stacks", "near", ]; for chain in &chains { let result = sign_message( @@ -1310,15 +1369,21 @@ mod tests { // bytes ARE the signable payload. Any non-empty bytes exercise the // sha256 -> ed25519 pipeline. let near_tx_hex = "42".repeat(80); + let mut stacks_tx = vec![0u8; 180]; + stacks_tx[5] = 0x04; + let stacks_tx_hex = hex::encode(stacks_tx); let chains = [ - "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "sui", "xrpl", "near", + "evm", "solana", "bitcoin", "cosmos", "tron", "ton", "spark", "filecoin", "sui", + "stacks", "xrpl", "near", ]; for chain in &chains { let tx = if *chain == "solana" { &solana_tx_hex } else if *chain == "near" { &near_tx_hex + } else if *chain == "stacks" { + &stacks_tx_hex } else { generic_tx_hex }; diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index 174537a2..6896fc00 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -6,6 +6,7 @@ pub mod nano; pub mod near; pub mod solana; pub mod spark; +pub mod stacks; pub mod sui; pub mod ton; pub mod tron; @@ -19,6 +20,7 @@ pub use self::nano::NanoSigner; pub use self::near::NearSigner; 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; @@ -38,6 +40,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..51f2d004 --- /dev/null +++ b/ows/crates/ows-signer/src/chains/stacks.rs @@ -0,0 +1,527 @@ +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);