diff --git a/Cargo.lock b/Cargo.lock index a587278c..189f5798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7300,6 +7300,7 @@ dependencies = [ "frame-system-benchmarking", "frame-system-rpc-runtime-api", "hex-literal", + "libsecp256k1", "pallet-account-mapping", "pallet-account-mapping-runtime-api", "pallet-aura", diff --git a/template/node/src/relayer_register.rs b/template/node/src/relayer_register.rs index a2352b39..981dd5bf 100644 --- a/template/node/src/relayer_register.rs +++ b/template/node/src/relayer_register.rs @@ -17,11 +17,12 @@ use sp_runtime::{ codec::{Decode, Encode}, generic::Era, traits::{Block as BlockT, Zero}, - MultiSignature, }; use frame_system_rpc_runtime_api::AccountNonceApi; -use orbinum_runtime::{AccountId, Nonce, SignedExtra, SignedPayload, UncheckedExtrinsic}; +use orbinum_runtime::{ + AccountId, Nonce, OrbinumSignature, SignedExtra, SignedPayload, UncheckedExtrinsic, +}; use crate::client::FullBackend; @@ -206,7 +207,7 @@ pub async fn auto_register( let extrinsic = UncheckedExtrinsic::new_signed( call, account_id.clone(), - MultiSignature::Sr25519(signature), + OrbinumSignature::Sr25519(signature), extra, ); diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index 04c5bcab..d8818e12 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -14,6 +14,7 @@ targets = ["x86_64-unknown-linux-gnu"] [dependencies] ethereum = { workspace = true } hex-literal = { workspace = true } +libsecp256k1 = { workspace = true } scale-codec = { workspace = true } scale-info = { workspace = true } serde_json = { workspace = true, default-features = false, features = [ diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 4920bbcf..c2951cb4 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -13,6 +13,7 @@ use sp_io as _; mod account_mapping_runtime; mod genesis_config_preset; +mod orbinum_signature; mod precompiles; mod weights; @@ -34,7 +35,6 @@ use sp_core::{ crypto::{ByteArray, KeyTypeId}, ConstU128, OpaqueMetadata, H160, H256, U256, }; -use sp_runtime::MultiSignature; use sp_runtime::{ generic, impl_opaque_keys, traits::{ @@ -85,8 +85,10 @@ use precompiles::FrontierPrecompiles; pub type BlockNumber = u32; /// Alias to 512-bit hash when used in the context of a transaction signature on the chain. -/// MultiSignature supports both sr25519 (Substrate) and ECDSA (Ethereum) signatures -pub type Signature = MultiSignature; +/// OrbinumSignature unifies sr25519, ed25519, and ECDSA with EVM-compatible AccountId +/// derivation for ECDSA keys: `[eth_addr | 0x00×12]` — same as `EeSuffixAddressMapping`. +pub use orbinum_signature::OrbinumSignature; +pub type Signature = OrbinumSignature; /// Account id is always 32 bytes (AccountId32 for Substrate-native accounts) /// EVM addresses (20 bytes) are mapped to AccountId32 for compatibility diff --git a/template/runtime/src/orbinum_signature.rs b/template/runtime/src/orbinum_signature.rs new file mode 100644 index 00000000..db16c9ca --- /dev/null +++ b/template/runtime/src/orbinum_signature.rs @@ -0,0 +1,178 @@ +//! OrbinumSignature — unified signature type for Orbinum. +//! +//! Extends the standard [`sp_runtime::MultiSignature`] so that the ECDSA variant +//! derives `AccountId32` using Ethereum-compatible address encoding: +//! +//! ```text +//! AccountId32 = [keccak256(uncompressed_pubkey[1..])[12..] | 0x00 × 12] +//! ``` +//! +//! This is identical to the mapping produced by `EeSuffixAddressMapping`, meaning +//! the same secp256k1 keypair produces the **same** `AccountId32` from both an +//! EVM transaction and a native Substrate extrinsic. +//! +//! Sr25519 and Ed25519 variants behave identically to `MultiSignature`. +//! +//! ## SCALE discriminants +//! +//! The variant ordering mirrors `sp_runtime::MultiSignature` so that SCALE-encoded +//! bytes remain wire-compatible with existing tooling: +//! +//! | Discriminant | Variant | +//! |:---:|----------| +//! | 0x00 | Ed25519 | +//! | 0x01 | Sr25519 | +//! | 0x02 | Ecdsa | + +use scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_core::{ecdsa, ed25519, sr25519, H160}; +use sp_runtime::traits::{IdentifyAccount, Lazy, Verify}; + +use crate::account_mapping_runtime::evm_bytes_to_account_id_bytes; + +/// The account ID type used by this runtime. +pub type AccountId = sp_runtime::AccountId32; + +/// Derive an Ethereum `H160` address from the 64-byte uncompressed public-key +/// body (the 64 bytes *after* the `0x04` prefix byte). +/// +/// This is the standard Ethereum address derivation: +/// `keccak256(uncompressed_pubkey[1..])[12..]` +fn pub64_to_eth_address(pub64: &[u8; 64]) -> H160 { + let hash = sp_io::hashing::keccak_256(pub64); + H160::from_slice(&hash[12..]) +} + +// ─── OrbinumSigner ──────────────────────────────────────────────────────────── + +/// The signer type that corresponds to [`OrbinumSignature`]. +/// +/// For ECDSA keys the `AccountId` is derived as `[eth_address | 0x00 × 12]` +/// rather than `blake2_256(compressed_pubkey)` used by `MultiSigner`. +/// +/// Variant order matches `sp_runtime::MultiSigner` for SCALE compatibility. +#[derive( + Clone, + Eq, + PartialEq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + MaxEncodedLen +)] +pub enum OrbinumSigner { + Ed25519(ed25519::Public), + Sr25519(sr25519::Public), + /// ECDSA key — `AccountId` derived as `[eth_address | 0x00 × 12]`. + Ecdsa(ecdsa::Public), +} + +impl From for OrbinumSigner { + fn from(k: ed25519::Public) -> Self { + Self::Ed25519(k) + } +} + +impl From for OrbinumSigner { + fn from(k: sr25519::Public) -> Self { + Self::Sr25519(k) + } +} + +impl From for OrbinumSigner { + fn from(k: ecdsa::Public) -> Self { + Self::Ecdsa(k) + } +} + +impl IdentifyAccount for OrbinumSigner { + type AccountId = AccountId; + + fn into_account(self) -> AccountId { + match self { + OrbinumSigner::Ed25519(k) => sp_runtime::MultiSigner::Ed25519(k).into_account(), + OrbinumSigner::Sr25519(k) => sp_runtime::MultiSigner::Sr25519(k).into_account(), + OrbinumSigner::Ecdsa(k) => { + // Decompress the 33-byte compressed key to 65-byte uncompressed form, + // then derive the Ethereum address with keccak256. + use libsecp256k1::{PublicKey, PublicKeyFormat}; + let compressed: &[u8; 33] = k.as_ref(); + let pk = match PublicKey::parse_slice(compressed, Some(PublicKeyFormat::Compressed)) + { + Ok(pk) => pk, + // Invalid key — fall back to standard MultiSigner derivation. + Err(_) => return sp_runtime::MultiSigner::Ecdsa(k).into_account(), + }; + let uncompressed = pk.serialize(); // [u8; 65], 0x04 prefix + let pub64: [u8; 64] = uncompressed[1..65] + .try_into() + .expect("uncompressed key without prefix is exactly 64 bytes; qed"); + let h160 = pub64_to_eth_address(&pub64); + AccountId::from(evm_bytes_to_account_id_bytes(*h160.as_fixed_bytes())) + } + } + } +} + +// ─── OrbinumSignature ───────────────────────────────────────────────────────── + +/// Unified signature type for Orbinum. +/// +/// - `Ed25519` / `Sr25519`: identical to [`sp_runtime::MultiSignature`]. +/// - `Ecdsa`: verifies using `blake2_256` of the payload; the signer `AccountId` +/// is derived as `[keccak256(uncompressed_pubkey[1..])[12..] | 0x00 × 12]`, +/// matching `EeSuffixAddressMapping`. +/// +/// Variant order mirrors `sp_runtime::MultiSignature` for SCALE wire compatibility +/// (Ed25519 = 0x00, Sr25519 = 0x01, Ecdsa = 0x02). +#[derive( + Clone, + Eq, + PartialEq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + TypeInfo, + MaxEncodedLen +)] +pub enum OrbinumSignature { + Ed25519(ed25519::Signature), + Sr25519(sr25519::Signature), + /// ECDSA: payload hashed with blake2_256; AccountId via keccak256 / EVM-suffix. + Ecdsa(ecdsa::Signature), +} + +impl Verify for OrbinumSignature { + type Signer = OrbinumSigner; + + fn verify>(&self, mut msg: L, signer: &AccountId) -> bool { + match self { + OrbinumSignature::Ed25519(sig) => { + sp_runtime::MultiSignature::Ed25519(*sig).verify(msg, signer) + } + OrbinumSignature::Sr25519(sig) => { + sp_runtime::MultiSignature::Sr25519(*sig).verify(msg, signer) + } + OrbinumSignature::Ecdsa(sig) => { + // 1. Hash the payload with blake2_256 (standard Substrate signing). + let hash = sp_io::hashing::blake2_256(msg.get()); + // 2. Recover the 64-byte uncompressed public key (without 0x04 prefix). + let sig_bytes: &[u8; 65] = sig.as_ref(); + let pub64 = match sp_io::crypto::secp256k1_ecdsa_recover(sig_bytes, &hash) { + Ok(k) => k, + Err(_) => return false, + }; + // 3. Derive the Ethereum address: keccak256(pub64)[12..]. + let h160 = pub64_to_eth_address(&pub64); + // 4. Build AccountId32 = [H160 | 0x00 × 12] and compare. + let expected = + AccountId::from(evm_bytes_to_account_id_bytes(*h160.as_fixed_bytes())); + expected == *signer + } + } + } +} diff --git a/template/runtime/src/runtime_tests.rs b/template/runtime/src/runtime_tests.rs index a3a06a63..73512f94 100644 --- a/template/runtime/src/runtime_tests.rs +++ b/template/runtime/src/runtime_tests.rs @@ -1,12 +1,13 @@ -use crate::{account_mapping_runtime::EeSuffixAddressMapping, AccountId, Runtime, WeightPerGas}; +use crate::{ + account_mapping_runtime::EeSuffixAddressMapping, + orbinum_signature::{OrbinumSignature, OrbinumSigner}, + AccountId, Runtime, WeightPerGas, +}; use hex_literal::hex; use pallet_evm::AddressMapping; use sp_core::{ecdsa, sr25519, Pair, H160}; use sp_io::TestExternalities; -use sp_runtime::{ - traits::{IdentifyAccount, Verify}, - MultiSignature, MultiSigner, -}; +use sp_runtime::traits::{IdentifyAccount, Verify}; fn with_ext(run: impl FnOnce() -> R) -> R { TestExternalities::default().execute_with(run) @@ -116,12 +117,12 @@ fn sr25519_valid_signature_verifies() { let msg = b"test-multisignature-orbinum"; let sig = pair.sign(msg); - let multi_sig = MultiSignature::Sr25519(sig); + let orbinum_sig = OrbinumSignature::Sr25519(sig); - let signer_account: AccountId = MultiSigner::from(pair.public()).into_account(); + let signer_account: AccountId = OrbinumSigner::from(pair.public()).into_account(); assert!( - multi_sig.verify(msg.as_ref(), &signer_account), + orbinum_sig.verify(msg.as_ref(), &signer_account), "Sr25519 valid signature must verify against its own AccountId" ); } @@ -133,12 +134,12 @@ fn sr25519_wrong_signer_rejected() { let msg = b"test-wrong-signer"; let alice_sig = alice.sign(msg); - let multi_sig = MultiSignature::Sr25519(alice_sig); + let orbinum_sig = OrbinumSignature::Sr25519(alice_sig); - let bob_account: AccountId = MultiSigner::from(bob.public()).into_account(); + let bob_account: AccountId = OrbinumSigner::from(bob.public()).into_account(); assert!( - !multi_sig.verify(msg.as_ref(), &bob_account), + !orbinum_sig.verify(msg.as_ref(), &bob_account), "Sr25519 Alice's signature must NOT verify against Bob's account" ); } @@ -151,12 +152,12 @@ fn sr25519_wrong_message_rejected() { let different_msg = b"different-message"; let sig = pair.sign(original_msg); - let multi_sig = MultiSignature::Sr25519(sig); + let orbinum_sig = OrbinumSignature::Sr25519(sig); - let signer_account: AccountId = MultiSigner::from(pair.public()).into_account(); + let signer_account: AccountId = OrbinumSigner::from(pair.public()).into_account(); assert!( - !multi_sig.verify(different_msg.as_ref(), &signer_account), + !orbinum_sig.verify(different_msg.as_ref(), &signer_account), "Sr25519 signature over message A must NOT verify message B" ); } @@ -167,12 +168,12 @@ fn sr25519_corrupted_signature_rejected() { let msg = b"test-corrupted"; let corrupted = sr25519::Signature::default(); - let multi_sig = MultiSignature::Sr25519(corrupted); + let orbinum_sig = OrbinumSignature::Sr25519(corrupted); - let signer_account: AccountId = MultiSigner::from(pair.public()).into_account(); + let signer_account: AccountId = OrbinumSigner::from(pair.public()).into_account(); assert!( - !multi_sig.verify(msg.as_ref(), &signer_account), + !orbinum_sig.verify(msg.as_ref(), &signer_account), "Sr25519 corrupted signature must be rejected" ); } @@ -192,13 +193,13 @@ fn sr25519_each_account_verifies_only_own_signature() { "Alice and Bob have distinct keys" ); - let alice_acc: AccountId = MultiSigner::from(alice.public()).into_account(); - let bob_acc: AccountId = MultiSigner::from(bob.public()).into_account(); + let alice_acc: AccountId = OrbinumSigner::from(alice.public()).into_account(); + let bob_acc: AccountId = OrbinumSigner::from(bob.public()).into_account(); - assert!(MultiSignature::Sr25519(alice_sig).verify(msg.as_ref(), &alice_acc)); - assert!(MultiSignature::Sr25519(bob_sig).verify(msg.as_ref(), &bob_acc)); - assert!(!MultiSignature::Sr25519(alice.sign(msg)).verify(msg.as_ref(), &bob_acc)); - assert!(!MultiSignature::Sr25519(bob.sign(msg)).verify(msg.as_ref(), &alice_acc)); + assert!(OrbinumSignature::Sr25519(alice_sig).verify(msg.as_ref(), &alice_acc)); + assert!(OrbinumSignature::Sr25519(bob_sig).verify(msg.as_ref(), &bob_acc)); + assert!(!OrbinumSignature::Sr25519(alice.sign(msg)).verify(msg.as_ref(), &bob_acc)); + assert!(!OrbinumSignature::Sr25519(bob.sign(msg)).verify(msg.as_ref(), &alice_acc)); } #[test] @@ -207,12 +208,12 @@ fn ecdsa_valid_signature_verifies() { let msg = b"test-ecdsa-multisignature"; let sig = pair.sign(msg); - let multi_sig = MultiSignature::Ecdsa(sig); + let orbinum_sig = OrbinumSignature::Ecdsa(sig); - let signer_account: AccountId = MultiSigner::from(pair.public()).into_account(); + let signer_account: AccountId = OrbinumSigner::from(pair.public()).into_account(); assert!( - multi_sig.verify(msg.as_ref(), &signer_account), + orbinum_sig.verify(msg.as_ref(), &signer_account), "ECDSA valid signature must verify against its AccountId" ); } @@ -224,30 +225,41 @@ fn ecdsa_wrong_signer_rejected() { let msg = b"test-ecdsa-wrong-signer"; let alice_sig = alice.sign(msg); - let multi_sig = MultiSignature::Ecdsa(alice_sig); + let orbinum_sig = OrbinumSignature::Ecdsa(alice_sig); - let bob_account: AccountId = MultiSigner::from(bob.public()).into_account(); + let bob_account: AccountId = OrbinumSigner::from(bob.public()).into_account(); assert!( - !multi_sig.verify(msg.as_ref(), &bob_account), + !orbinum_sig.verify(msg.as_ref(), &bob_account), "ECDSA Alice's signature must NOT verify against Bob's account" ); } #[test] -fn ecdsa_substrate_and_evm_paths_are_independent() { +fn ecdsa_evm_and_substrate_paths_are_unified() { with_ext(|| { - let alith_eth_address = H160::from(hex!("f24FF3a9CF04c71Dbc94D0b566f7A27B94566cac")); + // Any ECDSA keypair: OrbinumSigner derives AccountId identically to EeSuffixAddressMapping. + let ecdsa_pair = ecdsa::Pair::from_string("//Alice", None).unwrap(); - let evm_account = EeSuffixAddressMapping::::into_account_id(alith_eth_address); + // Substrate path: OrbinumSigner::Ecdsa → [eth_addr | 0x00×12] + let substrate_account: AccountId = OrbinumSigner::Ecdsa(ecdsa_pair.public()).into_account(); - let ecdsa_pair = ecdsa::Pair::from_string("//AliceEcdsa", None).unwrap(); - let substrate_ecdsa_account: AccountId = - MultiSigner::from(ecdsa_pair.public()).into_account(); + // Verify layout: last 12 bytes must be zero (EVM-suffix format). + let bytes: &[u8; 32] = substrate_account.as_ref(); + assert_eq!( + &bytes[20..], + &[0u8; 12], + "OrbinumSigner::Ecdsa must produce AccountId32 with 12-byte zero suffix" + ); - assert_ne!( - evm_account, substrate_ecdsa_account, - "EeSuffixAddressMapping (EVM) and MultiSigner ECDSA are independent routes" + // EVM path: extract the H160 and feed it through EeSuffixAddressMapping. + let eth_addr = H160::from_slice(&bytes[..20]); + let evm_account = EeSuffixAddressMapping::::into_account_id(eth_addr); + + assert_eq!( + substrate_account, evm_account, + "OrbinumSigner::Ecdsa and EeSuffixAddressMapping must produce identical AccountId32 \ + for the same secp256k1 keypair — Phase 3 unification confirmed" ); }); } @@ -269,24 +281,113 @@ fn multisignature_variants_have_correct_byte_sizes() { assert_eq!(ecdsa_sig.0.len(), 65, "ECDSA signature must be 65 bytes"); - let _ms_sr = MultiSignature::Sr25519(sr25519_sig); - let _ms_ec = MultiSignature::Ecdsa(ecdsa_sig); + let _ms_sr = OrbinumSignature::Sr25519(sr25519_sig); + let _ms_ec = OrbinumSignature::Ecdsa(ecdsa_sig); +} + +/// `OrbinumSignature` must encode with the same SCALE discriminant bytes as +/// `sp_runtime::MultiSignature` so existing block explorers and wallets see the +/// same wire format. +/// +/// MultiSignature layout (from sp-runtime): +/// 0x00 | 64 bytes → Ed25519 +/// 0x01 | 64 bytes → Sr25519 +/// 0x02 | 65 bytes → Ecdsa +#[test] +fn orbinum_signature_scale_discriminants_match_multisignature() { + use scale_codec::Encode; + use sp_runtime::MultiSignature; + + let sr25519_pair = sr25519::Pair::from_string("//Alice", None).unwrap(); + let ecdsa_pair = ecdsa::Pair::from_string("//Alice", None).unwrap(); + let msg = b"discriminant-test"; + + let sr_sig = sr25519_pair.sign(msg); + let ec_sig = ecdsa_pair.sign(msg); + + let orbinum_sr = OrbinumSignature::Sr25519(sr_sig).encode(); + let multi_sr = MultiSignature::Sr25519(sr_sig).encode(); + assert_eq!( + orbinum_sr[0], multi_sr[0], + "Sr25519 discriminant must match MultiSignature" + ); + assert_eq!( + orbinum_sr.len(), + multi_sr.len(), + "Sr25519 encoded length must match" + ); + + let orbinum_ec = OrbinumSignature::Ecdsa(ec_sig).encode(); + let multi_ec = MultiSignature::Ecdsa(ec_sig).encode(); + assert_eq!( + orbinum_ec[0], multi_ec[0], + "Ecdsa discriminant must match MultiSignature" + ); + assert_eq!( + orbinum_ec.len(), + multi_ec.len(), + "Ecdsa encoded length must match" + ); +} + +/// Known-vector test: Alith's compressed public key is publicly documented. +/// We verify that `OrbinumSigner::Ecdsa` produces exactly the expected AccountId32 +/// without relying on the runtime's own derivation path — an independent check. +/// +/// Alith data (Moonbeam/Frontier dev accounts, well-known secp256k1 keypair): +/// private key : 0x5fb92d6e98884f76de468fa3f6278f8807c48bebc13595d45af5bdc4da702133 +/// H160 : 0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac +/// compressed pk (33 bytes): 02509540919faacf9ab52146c9aa40db68172d83777250b28e4679176e49ccdd9f +/// +/// Expected AccountId32 = [H160 | 0x00×12] +#[test] +fn ecdsa_known_vector_alith_derives_correct_account() { + with_ext(|| { + // Alith compressed public key (33 bytes). + let compressed_pk: [u8; 33] = + hex!("02509540919faacf9ab52146c9aa40db68172d83777250b28e4679176e49ccdd9f"); + let alith_h160: [u8; 20] = hex!("f24FF3a9CF04c71Dbc94D0b566f7A27B94566cac"); + + let pub_key = sp_core::ecdsa::Public::from_raw(compressed_pk); + let derived: AccountId = OrbinumSigner::Ecdsa(pub_key).into_account(); + let derived_bytes: &[u8; 32] = derived.as_ref(); + + // First 20 bytes must equal Alith's H160. + assert_eq!( + &derived_bytes[..20], + &alith_h160, + "OrbinumSigner::Ecdsa must derive Alith's H160 as the first 20 bytes" + ); + // Last 12 bytes must be zero (EVM-suffix layout). + assert_eq!( + &derived_bytes[20..], + &[0u8; 12], + "OrbinumSigner::Ecdsa must produce 12-byte zero suffix" + ); + + // Must also match EeSuffixAddressMapping for the same H160. + let evm_account = + EeSuffixAddressMapping::::into_account_id(H160::from(alith_h160)); + assert_eq!( + derived, evm_account, + "OrbinumSigner::Ecdsa(Alith) must equal EeSuffixAddressMapping(Alith H160)" + ); + }); } -fn api_validate_signature(signature: MultiSignature, message: &[u8], signer: &AccountId) -> bool { - use sp_runtime::traits::Verify; +fn api_validate_signature(signature: OrbinumSignature, message: &[u8], signer: &AccountId) -> bool { signature.verify(message, signer) } fn sr25519_account(derivation: &str) -> (sr25519::Pair, AccountId) { let pair = sr25519::Pair::from_string(derivation, None).unwrap(); - let account: AccountId = MultiSigner::from(pair.public()).into_account(); + let account: AccountId = OrbinumSigner::from(pair.public()).into_account(); (pair, account) } fn ecdsa_account(derivation: &str) -> (ecdsa::Pair, AccountId) { let pair = ecdsa::Pair::from_string(derivation, None).unwrap(); - let account: AccountId = MultiSigner::from(pair.public()).into_account(); + let account: AccountId = OrbinumSigner::Ecdsa(pair.public()).into_account(); (pair, account) } @@ -324,7 +425,7 @@ fn same_account_single_nonce_regardless_of_signature_type() { let (alice_sr, alice_sr_account) = sr25519_account("//AliceNonce"); let msg = b"nonce-invariant-test"; - let sig_sr = MultiSignature::Sr25519(alice_sr.sign(msg)); + let sig_sr = OrbinumSignature::Sr25519(alice_sr.sign(msg)); assert!( api_validate_signature(sig_sr, msg, &alice_sr_account), "Sr25519 signature for the correct AccountId always verifies"