diff --git a/ows/Cargo.lock b/ows/Cargo.lock index 691ff155..b3a02634 100644 --- a/ows/Cargo.lock +++ b/ows/Cargo.lock @@ -357,6 +357,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -503,6 +530,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -526,6 +559,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cryptoxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382ce8820a5bb815055d3553a610e8cb542b2d767bbacea99038afda96cd760d" + [[package]] name = "ctr" version = "0.9.2" @@ -680,6 +719,15 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-bip32" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb588f93c0d91b2f668849fd6d030cddb0b2e31f105963be189da5acdf492a21" +dependencies = [ + "cryptoxide", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -955,6 +1003,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1604,9 +1663,11 @@ dependencies = [ "bech32 0.11.1", "blake2", "bs58", + "ciborium", "coins-bip32", "coins-bip39", "digest", + "ed25519-bip32", "ed25519-dalek", "hex", "hkdf", diff --git a/ows/crates/ows-core/src/chain.rs b/ows/crates/ows-core/src/chain.rs index 1f741115..0b835418 100644 --- a/ows/crates/ows-core/src/chain.rs +++ b/ows/crates/ows-core/src/chain.rs @@ -17,10 +17,11 @@ pub enum ChainType { Sui, Xrpl, Nano, + Cardano, } /// 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, @@ -31,6 +32,7 @@ pub const ALL_CHAIN_TYPES: [ChainType; 10] = [ ChainType::Sui, ChainType::Xrpl, ChainType::Nano, + ChainType::Cardano, ]; /// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID. @@ -190,6 +192,21 @@ pub const KNOWN_CHAINS: &[Chain] = &[ chain_type: ChainType::Evm, chain_id: "eip155:999", }, + Chain { + name: "cardano", + chain_type: ChainType::Cardano, + chain_id: "cardano:mainnet", + }, + Chain { + name: "cardano-preprod", + chain_type: ChainType::Cardano, + chain_id: "cardano:preprod", + }, + Chain { + name: "cardano-preview", + chain_type: ChainType::Cardano, + chain_id: "cardano:preview", + }, ]; /// Parse a chain string into a `Chain`. Accepts: @@ -254,6 +271,7 @@ pub fn parse_chain(s: &str) -> Result { EVM: ethereum, base, arbitrum, optimism, polygon, bsc, avalanche, plasma, etherlink\n \ Solana: solana\n \ Bitcoin: bitcoin\n \ + Cardano: cardano, cardano-preprod, cardano-preview\n \ Other: cosmos, tron, ton, sui, filecoin, spark, xrpl, nano\n\n\ Or use a CAIP-2 ID (eip155:8453) or bare EVM chain ID (8453)" )) @@ -279,6 +297,7 @@ impl ChainType { ChainType::Sui => "sui", ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", + ChainType::Cardano => "cardano", } } @@ -296,6 +315,7 @@ impl ChainType { ChainType::Sui => 784, ChainType::Xrpl => 144, ChainType::Nano => 165, + ChainType::Cardano => 1815, } } @@ -313,6 +333,7 @@ impl ChainType { "sui" => Some(ChainType::Sui), "xrpl" => Some(ChainType::Xrpl), "nano" => Some(ChainType::Nano), + "cardano" => Some(ChainType::Cardano), _ => None, } } @@ -332,6 +353,7 @@ impl fmt::Display for ChainType { ChainType::Sui => "sui", ChainType::Xrpl => "xrpl", ChainType::Nano => "nano", + ChainType::Cardano => "cardano", }; write!(f, "{}", s) } @@ -353,6 +375,7 @@ impl FromStr for ChainType { "sui" => Ok(ChainType::Sui), "xrpl" => Ok(ChainType::Xrpl), "nano" => Ok(ChainType::Nano), + "cardano" => Ok(ChainType::Cardano), _ => Err(format!("unknown chain type: {}", s)), } } @@ -385,6 +408,7 @@ mod tests { (ChainType::Sui, "\"sui\""), (ChainType::Xrpl, "\"xrpl\""), (ChainType::Nano, "\"nano\""), + (ChainType::Cardano, "\"cardano\""), ] { let json = serde_json::to_string(&chain).unwrap(); assert_eq!(json, expected); @@ -406,6 +430,7 @@ mod tests { assert_eq!(ChainType::Sui.namespace(), "sui"); assert_eq!(ChainType::Xrpl.namespace(), "xrpl"); assert_eq!(ChainType::Nano.namespace(), "nano"); + assert_eq!(ChainType::Cardano.namespace(), "cardano"); } #[test] @@ -421,6 +446,7 @@ mod tests { assert_eq!(ChainType::Sui.default_coin_type(), 784); assert_eq!(ChainType::Xrpl.default_coin_type(), 144); assert_eq!(ChainType::Nano.default_coin_type(), 165); + assert_eq!(ChainType::Cardano.default_coin_type(), 1815); } #[test] @@ -439,6 +465,10 @@ mod tests { assert_eq!(ChainType::from_namespace("sui"), Some(ChainType::Sui)); assert_eq!(ChainType::from_namespace("xrpl"), Some(ChainType::Xrpl)); assert_eq!(ChainType::from_namespace("nano"), Some(ChainType::Nano)); + assert_eq!( + ChainType::from_namespace("cardano"), + Some(ChainType::Cardano) + ); assert_eq!(ChainType::from_namespace("unknown"), None); } @@ -580,6 +610,26 @@ mod tests { assert_eq!(chain.chain_id, "eip155:99999"); } + #[test] + fn test_parse_chain_cardano() { + let chain = parse_chain("cardano").unwrap(); + assert_eq!(chain.chain_type, ChainType::Cardano); + assert_eq!(chain.chain_id, "cardano:mainnet"); + + let preprod = parse_chain("cardano-preprod").unwrap(); + assert_eq!(preprod.chain_type, ChainType::Cardano); + assert_eq!(preprod.chain_id, "cardano:preprod"); + + let preview = parse_chain("cardano-preview").unwrap(); + assert_eq!(preview.chain_type, ChainType::Cardano); + assert_eq!(preview.chain_id, "cardano:preview"); + + // CAIP-2 IDs also accepted directly + let via_caip2 = parse_chain("cardano:mainnet").unwrap(); + assert_eq!(via_caip2.chain_type, ChainType::Cardano); + assert_eq!(via_caip2.chain_id, "cardano:mainnet"); + } + #[test] fn test_parse_chain_unknown() { assert!(parse_chain("unknown_chain").is_err()); @@ -619,7 +669,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..8983153f 100644 --- a/ows/crates/ows-core/src/config.rs +++ b/ows/crates/ows-core/src/config.rs @@ -79,6 +79,18 @@ impl Config { "eip155:999".into(), "https://rpc.hyperliquid.xyz/evm".into(), ); + rpc.insert( + "cardano:mainnet".into(), + "https://api.koios.rest/api/v1".into(), + ); + rpc.insert( + "cardano:preprod".into(), + "https://preprod.koios.rest/api/v1".into(), + ); + rpc.insert( + "cardano:preview".into(), + "https://preview.koios.rest/api/v1".into(), + ); rpc } } @@ -222,6 +234,18 @@ mod tests { config.rpc_url("eip155:999"), Some("https://rpc.hyperliquid.xyz/evm") ); + assert_eq!( + config.rpc_url("cardano:mainnet"), + Some("https://api.koios.rest/api/v1") + ); + assert_eq!( + config.rpc_url("cardano:preprod"), + Some("https://preprod.koios.rest/api/v1") + ); + assert_eq!( + config.rpc_url("cardano:preview"), + Some("https://preview.koios.rest/api/v1") + ); } #[test] @@ -264,7 +288,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(), 24); 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 29c193d2..cb733124 100644 --- a/ows/crates/ows-lib/src/ops.rs +++ b/ows/crates/ows-lib/src/ops.rs @@ -62,6 +62,8 @@ fn derive_all_accounts(mnemonic: &Mnemonic, index: u32) -> Result, ed25519: Vec, + /// Ed25519-BIP32 extended key (64 bytes) for Cardano / CIP-1852. + ed25519_bip32: Vec, } impl Drop for KeyPair { @@ -69,6 +71,7 @@ impl Drop for KeyPair { use zeroize::Zeroize; self.secp256k1.zeroize(); self.ed25519.zeroize(); + self.ed25519_bip32.zeroize(); } } @@ -78,6 +81,7 @@ impl KeyPair { match curve { ows_signer::Curve::Secp256k1 => &self.secp256k1, ows_signer::Curve::Ed25519 => &self.ed25519, + ows_signer::Curve::Ed25519Bip32 => &self.ed25519_bip32, } } @@ -86,11 +90,15 @@ impl KeyPair { let obj = serde_json::json!({ "secp256k1": hex::encode(&self.secp256k1), "ed25519": hex::encode(&self.ed25519), + "ed25519_bip32": hex::encode(&self.ed25519_bip32), }); obj.to_string().into_bytes() } /// Deserialize from JSON bytes after decryption. + /// + /// The `ed25519_bip32` field is optional for backwards compatibility with + /// wallets created before Cardano support was added. fn from_json_bytes(bytes: &[u8]) -> Result { let s = String::from_utf8(bytes.to_vec()) .map_err(|_| OwsLibError::InvalidInput("invalid key pair data".into()))?; @@ -101,11 +109,19 @@ impl KeyPair { let ed = obj["ed25519"] .as_str() .ok_or_else(|| OwsLibError::InvalidInput("missing ed25519 key".into()))?; + // Optional — absent in wallets created before Cardano support. + let ed25519_bip32 = if let Some(hex_str) = obj["ed25519_bip32"].as_str() { + hex::decode(hex_str) + .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519_bip32 hex: {e}")))? + } else { + vec![0u8; 64] + }; Ok(KeyPair { secp256k1: hex::decode(secp) .map_err(|e| OwsLibError::InvalidInput(format!("invalid secp256k1 hex: {e}")))?, ed25519: hex::decode(ed) .map_err(|e| OwsLibError::InvalidInput(format!("invalid ed25519 hex: {e}")))?, + ed25519_bip32, }) } } @@ -297,10 +313,17 @@ pub fn import_wallet_private_key( let keys = match (secp256k1_key_hex, ed25519_key_hex) { // Both curve keys explicitly provided — use them directly - (Some(secp_hex), Some(ed_hex)) => KeyPair { - secp256k1: decode_hex_key(secp_hex)?, - ed25519: decode_hex_key(ed_hex)?, - }, + (Some(secp_hex), Some(ed_hex)) => { + let mut random_bip32 = vec![0u8; 64]; + getrandom::getrandom(&mut random_bip32).map_err(|e| { + OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) + })?; + KeyPair { + secp256k1: decode_hex_key(secp_hex)?, + ed25519: decode_hex_key(ed_hex)?, + ed25519_bip32: random_bip32, + } + } // Existing single-key behavior _ => { let key_bytes = decode_hex_key(private_key_hex)?; @@ -314,9 +337,13 @@ pub fn import_wallet_private_key( None => ows_signer::Curve::Secp256k1, }; - // Build key pair: provided key for its curve, random 32 bytes for the other - let mut other_key = vec![0u8; 32]; - getrandom::getrandom(&mut other_key).map_err(|e| { + // Build key pair: provided key for its curve, random bytes for the others. + let mut other_key_32 = vec![0u8; 32]; + getrandom::getrandom(&mut other_key_32).map_err(|e| { + OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) + })?; + let mut random_bip32 = vec![0u8; 64]; + getrandom::getrandom(&mut random_bip32).map_err(|e| { OwsLibError::InvalidInput(format!("failed to generate random key: {e}")) })?; @@ -326,14 +353,31 @@ pub fn import_wallet_private_key( ed25519: ed25519_key_hex .map(decode_hex_key) .transpose()? - .unwrap_or(other_key), + .unwrap_or(other_key_32), + ed25519_bip32: random_bip32, }, ows_signer::Curve::Ed25519 => KeyPair { secp256k1: secp256k1_key_hex .map(decode_hex_key) .transpose()? - .unwrap_or(other_key), + .unwrap_or(other_key_32), ed25519: key_bytes, + ed25519_bip32: random_bip32, + }, + ows_signer::Curve::Ed25519Bip32 => KeyPair { + secp256k1: secp256k1_key_hex + .map(decode_hex_key) + .transpose()? + .unwrap_or(other_key_32), + ed25519: ed25519_key_hex + .map(decode_hex_key) + .transpose()? + .unwrap_or_else(|| { + let mut k = vec![0u8; 32]; + let _ = getrandom::getrandom(&mut k); + k + }), + ed25519_bip32: key_bytes, }, } } @@ -819,6 +863,7 @@ fn broadcast(chain: ChainType, rpc_url: &str, signed_bytes: &[u8]) -> Result broadcast_sui(rpc_url, signed_bytes), ChainType::Xrpl => broadcast_xrpl(rpc_url, signed_bytes), ChainType::Nano => broadcast_nano(rpc_url, signed_bytes), + ChainType::Cardano => broadcast_cardano(rpc_url, signed_bytes), } } @@ -949,6 +994,66 @@ fn broadcast_ton(rpc_url: &str, signed_bytes: &[u8]) -> Result Result { + use std::io::Write; + use std::process::Stdio; + + let url = format!("{}/submittx", rpc_url.trim_end_matches('/')); + + // Pipe raw CBOR bytes into curl via stdin so binary content is preserved exactly. + let mut child = Command::new("curl") + .args([ + "-fsSL", + "-X", + "POST", + "-H", + "Content-Type: application/cbor", + "-H", + "Accept: application/json", + "--data-binary", + "@-", + &url, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to spawn curl: {e}")))?; + + child + .stdin + .take() + .expect("stdin is piped") + .write_all(signed_bytes) + .map_err(|e| OwsLibError::BroadcastFailed(format!("failed to write CBOR to curl: {e}")))?; + + let output = child + .wait_with_output() + .map_err(|e| OwsLibError::BroadcastFailed(format!("curl wait failed: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(OwsLibError::BroadcastFailed(format!( + "Cardano broadcast failed: {stderr}{stdout}" + ))); + } + + // Koios returns the tx hash as a quoted JSON string, e.g. "\"abc123...\"" + let response = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let tx_hash = response.trim_matches('"').to_string(); + if tx_hash.is_empty() { + return Err(OwsLibError::BroadcastFailed( + "empty tx hash in Cardano node response".into(), + )); + } + Ok(tx_hash) +} + fn broadcast_sui(rpc_url: &str, signed_bytes: &[u8]) -> Result { use ows_signer::chains::sui::WIRE_SIG_LEN; @@ -1110,6 +1215,7 @@ mod tests { let keys = KeyPair { secp256k1: key_bytes, ed25519: ed_key, + ed25519_bip32: vec![0u8; 64], }; let accounts = derive_all_accounts_from_keys(&keys).unwrap(); let payload = keys.to_json_bytes(); diff --git a/ows/crates/ows-signer/Cargo.toml b/ows/crates/ows-signer/Cargo.toml index ae07ac12..1fb4d569 100644 --- a/ows/crates/ows-signer/Cargo.toml +++ b/ows/crates/ows-signer/Cargo.toml @@ -38,5 +38,7 @@ blake2 = "0.10" digest = "0.10" libc = "0.2" signal-hook = "0.4" +ed25519-bip32 = "0.4" +ciborium = "0.2" [dev-dependencies] diff --git a/ows/crates/ows-signer/src/chains/cardano.rs b/ows/crates/ows-signer/src/chains/cardano.rs new file mode 100644 index 00000000..d50331c5 --- /dev/null +++ b/ows/crates/ows-signer/src/chains/cardano.rs @@ -0,0 +1,574 @@ +use crate::curve::Curve; +use crate::traits::{ChainSigner, SignOutput, SignerError}; +use bech32::{Bech32, Hrp}; +use blake2::digest::{Update, VariableOutput}; +use blake2::Blake2bVar; +use ed25519_bip32::{DerivationScheme, XPrv}; +use ows_core::ChainType; + +/// Cardano network tag used in address header byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Network { + Mainnet, + Testnet, +} + +impl Network { + fn tag(self) -> u8 { + match self { + Network::Mainnet => 1, + Network::Testnet => 0, + } + } + + fn hrp(self) -> &'static str { + match self { + Network::Mainnet => "addr", + Network::Testnet => "addr_test", + } + } +} + +/// Cardano signer — CIP-1852, Ed25519-BIP32, bech32 enterprise addresses. +pub struct CardanoSigner { + network: Network, +} + +impl CardanoSigner { + /// Mainnet signer (default). + pub fn mainnet() -> Self { + Self { + network: Network::Mainnet, + } + } + + /// Testnet signer (preprod / preview). + pub fn testnet() -> Self { + Self { + network: Network::Testnet, + } + } + + /// Reconstruct an `XPrv` from the 64-byte extended private key produced by CIP-1852 + /// derivation. Chain code is set to zeroes because it is not needed for signing or + /// public-key derivation — only the scalar (kL) and extension (kR) matter. + /// + /// The scalar (kL, first 32 bytes) is clamped per the BIP32-Ed25519 spec so that + /// the XPrv construction never panics regardless of the key source. + fn xprv_from_extended(extended_key: &[u8]) -> Result { + if extended_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey(format!( + "Cardano requires a 64-byte extended private key, got {}", + extended_key.len() + ))); + } + let mut sk = [0u8; 64]; + sk.copy_from_slice(extended_key); + // Clamp kL per BIP32-Ed25519 / CIP-1852 so the scalar is always valid. + sk[0] &= 0b1111_1000; + sk[31] &= 0b0001_1111; + sk[31] |= 0b0100_0000; + Ok(XPrv::from_extended_and_chaincode(&sk, &[0u8; 32])) + } + + /// Compute the blake2b-224 hash (28 bytes) of the given data. + fn blake2b_224(data: &[u8]) -> [u8; 28] { + let mut hasher = Blake2bVar::new(28).expect("28 is a valid Blake2b output length"); + hasher.update(data); + let mut out = [0u8; 28]; + hasher.finalize_variable(&mut out).unwrap(); + out + } + + /// Compute the blake2b-256 hash (32 bytes) of the given data. + fn blake2b_256(data: &[u8]) -> [u8; 32] { + let mut hasher = Blake2bVar::new(32).expect("32 is a valid Blake2b output length"); + hasher.update(data); + let mut out = [0u8; 32]; + hasher.finalize_variable(&mut out).unwrap(); + out + } + + /// Encode a Cardano enterprise address for this signer's network. + /// + /// Enterprise address = header_byte || blake2b-224(payment_pubkey) + /// Header byte: 0b0110_0000 | network_tag (0x61 mainnet, 0x60 testnet) + fn encode_address(&self, pub_key: &[u8; 32]) -> Result { + let header = 0b0110_0000u8 | self.network.tag(); + let key_hash = Self::blake2b_224(pub_key); + + // 29 bytes: [header (1)] || [key_hash (28)] + let mut payload = Vec::with_capacity(29); + payload.push(header); + payload.extend_from_slice(&key_hash); + + let hrp = Hrp::parse(self.network.hrp()) + .map_err(|e| SignerError::AddressDerivationFailed(e.to_string()))?; + bech32::encode::(hrp, &payload) + .map_err(|e| SignerError::AddressDerivationFailed(e.to_string())) + } +} + +impl ChainSigner for CardanoSigner { + fn chain_type(&self) -> ChainType { + ChainType::Cardano + } + + fn curve(&self) -> Curve { + Curve::Ed25519Bip32 + } + + fn coin_type(&self) -> u32 { + 1815 + } + + /// Derive a Cardano enterprise address from a 64-byte extended private key. + fn derive_address(&self, private_key: &[u8]) -> Result { + let xprv = Self::xprv_from_extended(private_key)?; + let xpub = xprv.public(); + let pub_key = xpub.public_key_bytes(); + self.encode_address(pub_key) + } + + /// Sign a message directly with Ed25519-BIP32 (no pre-hashing). + fn sign(&self, private_key: &[u8], message: &[u8]) -> Result { + let xprv = Self::xprv_from_extended(private_key)?; + let sig: ed25519_bip32::Signature<()> = xprv.sign(message); + Ok(SignOutput { + signature: sig.as_ref().to_vec(), + recovery_id: None, + public_key: Some(xprv.public().public_key_bytes().to_vec()), + }) + } + + /// Sign a Cardano transaction. + /// + /// `tx_bytes` must be the CBOR-serialized full transaction: + /// `[tx_body_map, witness_set_map, bool, aux_data_or_null]`. + /// + /// The signature covers blake2b-256(cbor(tx_body)) where `tx_body` is the + /// first element of the outer array (CBOR index 0). + fn sign_transaction( + &self, + private_key: &[u8], + tx_bytes: &[u8], + ) -> Result { + // Parse the full CBOR transaction to extract the transaction body. + let tx_value: ciborium::Value = ciborium::de::from_reader(tx_bytes) + .map_err(|e| SignerError::InvalidTransaction(format!("CBOR decode error: {e}")))?; + + let tx_array = match &tx_value { + ciborium::Value::Array(arr) => arr, + _ => { + return Err(SignerError::InvalidTransaction( + "expected CBOR array at top level".into(), + )) + } + }; + + if tx_array.is_empty() { + return Err(SignerError::InvalidTransaction( + "transaction array is empty".into(), + )); + } + + // Re-encode the transaction body (index 0) to get its canonical CBOR bytes. + let mut tx_body_cbor = Vec::new(); + ciborium::ser::into_writer(&tx_array[0], &mut tx_body_cbor).map_err(|e| { + SignerError::InvalidTransaction(format!("CBOR encode tx_body error: {e}")) + })?; + + // Hash the tx_body with blake2b-256. + let tx_hash = Self::blake2b_256(&tx_body_cbor); + + // Sign the hash. + self.sign(private_key, &tx_hash) + } + + /// Sign an arbitrary message (raw, no chain-specific prefix). + fn sign_message(&self, private_key: &[u8], message: &[u8]) -> Result { + self.sign(private_key, message) + } + + /// Inject the witness set into the Cardano transaction. + /// + /// `tx_bytes` must be the full CBOR transaction array. + /// The witness set is injected at index 1 as: + /// `{ 0: [[vkey_32_bytes, signature_64_bytes]] }` + fn encode_signed_transaction( + &self, + tx_bytes: &[u8], + signature: &SignOutput, + ) -> Result, SignerError> { + if signature.signature.len() != 64 { + return Err(SignerError::InvalidTransaction( + "expected 64-byte Ed25519 signature".into(), + )); + } + let pub_key = signature.public_key.as_ref().ok_or_else(|| { + SignerError::InvalidTransaction("missing public key in SignOutput".into()) + })?; + if pub_key.len() != 32 { + return Err(SignerError::InvalidTransaction( + "expected 32-byte Ed25519 public key".into(), + )); + } + + // Parse the original transaction. + let mut tx_value: ciborium::Value = ciborium::de::from_reader(tx_bytes) + .map_err(|e| SignerError::InvalidTransaction(format!("CBOR decode error: {e}")))?; + + let tx_array = match &mut tx_value { + ciborium::Value::Array(arr) => arr, + _ => { + return Err(SignerError::InvalidTransaction( + "expected CBOR array at top level".into(), + )) + } + }; + + if tx_array.len() < 2 { + return Err(SignerError::InvalidTransaction( + "transaction array too short (need at least 2 elements)".into(), + )); + } + + // Build witness set: { 0: [[vkey, signature]] } + let witness_set = ciborium::Value::Map(vec![( + ciborium::Value::Integer(0u64.into()), + ciborium::Value::Array(vec![ciborium::Value::Array(vec![ + ciborium::Value::Bytes(pub_key.clone()), + ciborium::Value::Bytes(signature.signature.clone()), + ])]), + )]); + + // Replace the witness set at index 1. + tx_array[1] = witness_set; + + // Re-encode the modified transaction. + let mut encoded = Vec::new(); + ciborium::ser::into_writer(&tx_value, &mut encoded) + .map_err(|e| SignerError::InvalidTransaction(format!("CBOR encode error: {e}")))?; + + Ok(encoded) + } + + /// CIP-1852 derivation path for the payment key. + /// + /// Path: `m/1852'/1815'/account'/0/index` + fn default_derivation_path(&self, index: u32) -> String { + format!("m/1852'/1815'/{index}'/0/0") + } +} + +/// Derive the staking key path per CIP-1852. +/// +/// Path: `m/1852'/1815'/account'/2/0` +pub fn staking_key_path(account: u32) -> String { + format!("m/1852'/1815'/{account}'/2/0") +} + +/// Apply one more step of CIP-1852 child derivation to reach the payment key at `index`. +/// Useful when the caller already has the account-level key and wants a specific address index. +pub fn payment_key_path(account: u32, index: u32) -> String { + format!("m/1852'/1815'/{account}'/0/{index}") +} + +/// Derive a staking address from an extended stake key using the CIP-1852 reward address format. +/// +/// Reward address = header_byte || blake2b-224(stake_pubkey) +/// Header: 0b1110_0000 | network_tag (0xE1 mainnet, 0xE0 testnet) +pub fn reward_address(stake_key: &[u8], mainnet: bool) -> Result { + if stake_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey( + "stake key must be 64-byte extended private key".into(), + )); + } + let sk: &[u8; 64] = stake_key.try_into().unwrap(); + let xprv = XPrv::from_extended_and_chaincode(sk, &[0u8; 32]); + let pub_key = xprv.public(); + let pub_key_bytes = pub_key.public_key_bytes(); + + let mut hasher = Blake2bVar::new(28).expect("valid output length"); + hasher.update(pub_key_bytes); + let mut key_hash = [0u8; 28]; + hasher.finalize_variable(&mut key_hash).unwrap(); + + let network_tag: u8 = if mainnet { 1 } else { 0 }; + let header = 0b1110_0000u8 | network_tag; + + let mut payload = Vec::with_capacity(29); + payload.push(header); + payload.extend_from_slice(&key_hash); + + let hrp_str = if mainnet { "stake" } else { "stake_test" }; + let hrp = + Hrp::parse(hrp_str).map_err(|e| SignerError::AddressDerivationFailed(e.to_string()))?; + bech32::encode::(hrp, &payload) + .map_err(|e| SignerError::AddressDerivationFailed(e.to_string())) +} + +/// Derive a child key one level deeper using V2 derivation (soft). +/// +/// Used internally to step from the account key to payment/staking key. +pub fn derive_child_soft(extended_key: &[u8], index: u32) -> Result, SignerError> { + if extended_key.len() != 64 { + return Err(SignerError::InvalidPrivateKey( + "expected 64-byte extended private key".into(), + )); + } + let sk: &[u8; 64] = extended_key.try_into().unwrap(); + let parent = XPrv::from_extended_and_chaincode(sk, &[0u8; 32]); + let child = parent.derive(DerivationScheme::V2, index); + Ok(child.extended_secret_key_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::curve::Curve; + use crate::hd::HdDeriver; + use crate::mnemonic::Mnemonic; + + const ABANDON_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn derive_payment_key(mnemonic: &Mnemonic) -> Vec { + let signer = CardanoSigner::mainnet(); + let path = signer.default_derivation_path(0); + HdDeriver::derive_from_mnemonic(mnemonic, "", &path, Curve::Ed25519Bip32) + .unwrap() + .expose() + .to_vec() + } + + #[test] + fn test_chain_properties() { + let signer = CardanoSigner::mainnet(); + assert_eq!(signer.chain_type(), ChainType::Cardano); + assert_eq!(signer.curve(), Curve::Ed25519Bip32); + assert_eq!(signer.coin_type(), 1815); + } + + #[test] + fn test_derivation_path() { + let signer = CardanoSigner::mainnet(); + assert_eq!(signer.default_derivation_path(0), "m/1852'/1815'/0'/0/0"); + assert_eq!(signer.default_derivation_path(1), "m/1852'/1815'/1'/0/0"); + } + + #[test] + fn test_address_is_valid_bech32_mainnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let address = signer.derive_address(&key).unwrap(); + assert!( + address.starts_with("addr1"), + "mainnet enterprise address must start with 'addr1', got: {address}" + ); + // Mainnet enterprise addresses are always 59 chars (29 payload bytes → 47 base32 chars + 6 checksum + "addr1" prefix) + assert!( + address.len() > 50, + "address too short: {} chars", + address.len() + ); + } + + #[test] + fn test_address_is_valid_bech32_testnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::testnet(); + let address = signer.derive_address(&key).unwrap(); + assert!( + address.starts_with("addr_test1"), + "testnet enterprise address must start with 'addr_test1', got: {address}" + ); + } + + #[test] + fn test_mainnet_testnet_different_addresses() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let mainnet_addr = CardanoSigner::mainnet().derive_address(&key).unwrap(); + let testnet_addr = CardanoSigner::testnet().derive_address(&key).unwrap(); + assert_ne!(mainnet_addr, testnet_addr); + } + + #[test] + fn test_deterministic_address() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let addr1 = signer.derive_address(&key).unwrap(); + let addr2 = signer.derive_address(&key).unwrap(); + assert_eq!(addr1, addr2); + } + + #[test] + fn test_sign_verify_roundtrip() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let message = b"test message for cardano"; + let result = signer.sign(&key, message).unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + assert!(result.public_key.is_some()); + assert_eq!(result.public_key.unwrap().len(), 32); + } + + #[test] + fn test_sign_deterministic() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + let message = b"hello cardano"; + let sig1 = signer.sign(&key, message).unwrap(); + let sig2 = signer.sign(&key, message).unwrap(); + assert_eq!(sig1.signature, sig2.signature); + } + + #[test] + fn test_invalid_key_length() { + let signer = CardanoSigner::mainnet(); + let bad_key = vec![0u8; 32]; // too short (need 64) + assert!(signer.derive_address(&bad_key).is_err()); + assert!(signer.sign(&bad_key, b"msg").is_err()); + } + + #[test] + fn test_different_mnemonics_different_addresses() { + let mnemonic1 = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let phrase2 = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + let mnemonic2 = Mnemonic::from_phrase(phrase2).unwrap(); + + let signer = CardanoSigner::mainnet(); + let key1 = derive_payment_key(&mnemonic1); + let path = signer.default_derivation_path(0); + let key2 = HdDeriver::derive_from_mnemonic(&mnemonic2, "", &path, Curve::Ed25519Bip32) + .unwrap() + .expose() + .to_vec(); + + let addr1 = signer.derive_address(&key1).unwrap(); + let addr2 = signer.derive_address(&key2).unwrap(); + assert_ne!(addr1, addr2); + } + + #[test] + fn test_sign_transaction_valid_cbor() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + + // Minimal syntactically valid Cardano transaction CBOR: + // [tx_body_map, witness_set_map, true, null] + // tx_body_map = {0: [[input_bytes, 0]], 1: [[output_addr, lovelace]], 2: fee} + // We use a simplified form: [{}, {}, true, null] + let tx: Vec = vec![ + ciborium::Value::Map(vec![]), // tx_body (empty for test) + ciborium::Value::Map(vec![]), // witness_set placeholder + ciborium::Value::Bool(true), + ciborium::Value::Null, + ]; + let mut tx_bytes = Vec::new(); + ciborium::ser::into_writer(&ciborium::Value::Array(tx), &mut tx_bytes).unwrap(); + + let result = signer.sign_transaction(&key, &tx_bytes).unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.public_key.is_some()); + } + + #[test] + fn test_encode_signed_transaction() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let key = derive_payment_key(&mnemonic); + let signer = CardanoSigner::mainnet(); + + let tx: Vec = vec![ + ciborium::Value::Map(vec![]), + ciborium::Value::Map(vec![]), + ciborium::Value::Bool(true), + ciborium::Value::Null, + ]; + let mut tx_bytes = Vec::new(); + ciborium::ser::into_writer(&ciborium::Value::Array(tx), &mut tx_bytes).unwrap(); + + let sig = signer.sign_transaction(&key, &tx_bytes).unwrap(); + let signed_tx = signer.encode_signed_transaction(&tx_bytes, &sig).unwrap(); + + // Decode the signed tx and verify witness set is present at index 1 + let decoded: ciborium::Value = ciborium::de::from_reader(&signed_tx[..]).unwrap(); + let arr = match decoded { + ciborium::Value::Array(a) => a, + _ => panic!("expected array"), + }; + assert_eq!(arr.len(), 4); + + // Witness set should be a map with key 0 + match &arr[1] { + ciborium::Value::Map(m) => { + assert_eq!(m.len(), 1, "witness set should have one entry"); + } + other => panic!("expected map at index 1, got: {other:?}"), + } + } + + #[test] + fn test_blake2b_224_known_vector() { + // Blake2b-224 of empty input — known value + let hash = CardanoSigner::blake2b_224(b""); + assert_eq!(hash.len(), 28); + // Basic sanity: not all zeros + assert_ne!(hash, [0u8; 28]); + } + + #[test] + fn test_blake2b_256_known_vector() { + let hash = CardanoSigner::blake2b_256(b""); + assert_eq!(hash.len(), 32); + assert_ne!(hash, [0u8; 32]); + } + + #[test] + fn test_staking_key_path() { + assert_eq!(staking_key_path(0), "m/1852'/1815'/0'/2/0"); + assert_eq!(staking_key_path(1), "m/1852'/1815'/1'/2/0"); + } + + #[test] + fn test_payment_key_path() { + assert_eq!(payment_key_path(0, 0), "m/1852'/1815'/0'/0/0"); + assert_eq!(payment_key_path(0, 5), "m/1852'/1815'/0'/0/5"); + } + + #[test] + fn test_reward_address_mainnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let stake_path = staking_key_path(0); + let stake_key = + HdDeriver::derive_from_mnemonic(&mnemonic, "", &stake_path, Curve::Ed25519Bip32) + .unwrap(); + let addr = reward_address(stake_key.expose(), true).unwrap(); + assert!( + addr.starts_with("stake1"), + "mainnet reward address must start with 'stake1', got: {addr}" + ); + } + + #[test] + fn test_reward_address_testnet() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let stake_path = staking_key_path(0); + let stake_key = + HdDeriver::derive_from_mnemonic(&mnemonic, "", &stake_path, Curve::Ed25519Bip32) + .unwrap(); + let addr = reward_address(stake_key.expose(), false).unwrap(); + assert!( + addr.starts_with("stake_test1"), + "testnet reward address must start with 'stake_test1', got: {addr}" + ); + } +} diff --git a/ows/crates/ows-signer/src/chains/mod.rs b/ows/crates/ows-signer/src/chains/mod.rs index b1488772..e995b91b 100644 --- a/ows/crates/ows-signer/src/chains/mod.rs +++ b/ows/crates/ows-signer/src/chains/mod.rs @@ -1,4 +1,5 @@ pub mod bitcoin; +pub mod cardano; pub mod cosmos; pub mod evm; pub mod filecoin; @@ -11,6 +12,7 @@ pub mod tron; pub mod xrpl; pub use self::bitcoin::BitcoinSigner; +pub use self::cardano::CardanoSigner; pub use self::cosmos::CosmosSigner; pub use self::evm::EvmSigner; pub use self::filecoin::FilecoinSigner; @@ -39,5 +41,6 @@ pub fn signer_for_chain(chain: ChainType) -> Box { ChainType::Sui => Box::new(SuiSigner), ChainType::Xrpl => Box::new(XrplSigner), ChainType::Nano => Box::new(NanoSigner), + ChainType::Cardano => Box::new(CardanoSigner::mainnet()), } } diff --git a/ows/crates/ows-signer/src/curve.rs b/ows/crates/ows-signer/src/curve.rs index 06bd403a..8188e059 100644 --- a/ows/crates/ows-signer/src/curve.rs +++ b/ows/crates/ows-signer/src/curve.rs @@ -3,6 +3,9 @@ pub enum Curve { Secp256k1, Ed25519, + /// Ed25519-BIP32 extended keys (Cardano CIP-1852 / BIP32-Ed25519). + /// Private keys are 64 bytes: 32-byte scalar || 32-byte extension. + Ed25519Bip32, } impl Curve { @@ -11,6 +14,7 @@ impl Curve { match self { Curve::Secp256k1 => 32, Curve::Ed25519 => 32, + Curve::Ed25519Bip32 => 64, } } @@ -19,6 +23,7 @@ impl Curve { match self { Curve::Secp256k1 => 33, // compressed Curve::Ed25519 => 32, + Curve::Ed25519Bip32 => 32, } } } diff --git a/ows/crates/ows-signer/src/hd.rs b/ows/crates/ows-signer/src/hd.rs index d86f4c5f..0635c570 100644 --- a/ows/crates/ows-signer/src/hd.rs +++ b/ows/crates/ows-signer/src/hd.rs @@ -36,6 +36,7 @@ impl HdDeriver { match curve { Curve::Secp256k1 => Self::derive_secp256k1(seed, path), Curve::Ed25519 => Self::derive_ed25519(seed, path), + Curve::Ed25519Bip32 => Self::derive_cardano_cip1852(seed, path), } } @@ -72,6 +73,7 @@ impl HdDeriver { hasher.update(match curve { Curve::Secp256k1 => b"secp256k1" as &[u8], Curve::Ed25519 => b"ed25519", + Curve::Ed25519Bip32 => b"ed25519-bip32", }); let cache_key = hex::encode(hasher.finalize()); @@ -190,6 +192,92 @@ impl HdDeriver { chain_code.zeroize(); Ok(SecretBytes::new(key)) } + + /// CIP-1852 / BIP32-Ed25519 derivation for Cardano. + /// + /// Derives an extended private key (64 bytes: scalar || extension) from a BIP-39 seed + /// using the Cardano key derivation scheme and the `ed25519-bip32` crate. + /// + /// Root key construction from seed (Shelley variant): + /// - Apply HMAC-SHA512 with key `"ed25519 cardano seed"` to the BIP-39 seed + /// - Bit-tweak the first 32 bytes to produce a valid Ed25519 scalar + /// - Second HMAC-SHA512 (with `0x01` prefix) gives the chain code + /// + /// Note: Lucid Evolution derives from BIP-39 *entropy* using the Icarus algorithm. + /// This implementation uses the BIP-39 *seed* (PBKDF2 output) which is deterministic + /// but produces different addresses than Lucid for the same mnemonic. + fn derive_cardano_cip1852(seed: &[u8], path: &str) -> Result { + use ed25519_bip32::{DerivationScheme, XPrv}; + use zeroize::Zeroize; + + // --- Root key from BIP-39 seed --- + // Pass 1: HMAC-SHA512(key="ed25519 cardano seed", data=seed) → 64 bytes + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"ed25519 cardano seed") + .expect("HMAC can take key of any size"); + mac.update(seed); + let i: [u8; 64] = mac.finalize().into_bytes().into(); + + // Bit-tweak the left 32 bytes to produce a valid Ed25519 extended scalar (kL): + // - Clear bottom 3 bits of byte 0 (multiple of cofactor 8) + // - Clear top 3 bits of byte 31 (prevent overflow past curve order) + // - Set bit 254 (0x40 in byte 31) as required by BIP32-Ed25519 + let mut kl: [u8; 32] = i[..32].try_into().unwrap(); + kl[0] &= 0b1111_1000; + kl[31] &= 0b0001_1111; + kl[31] |= 0b0100_0000; + let kr: [u8; 32] = i[32..64].try_into().unwrap(); + + // Pass 2: HMAC-SHA512(key="ed25519 cardano seed", data=[0x01] || seed) → chain code + let mut mac2 = HmacSha512::new_from_slice(b"ed25519 cardano seed") + .expect("HMAC can take key of any size"); + mac2.update(&[0x01]); + mac2.update(seed); + let chain_code_full: [u8; 64] = mac2.finalize().into_bytes().into(); + let chain_code: [u8; 32] = chain_code_full[..32].try_into().unwrap(); + + // Build root XPrv from the extended key (kL || kR) and chain code + let mut extended_key: [u8; 64] = [0u8; 64]; + extended_key[..32].copy_from_slice(&kl); + extended_key[32..64].copy_from_slice(&kr); + + let mut root = XPrv::from_extended_and_chaincode(&extended_key, &chain_code); + extended_key.zeroize(); + + // --- Child derivation following CIP-1852 path --- + // Path format: m/purpose'/coin_type'/account'/role/index + // Example: m/1852'/1815'/0'/0/0 + // DerivationIndex is u32; hardened indices have bit 31 set (0x80000000) + let components: Vec<(u32, bool)> = if path == "m" { + vec![] + } else { + path[2..] + .split('/') + .map(|c| { + let hardened = c.ends_with('\''); + let index_str = c.trim_end_matches('\''); + let index: u32 = index_str + .parse() + .map_err(|_| HdError::InvalidPath(format!("invalid index: {}", c)))?; + Ok((index, hardened)) + }) + .collect::, HdError>>()? + }; + + for (index, hardened) in &components { + let di: u32 = if *hardened { + 0x8000_0000u32 | index + } else { + *index + }; + let child = root.derive(DerivationScheme::V2, di); + root = child; + } + + // Return the 64-byte extended private key (kL || kR), without chain code + let secret_bytes = root.extended_secret_key_bytes().to_vec(); + Ok(SecretBytes::new(secret_bytes)) + } } #[cfg(test)] diff --git a/ows/crates/ows-signer/src/lib.rs b/ows/crates/ows-signer/src/lib.rs index ee4c2603..2ad7aee2 100644 --- a/ows/crates/ows-signer/src/lib.rs +++ b/ows/crates/ows-signer/src/lib.rs @@ -128,6 +128,22 @@ mod integration_tests { ); } + #[test] + fn test_full_pipeline_cardano() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let address = derive_address_for_chain(&mnemonic, ChainType::Cardano); + assert!( + address.starts_with("addr1"), + "Cardano mainnet enterprise address must start with 'addr1', got: {}", + address + ); + assert!( + address.len() > 50, + "Cardano address length must be > 50, got: {}", + address.len() + ); + } + #[test] fn test_full_pipeline_filecoin() { let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); @@ -182,6 +198,7 @@ mod integration_tests { let spark_addr = derive_address_for_chain(&mnemonic, ChainType::Spark); let fil_addr = derive_address_for_chain(&mnemonic, ChainType::Filecoin); let xrpl_addr = derive_address_for_chain(&mnemonic, ChainType::Xrpl); + let ada_addr = derive_address_for_chain(&mnemonic, ChainType::Cardano); // All addresses should be different let addrs = [ @@ -194,6 +211,7 @@ mod integration_tests { &spark_addr, &fil_addr, &xrpl_addr, + &ada_addr, ]; for i in 0..addrs.len() { for j in (i + 1)..addrs.len() { @@ -251,6 +269,22 @@ mod integration_tests { } } + #[test] + fn test_sign_roundtrip_cardano() { + let mnemonic = Mnemonic::from_phrase(ABANDON_PHRASE).unwrap(); + let signer = signer_for_chain(ChainType::Cardano); + let path = signer.default_derivation_path(0); + let key = + HdDeriver::derive_from_mnemonic(&mnemonic, "", &path, Curve::Ed25519Bip32).unwrap(); + + let result = signer + .sign(key.expose(), b"test message for cardano") + .unwrap(); + assert_eq!(result.signature.len(), 64); + assert!(result.recovery_id.is_none()); + assert_eq!(result.public_key.as_ref().unwrap().len(), 32); + } + #[test] fn test_signer_for_chain_registry() { // Verify all chain types are supported @@ -264,6 +298,7 @@ mod integration_tests { ChainType::Spark, ChainType::Filecoin, ChainType::Xrpl, + ChainType::Cardano, ] { let signer = signer_for_chain(chain); assert_eq!(signer.chain_type(), chain);