diff --git a/pallets/limit-orders/Cargo.toml b/pallets/limit-orders/Cargo.toml index 675f2636dc..72e4e7ddbf 100644 --- a/pallets/limit-orders/Cargo.toml +++ b/pallets/limit-orders/Cargo.toml @@ -12,7 +12,10 @@ sp-keyring = { workspace = true, optional = true } frame-support.workspace = true frame-system.workspace = true scale-info.workspace = true -sp-core.workspace = true +# `serde` is what gates `Ss58Codec::to_ss58check_with_version`, used to render +# accounts into the human-readable order message. Despite the name it is sp-core's +# "serde support without relying on std features", so it works in the wasm build. +sp-core = { workspace = true, features = ["serde"] } sp-runtime.workspace = true sp-std.workspace = true log.workspace = true diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 937cc3ee7e..6f33c65010 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -11,20 +11,38 @@ use sp_core::{Get, H256}; use sp_runtime::{AccountId32, MultiSignature, Perbill, traits::AccountIdConversion}; extern crate alloc; use crate::{Call, Config, Pallet}; -use codec::Encode; /// Sign a versioned order using the runtime keystore (no `full_crypto` required). /// /// The key identified by `public` must already be registered in the keystore /// (e.g. via `sp_io::crypto::sr25519_generate`) before calling this. +/// +/// The order is signed in the **human-readable ("clear-signing") form** on +/// purpose: it is the worst case for `is_order_valid`, which tries +/// `verify_order` (raw) and `verify_wrapped` (hash) first and only succeeds on +/// the final `verify_readable`. Signing this form forces all three signature +/// verifications to run, so the measured weight reflects the true worst case. fn sign_order( public: sp_core::sr25519::Public, order: &crate::VersionedOrder, ) -> crate::SignedOrder { - let order_hash = sp_io::hashing::blake2_256(&order.encode()); - let payload = [b"".as_slice(), &order_hash, b"".as_slice()].concat(); - let sig = sp_io::crypto::sr25519_sign(sp_core::crypto::key_types::ACCOUNT, &public, &payload) - .unwrap(); + // Mirror the on-chain check in `verify_readable`: the signed message is the + // ``-wrapped canonical readable rendering of the order, hashed + // when it exceeds Ledger's raw-signing limit (which it always does in practice) + // exactly as the device does before signing. + let msg = crate::pallet::Pallet::::render_order(order); + let payload = [b"".as_slice(), &msg, b"".as_slice()].concat(); + let signed_bytes = if payload.len() > crate::LEDGER_MAX_SIGN_SIZE { + sp_core::hashing::blake2_256(&payload).to_vec() + } else { + payload + }; + let sig = sp_io::crypto::sr25519_sign( + sp_core::crypto::key_types::ACCOUNT, + &public, + &signed_bytes, + ) + .unwrap(); crate::SignedOrder { order: order.clone(), signature: MultiSignature::Sr25519(sig), diff --git a/pallets/limit-orders/src/lib.rs b/pallets/limit-orders/src/lib.rs index f7a7c2a07e..8917ff29a1 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -26,6 +26,20 @@ use subtensor_macros::freeze_struct; use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance, Token}; use subtensor_swap_interface::OrderSwapInterface; +/// Ledger's raw-signing size limit — `MAX_SIGN_SIZE` in the Zondax Polkadot app +/// (`app/src/coin.h`), mirroring the 256-byte rule Substrate applies to extrinsic +/// signing payloads. +/// +/// A `signRaw` payload longer than this is **blake2_256-hashed on-device** before +/// the ed25519 signature is produced (`crypto_sign_ed25519` in `app/src/crypto.c`), +/// so the signature commits to the hash of the payload rather than to the payload +/// bytes. The device still displays the full message: the printable-ASCII check and +/// pagination in `tx_raw_getItem` operate on the received buffer, and the hashing +/// happens later, in the signing step only. Clear-signing therefore remains +/// what-you-see-is-what-you-sign — the on-chain verifier just has to accept the +/// hashed commitment as well (see `verify_readable`). +pub const LEDGER_MAX_SIGN_SIZE: usize = 256; + // ── Data structures ────────────────────────────────────────────────────────── /// Internal direction of a net pool trade. Used only for `GroupExecutionSummary` @@ -134,15 +148,19 @@ impl VersionedOrd /// the user's signature over the order. /// /// Signature verification is performed against `order.inner().signer` (the AccountId) -/// directly. Sr25519 and ed25519 signatures over either the SCALE-encoded order or its -/// ``-wrapped blake2-256 hash are accepted; ecdsa is rejected at validation time. -#[freeze_struct("9dd5a8ac812dc504")] +/// directly, and either signing form is accepted (see `verify_order` / `verify_wrapped`): +/// - raw: the SCALE-encoded `VersionedOrder`, or +/// - wrapped: `` + `blake2_256(SCALE_ENCODE(VersionedOrder))` (the `OrderId`) + ``, +/// the `signRaw` envelope used by Polkadot.js / Ledger. +/// Both sr25519 and ed25519 signatures are accepted; ecdsa is rejected at validation time. +#[freeze_struct("969452eb68f33c4")] #[derive( Encode, Decode, DecodeWithMemTracking, TypeInfo, MaxEncodedLen, Clone, PartialEq, Eq, Debug, )] pub struct SignedOrder { pub order: VersionedOrder, - /// Sr25519 or ed25519 signature over the raw order or its wrapped hash. + /// Sr25519 or ed25519 signature over either the raw SCALE-encoded `VersionedOrder` + /// or the ``-wrapped order hash (see `verify_order` / `verify_wrapped`). pub signature: MultiSignature, /// Whether we want a partial fill for this order pub partial_fill: Option, @@ -202,6 +220,9 @@ pub mod pallet { transactional, }; use frame_system::pallet_prelude::*; + use alloc::format; + use alloc::string::String; + use sp_core::crypto::{Ss58AddressFormat, Ss58Codec}; use sp_runtime::traits::AccountIdConversion; use sp_std::collections::btree_set::BTreeSet; use sp_std::vec::Vec; @@ -596,6 +617,159 @@ pub mod pallet { T::SwapInterface::transfer_tao(signer, recipient, fee_tao) } + /// Verify the signature over the **raw** SCALE-encoded order — the original, + /// non-Ledger form a software wallet signing arbitrary bytes produces. + /// Accepts sr25519 and ed25519; rejects ecdsa. + pub(crate) fn verify_order(signed_order: &SignedOrder) -> bool { + let order = signed_order.order.inner(); + matches!( + signed_order.signature, + MultiSignature::Sr25519(_) | MultiSignature::Ed25519(_) + ) && signed_order + .signature + .verify(signed_order.order.encode().as_slice(), &order.signer) + } + + /// Verify the signature over the **wrapped order hash** — the Ledger/`signRaw` + /// form: `` + `blake2_256(SCALE_ENCODE(order))` (i.e. `order_id`) + ``. + /// Signing a fixed-size hash keeps the message within Ledger's signing limits, + /// and the `` envelope is what `signRaw` (Polkadot.js / Ledger) + /// wraps around raw payloads. Accepts sr25519 and ed25519; rejects ecdsa. + pub(crate) fn verify_wrapped( + signed_order: &SignedOrder, + order_id: H256, + ) -> bool { + let order = signed_order.order.inner(); + let payload = [ + b"".as_slice(), + order_id.as_bytes(), + b"".as_slice(), + ] + .concat(); + matches!( + signed_order.signature, + MultiSignature::Sr25519(_) | MultiSignature::Ed25519(_) + ) && signed_order + .signature + .verify(payload.as_slice(), &order.signer) + } + + /// Render `who` into its SS58 (base58check) string, reproducing + /// `Ss58Codec::to_ss58check_with_version`. + pub(crate) fn render_account(who: &T::AccountId) -> String { + let prefix = ::SS58Prefix::get(); + who.to_ss58check_with_version(Ss58AddressFormat::custom(prefix)) + } + + /// Build the canonical, single-line, all-printable-ASCII "clear-signing" + /// message for a versioned order. + /// + /// This is a PURE function of the order's fields: every token is a + /// deterministic rendering of a runtime field, so a TS frontend can rebuild + /// the exact same bytes and have a hardware wallet display and sign them. + /// + /// The `none` vs `[]` distinction for the relayer field is deliberate and + /// load-bearing: it prevents a signature produced for an "any relayer" order + /// from being transplanted onto an "empty relayer list" order (or vice versa). + pub(crate) fn render_order(order: &VersionedOrder) -> Vec { + let (version, o) = match order { + VersionedOrder::V1(o) => ("v1", o), + }; + + let label = match o.order_type { + OrderType::LimitBuy => "Limit buy", + OrderType::TakeProfit => "Take-profit", + OrderType::StopLoss => "Stop-loss", + }; + let price_word = match o.order_type { + OrderType::LimitBuy => "limit price", + OrderType::TakeProfit | OrderType::StopLoss => "trigger price", + }; + + let netuid: u16 = u16::from(o.netuid); + + let max_slippage = match o.max_slippage { + None => String::from("none"), + Some(p) => format!("{}", p.deconstruct()), + }; + + let relayer = match &o.relayer { + None => String::from("none"), + Some(list) if list.is_empty() => String::from("[]"), + Some(list) => { + let mut acc = String::new(); + for (i, r) in list.iter().enumerate() { + if i > 0 { + acc.push('+'); + } + acc.push_str(&Self::render_account(r)); + } + acc + } + }; + + let msg = format!( + "TAO.com order {version}: {label} {amount} on subnet {netuid}, \ +{price_word} {limit_price}, expiry {expiry}, hotkey {hotkey}, \ +fee {fee_rate} to {fee_recipient}, relayer {relayer}, \ +max slippage {max_slippage}, chain {chain_id}, \ +partial fills {partial}, signer {signer}", + version = version, + label = label, + amount = o.amount, + netuid = netuid, + price_word = price_word, + limit_price = o.limit_price, + expiry = o.expiry, + hotkey = Self::render_account(&o.hotkey), + fee_rate = o.fee_rate.deconstruct(), + fee_recipient = Self::render_account(&o.fee_recipient), + relayer = relayer, + max_slippage = max_slippage, + chain_id = o.chain_id, + partial = o.partial_fills_enabled, + signer = Self::render_account(&o.signer), + ); + + msg.into_bytes() + } + + /// Verify the signature over the **human-readable** ("clear-signing") message — + /// the form a hardware wallet (Ledger) can display to the user field-by-field + /// and sign. The signed payload is the ``-wrapped canonical message built + /// by [`render_order`] (the `signRaw`/Ledger envelope). Accepts sr25519 and + /// ed25519; rejects ecdsa. + /// + /// The bytes actually verified follow the device's own rule: a raw-signing + /// payload longer than [`LEDGER_MAX_SIGN_SIZE`] is blake2_256-hashed on-device + /// before signing, so for an oversized payload the signature commits to + /// `blake2_256(payload)` and that is what is verified; at or below the limit the + /// payload bytes are verified directly. In practice the readable message is + /// always oversized (three SS58 addresses alone are 144 characters), so the + /// hashed branch is the live one — the byte-exact branch exists to keep this + /// function correct for any future, shorter rendering. + /// + /// The sibling forms need no such rule: `verify_wrapped`'s payload is a fixed + /// 47 bytes (`` + 32-byte hash + ``), and `verify_order` is not a + /// Ledger form at all — it has no `` envelope, which the device's + /// `tx_raw_parse` requires, so a Ledger can never produce it. + pub(crate) fn verify_readable(signed_order: &SignedOrder) -> bool { + let order = signed_order.order.inner(); + let msg = Self::render_order(&signed_order.order); + let payload = [b"".as_slice(), &msg, b"".as_slice()].concat(); + let signed_bytes = if payload.len() > LEDGER_MAX_SIGN_SIZE { + sp_core::hashing::blake2_256(&payload).to_vec() + } else { + payload + }; + matches!( + signed_order.signature, + MultiSignature::Sr25519(_) | MultiSignature::Ed25519(_) + ) && signed_order + .signature + .verify(signed_bytes.as_slice(), &order.signer) + } + /// Validates all execution preconditions for a signed order. /// Checks that the order's netuid is not root (0), that the signature is valid, /// the order has not been processed, is not expired, and the price condition is met. @@ -613,23 +787,21 @@ pub mod pallet { order.chain_id == T::ChainId::get(), Error::::ChainIdMismatch ); + // Accept either signing form: the legacy raw form (`verify_order`, + // signature directly over the SCALE-encoded order) or the Ledger/`signRaw` + // form (`verify_wrapped`, signature over the ``-wrapped order + // hash). Both are checked so software wallets signing raw bytes and hardware + // wallets that can only sign wrapped messages are simultaneously supported. + // The raw form is checked first: it short-circuits the common relayer flow, + // and an order signed in the wrapped form falls through to a second verify. + // The human-readable ("clear-signing") form is checked LAST: it is the least + // common and involves the SS58/format rendering work, so it should only run + // on fall-through. Exercising all three verifications is the worst case the + // weights must account for. ensure!( - matches!( - signed_order.signature, - MultiSignature::Sr25519(_) | MultiSignature::Ed25519(_) - ) && (signed_order - .signature - .verify(signed_order.order.encode().as_slice(), &order.signer) - || signed_order.signature.verify( - [ - b"".as_slice(), - order_id.as_bytes(), - b"".as_slice(), - ] - .concat() - .as_slice(), - &order.signer, - )), + Self::verify_order(signed_order) + || Self::verify_wrapped(signed_order, order_id) + || Self::verify_readable(signed_order), Error::::InvalidSignature ); let order_status = Orders::::get(order_id); diff --git a/pallets/limit-orders/src/tests/auxiliary.rs b/pallets/limit-orders/src/tests/auxiliary.rs index 351859a291..9d75e93790 100644 --- a/pallets/limit-orders/src/tests/auxiliary.rs +++ b/pallets/limit-orders/src/tests/auxiliary.rs @@ -435,7 +435,9 @@ fn validate_and_classify_stores_effective_swap_limit_for_buy() { o }; let versioned = crate::VersionedOrder::V1(new_inner.clone()); - let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let sig = AccountKeyring::Alice + .pair() + .sign(&order_signing_payload(&versioned)); let signed_with_slippage = crate::SignedOrder { order: versioned, signature: sp_runtime::MultiSignature::Sr25519(sig), @@ -478,7 +480,9 @@ fn validate_and_classify_stores_effective_swap_limit_for_sell() { partial_fills_enabled: false, }; let versioned = crate::VersionedOrder::V1(new_inner); - let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let sig = AccountKeyring::Alice + .pair() + .sign(&order_signing_payload(&versioned)); let signed = crate::SignedOrder { order: versioned, signature: sp_runtime::MultiSignature::Sr25519(sig), @@ -1409,10 +1413,7 @@ fn collect_fees_no_transfer_when_zero_fees() { use crate::Error; use codec::Encode; use sp_core::Pair; -use sp_runtime::{ - MultiSignature, MultiSigner, - traits::{IdentifyAccount, Verify}, -}; +use sp_runtime::MultiSignature; use subtensor_swap_interface::OrderSwapInterface; fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { @@ -1433,7 +1434,7 @@ fn make_valid_signed_order() -> (crate::SignedOrder, sp_core::H256) { partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); let signed = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), @@ -1465,8 +1466,10 @@ fn is_order_valid_invalid_signature_returns_error() { MockTime::set(1_000_000); MockSwap::set_price(1.0); let (mut signed, id) = make_valid_signed_order(); - // Replace with a signature from a different key. - let wrong_sig = AccountKeyring::Bob.pair().sign(&signed.order.encode()); + // Replace with a signature over the correct payload but from a different key. + let wrong_sig = AccountKeyring::Bob + .pair() + .sign(&order_signing_payload(&signed.order)); signed.signature = MultiSignature::Sr25519(wrong_sig); let price = MockSwap::current_alpha_price(netuid()); assert_noop!( @@ -1477,42 +1480,39 @@ fn is_order_valid_invalid_signature_returns_error() { } #[test] -fn is_order_valid_accepts_raw_ed25519_signature() { +fn is_order_valid_accepts_ed25519_signature() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(1.0); - let (signed, _) = make_valid_signed_order(); + + // The `signer` field must match the ed25519 public key, so derive the + // AccountId from the ed25519 pair rather than reusing Alice's sr25519 key. let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); + let ed_signer = AccountId::from(ed_pair.public()); + let order = crate::VersionedOrder::V1(crate::Order { - signer: AccountId::from(ed_pair.public()), - ..signed.order.inner().clone() + signer: ed_signer, + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); - let signature = ed_pair.sign(&order.encode()); + let ed_sig = ed_pair.sign(&order_signing_payload(&order)); let signed = crate::SignedOrder { order, - signature: MultiSignature::Ed25519(signature), + signature: MultiSignature::Ed25519(ed_sig), partial_fill: None, }; - let price = MockSwap::current_alpha_price(netuid()); - assert_ok!(LimitOrders::::is_order_valid( - &signed, - id, - 1_000_000, - price, - &bob() - )); - }); -} -#[test] -fn is_order_valid_accepts_wrapped_sr25519_signature() { - new_test_ext().execute_with(|| { - MockTime::set(1_000_000); - MockSwap::set_price(1.0); - let (mut signed, id) = make_valid_signed_order(); - let payload = [b"".as_slice(), id.as_bytes(), b"".as_slice()].concat(); - signed.signature = MultiSignature::Sr25519(AccountKeyring::Alice.pair().sign(&payload)); let price = MockSwap::current_alpha_price(netuid()); assert_ok!(LimitOrders::::is_order_valid( &signed, @@ -1525,54 +1525,25 @@ fn is_order_valid_accepts_wrapped_sr25519_signature() { } #[test] -fn is_order_valid_accepts_wrapped_ed25519_signature() { +fn is_order_valid_rejects_ecdsa_signature() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(1.0); - let (signed, _) = make_valid_signed_order(); - let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); - let order = crate::VersionedOrder::V1(crate::Order { - signer: AccountId::from(ed_pair.public()), - ..signed.order.inner().clone() - }); - let id = H256(sp_io::hashing::blake2_256(&order.encode())); - let payload = [b"".as_slice(), id.as_bytes(), b"".as_slice()].concat(); - let signed = crate::SignedOrder { - order, - signature: MultiSignature::Ed25519(ed_pair.sign(&payload)), - partial_fill: None, - }; - let price = MockSwap::current_alpha_price(netuid()); - assert_ok!(LimitOrders::::is_order_valid( - &signed, - id, - 1_000_000, - price, - &bob() - )); - }); -} -#[test] -fn is_order_valid_ecdsa_signature_returns_error() { - new_test_ext().execute_with(|| { - MockTime::set(1_000_000); - MockSwap::set_price(1.0); - let (signed, _) = make_valid_signed_order(); - let pair = sp_core::ecdsa::Pair::from_legacy_string("//Alice", None); - let signer = MultiSigner::from(pair.public()).into_account(); - let order = crate::VersionedOrder::V1(crate::Order { - signer, - ..signed.order.inner().clone() - }); - let id = H256(sp_io::hashing::blake2_256(&order.encode())); - let signature = MultiSignature::Ecdsa(pair.sign(&order.encode())); - assert!(signature.verify(order.encode().as_slice(), &order.inner().signer)); + // Even a valid ecdsa signature over the correct payload must be rejected: + // only sr25519 and ed25519 are accepted. + let (order, id) = { + let (signed, id) = make_valid_signed_order(); + (signed.order, id) + }; + let ecdsa_pair = sp_core::ecdsa::Pair::from_legacy_string("//Alice", None); + let ecdsa_sig = ecdsa_pair.sign(&order_signing_payload(&order)); let signed = crate::SignedOrder { order, - signature, + signature: MultiSignature::Ecdsa(ecdsa_sig), partial_fill: None, }; + let price = MockSwap::current_alpha_price(netuid()); assert_noop!( LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), @@ -1609,7 +1580,7 @@ fn is_order_valid_expired_order_returns_error() { ..signed.order.inner().clone() }); let id2 = H256(sp_io::hashing::blake2_256(&order.encode())); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); let signed2 = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), @@ -1646,7 +1617,7 @@ fn is_order_valid_price_condition_not_met_returns_error() { partial_fills_enabled: false, }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); let signed = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), @@ -1672,7 +1643,7 @@ fn is_order_valid_wrong_chain_id_returns_error() { ..make_valid_signed_order().0.order.inner().clone() }); let id = H256(sp_io::hashing::blake2_256(&order.encode())); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); let signed = crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), @@ -1686,6 +1657,154 @@ fn is_order_valid_wrong_chain_id_returns_error() { }); } +#[test] +fn is_order_valid_accepts_raw_sr25519_signature() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // Legacy raw form: sign the SCALE-encoded order directly (NOT the + // ``-wrapped hash). This exercises the `verify_order` + // branch of the `verify_order(..) || verify_wrapped(..)` check. + let keyring = AccountKeyring::Alice; + let order = crate::VersionedOrder::V1(crate::Order { + signer: keyring.to_account_id(), + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + // Sign the raw encoded order, not the wrapped payload. + let sig = keyring.pair().sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + + let price = MockSwap::current_alpha_price(netuid()); + assert_ok!(LimitOrders::::is_order_valid( + &signed, + id, + 1_000_000, + price, + &bob() + )); + }); +} + +#[test] +fn is_order_valid_accepts_raw_ed25519_signature() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // ed25519 signer over the RAW encoded order (legacy form). The `signer` + // field must be the ed25519 public key for verification to succeed. + let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); + let ed_signer = AccountId::from(ed_pair.public()); + + let order = crate::VersionedOrder::V1(crate::Order { + signer: ed_signer, + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + // Sign the raw encoded order, not the wrapped payload. + let ed_sig = ed_pair.sign(&order.encode()); + let signed = crate::SignedOrder { + order, + signature: MultiSignature::Ed25519(ed_sig), + partial_fill: None, + }; + + let price = MockSwap::current_alpha_price(netuid()); + assert_ok!(LimitOrders::::is_order_valid( + &signed, + id, + 1_000_000, + price, + &bob() + )); + }); +} + +#[test] +fn verify_order_and_verify_wrapped_unit() { + new_test_ext().execute_with(|| { + let keyring = AccountKeyring::Alice; + let order = crate::VersionedOrder::V1(crate::Order { + signer: keyring.to_account_id(), + hotkey: AccountKeyring::Bob.to_account_id(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }); + let id = H256(sp_io::hashing::blake2_256(&order.encode())); + + // Raw-signed order: signature over `order.encode()`. + // verify_order must accept it; verify_wrapped must reject it. + let raw_sig = keyring.pair().sign(&order.encode()); + let raw_signed = crate::SignedOrder { + order: order.clone(), + signature: MultiSignature::Sr25519(raw_sig), + partial_fill: None, + }; + assert!( + LimitOrders::::verify_order(&raw_signed), + "raw-signed order must pass verify_order" + ); + assert!( + !LimitOrders::::verify_wrapped(&raw_signed, id), + "raw-signed order must NOT pass verify_wrapped" + ); + + // Wrapped-signed order: signature over the `` payload. + // verify_wrapped must accept it; verify_order must reject it. + let wrapped_sig = keyring.pair().sign(&order_signing_payload(&order)); + let wrapped_signed = crate::SignedOrder { + order, + signature: MultiSignature::Sr25519(wrapped_sig), + partial_fill: None, + }; + assert!( + !LimitOrders::::verify_order(&wrapped_signed), + "wrapped-signed order must NOT pass verify_order" + ); + assert!( + LimitOrders::::verify_wrapped(&wrapped_signed, id), + "wrapped-signed order must pass verify_wrapped" + ); + }); +} + // ───────────────────────────────────────────────────────────────────────────── // compute_order_status // ───────────────────────────────────────────────────────────────────────────── diff --git a/pallets/limit-orders/src/tests/extrinsics.rs b/pallets/limit-orders/src/tests/extrinsics.rs index 43a85a1db1..1859f1015f 100644 --- a/pallets/limit-orders/src/tests/extrinsics.rs +++ b/pallets/limit-orders/src/tests/extrinsics.rs @@ -5,7 +5,6 @@ //! and event emission are all verified. SwapInterface calls are handled by //! `MockSwap`, which records calls and maintains in-memory balance ledgers. -use codec::Encode; use frame_support::{BoundedVec, assert_noop, assert_ok}; use sp_core::Pair; use sp_keyring::Sr25519Keyring as AccountKeyring; @@ -2182,7 +2181,7 @@ fn make_signed_order_with_slippage( chain_id: 945, partial_fills_enabled: false, }); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); crate::SignedOrder { order, signature: sp_runtime::MultiSignature::Sr25519(sig), @@ -2983,7 +2982,9 @@ fn execute_orders_partial_fill_without_relayer_skipped() { partial_fills_enabled: true, }; let versioned = VersionedOrder::V1(inner); - let sig = AccountKeyring::Alice.pair().sign(&versioned.encode()); + let sig = AccountKeyring::Alice + .pair() + .sign(&order_signing_payload(&versioned)); let signed = crate::SignedOrder { order: versioned, signature: sp_runtime::MultiSignature::Sr25519(sig), diff --git a/pallets/limit-orders/src/tests/ledger_vector.rs b/pallets/limit-orders/src/tests/ledger_vector.rs new file mode 100644 index 0000000000..31c43e8113 --- /dev/null +++ b/pallets/limit-orders/src/tests/ledger_vector.rs @@ -0,0 +1,419 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +//! Hardware test vector for the human-readable ("clear-signing") signing form. +//! +//! ## What this pins +//! +//! A Ledger blake2_256-hashes a raw-signing (`signRaw`) payload longer than +//! `MAX_SIGN_SIZE` = 256 bytes before signing it (`crypto_sign_ed25519` in +//! `app/src/crypto.c` of the Zondax Polkadot app; the same `app_sign_ed25519` +//! callback serves both `INS_SIGN` and `INS_SIGN_RAW`). The readable message is +//! always over that limit, so a real device signature commits to +//! `blake2_256( ++ message ++ )`, never to the payload bytes — +//! which is why `verify_readable` follows the same rule. +//! +//! That rule is NOT the symmetric one in `Encode for SignedPayload` +//! (`sp_runtime::generic::unchecked_extrinsic`): that impl is only reached when the +//! extrinsic machinery rebuilds a *transaction* signing payload, and both signer and +//! verifier go through it. Raw message signing has no such mirror — polkadot-js's +//! `pair.sign()` applies a length rule for ecdsa only — so on this path the device +//! hashes and a software signer does not. +//! +//! Two things are pinned here: +//! 1. `render_order` produces byte-for-byte the message the device displayed and +//! signed, and its wrapped payload hashes to the digest the device signed over. +//! 2. That digest, not the payload, is what the recorded device signature verifies +//! against — on real hardware. +//! +//! ## Provenance +//! +//! Captured 2026-07-28 from a Nano S+ running Polkadot Generic v100.0.25, derivation +//! path `m/44'/354'/0'/0'/0'`. The device rendered the whole order text across its +//! screens before signing, so digest signing is NOT a blind-signing fallback: +//! clear-signing works, and shrinking the message under 256 bytes would only reduce +//! the page count. A probe matrix ruled out the alternatives (unwrapped message, +//! `blake2_256` of the unwrapped message, blake2_512, ASCII hex of the digest) — +//! all of them are re-checked below. +//! +//! ## Why this is not an end-to-end order test +//! +//! The capture's device key is not the account named in the message's `signer` field +//! (the message was rendered for a different account), and `verify_readable` checks +//! the signature against `order.signer`. So the vector pins the *rule* and the +//! *rendering*, not order execution; the acceptance path itself is covered by +//! `tests/readable.rs`. An executable vector needs a fresh capture whose message +//! renders `signer` as the device's own address, with `chain_id` matching the test +//! environment and `expiry` in milliseconds (this one's `1793000000` is a +//! seconds-scale value, i.e. long expired). +//! +//! The *executable* vector at the bottom of this file closes that gap without a +//! device: the software half's seed is known, ed25519 is deterministic (RFC 8032), +//! and the device's only transformation is the conditional hash — so a signature +//! minted offline over `blake2_256(payload)` for a message naming that account as +//! `signer` is byte-identical to what a Ledger holding the seed would return, and it +//! goes through the full acceptance path. + +use frame_support::{assert_ok, traits::Get}; +use sp_core::{Pair, crypto::Ss58Codec, hexdisplay::HexDisplay}; +use sp_runtime::{MultiSignature, Perbill, traits::Verify}; +use subtensor_runtime_common::NetUid; +use subtensor_swap_interface::OrderSwapInterface; + +use crate::pallet::Pallet as LimitOrders; +use crate::{LEDGER_MAX_SIGN_SIZE, Order, OrderType, VersionedOrder}; + +use super::mock::*; + +// ── The vector ─────────────────────────────────────────────────────────────── + +/// The exact text the device displayed and signed: `render_order`'s output for +/// [`vector_order`], SS58 prefix 42. 381 bytes. +const ORDER_MESSAGE: &str = "TAO.com order v1: Limit buy 1000000000 on subnet 64, \ +limit price 500000000, expiry 1793000000, \ +hotkey 5HK5tp6t2S59DywmHRWPBVJeJ86T61KjurYqeooqj8sREpeN, \ +fee 8500000 to 5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY, \ +relayer 5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty, max slippage 7500000, \ +chain 1, partial fills true, signer 5CD9UfFv3FLd9BRP8tK7BumpEYvu2y3KZMuhUnDAhuzPbdtC"; + +/// Length of `ORDER_MESSAGE` in bytes. +const ORDER_MESSAGE_LEN: usize = 381; + +/// Length of `` ++ `ORDER_MESSAGE` ++ `` — the blob that reaches the +/// device, and what it hashes because 396 > 256. +const WRAPPED_PAYLOAD_LEN: usize = 396; + +/// `blake2_256( ++ ORDER_MESSAGE ++ )`: the 32 bytes the device signs. +const WRAPPED_PAYLOAD_DIGEST: [u8; 32] = [ + 0x3c, 0x3e, 0xa8, 0x8b, 0x51, 0x45, 0x71, 0x89, 0x38, 0x89, 0x06, 0xee, 0xcb, 0x58, 0x2d, 0x5e, + 0xbf, 0x48, 0x1b, 0x1a, 0xf5, 0xb6, 0x6b, 0x6b, 0x57, 0x71, 0xe4, 0xe8, 0x4b, 0x6e, 0x5e, 0xd7, +]; + +/// ed25519 public key of the Nano S+ account that produced [`DEVICE_SIGNATURE`]. +const DEVICE_PUBLIC_KEY: [u8; 32] = [ + 0x76, 0xe2, 0x81, 0x5d, 0x89, 0xea, 0x8f, 0x87, 0xa7, 0xfc, 0x62, 0xc2, 0x1b, 0x3e, 0xe2, 0xfb, + 0x81, 0xd7, 0x8c, 0xa2, 0x8a, 0x24, 0xd3, 0x3a, 0x97, 0x4f, 0x47, 0xb2, 0x0b, 0xb7, 0x0a, 0x63, +]; + +/// The signature the device returned for the 396-byte wrapped payload. +const DEVICE_SIGNATURE: [u8; 64] = [ + 0x91, 0xa3, 0x7e, 0x50, 0xd0, 0x1e, 0xeb, 0x40, 0x7d, 0x9d, 0x19, 0x02, 0x37, 0x4f, 0xef, 0x24, + 0xdc, 0x28, 0x7c, 0x1e, 0xdb, 0x81, 0x47, 0x4d, 0xbe, 0x19, 0xe4, 0x61, 0x57, 0xbc, 0xc2, 0x3d, + 0xc5, 0xb7, 0xba, 0x72, 0x3a, 0xe7, 0xf8, 0xdd, 0x19, 0x20, 0x04, 0xc1, 0x50, 0xa8, 0xd0, 0x47, + 0x9a, 0x0c, 0xcb, 0x52, 0x2c, 0x93, 0x0d, 0xc8, 0xfc, 0xda, 0x7a, 0x15, 0xd6, 0xb4, 0x5b, 0x08, +]; + +/// Software half of the vector: reproducible without a device. Seed `0x01..0x20`, +/// with a signature over EACH form so both semantics have a fixture. +const SOFTWARE_SEED: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, +]; + +/// ed25519 public key derived from [`SOFTWARE_SEED`]. +const SOFTWARE_PUBLIC_KEY: [u8; 32] = [ + 0x79, 0xb5, 0x56, 0x2e, 0x8f, 0xe6, 0x54, 0xf9, 0x40, 0x78, 0xb1, 0x12, 0xe8, 0xa9, 0x8b, 0xa7, + 0x90, 0x1f, 0x85, 0x3a, 0xe6, 0x95, 0xbe, 0xd7, 0xe0, 0xe3, 0x91, 0x0b, 0xad, 0x04, 0x96, 0x64, +]; + +/// `ed25519(SOFTWARE_SEED, wrapped payload)` — the shape a software `signRaw` emits. +const SOFTWARE_SIGNATURE_OVER_PAYLOAD: [u8; 64] = [ + 0xc8, 0xd1, 0x2f, 0xfc, 0xdc, 0x50, 0x4a, 0x95, 0x6b, 0x97, 0xfd, 0x67, 0x00, 0x9d, 0xe2, 0x8c, + 0x65, 0x41, 0xbf, 0x79, 0xdc, 0x33, 0x90, 0x30, 0x92, 0xd9, 0xf1, 0xc2, 0x79, 0x71, 0x8c, 0x97, + 0x91, 0xcf, 0x5b, 0xc6, 0x9a, 0x38, 0x89, 0xc6, 0x69, 0x9a, 0x5a, 0xab, 0x18, 0x17, 0x0c, 0xdc, + 0x23, 0x66, 0xf8, 0x1d, 0xae, 0xa5, 0xec, 0xd3, 0x1c, 0x64, 0x10, 0x83, 0x85, 0xb1, 0xb6, 0x0d, +]; + +/// `ed25519(SOFTWARE_SEED, blake2_256(wrapped payload))` — the shape the device emits. +const SOFTWARE_SIGNATURE_OVER_DIGEST: [u8; 64] = [ + 0x81, 0xed, 0xd4, 0x7a, 0x3e, 0x02, 0xb7, 0xf5, 0x2c, 0xc6, 0xa7, 0xdb, 0x02, 0xe9, 0xa8, 0xc0, + 0x23, 0xc1, 0xf1, 0x01, 0x52, 0x6a, 0x7d, 0x5f, 0xe4, 0xbe, 0x11, 0x8a, 0xff, 0x36, 0x09, 0x0c, + 0xcf, 0x65, 0xf7, 0x51, 0x36, 0xb3, 0x1f, 0x6f, 0x64, 0xd8, 0xbc, 0xec, 0xc4, 0xe4, 0x41, 0xb3, + 0x23, 0x22, 0xc3, 0x7b, 0x4a, 0xf5, 0x14, 0x36, 0xa1, 0xe9, 0x90, 0x96, 0x20, 0x2b, 0x86, 0x08, +]; + +/// The order whose rendering is `ORDER_MESSAGE`. Accounts are decoded from the SS58 +/// strings in the message itself, so the rendering assertion is a round-trip through +/// `Ss58Codec` rather than a comparison of one `render_account` call against another. +fn vector_order() -> Order { + let account = |s: &str| AccountId::from_ss58check(s).expect("vector SS58 must decode"); + Order { + signer: account("5CD9UfFv3FLd9BRP8tK7BumpEYvu2y3KZMuhUnDAhuzPbdtC"), + hotkey: account("5HK5tp6t2S59DywmHRWPBVJeJ86T61KjurYqeooqj8sREpeN"), + netuid: NetUid::from(64u16), + order_type: OrderType::LimitBuy, + amount: 1_000_000_000, + limit_price: 500_000_000, + expiry: 1_793_000_000, + fee_rate: Perbill::from_parts(8_500_000), + fee_recipient: account("5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY"), + relayer: Some( + frame_support::BoundedVec::try_from(vec![account( + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + )]) + .unwrap(), + ), + max_slippage: Some(Perbill::from_parts(7_500_000)), + chain_id: 1, + partial_fills_enabled: true, + } +} + +/// `` ++ `ORDER_MESSAGE` ++ ``, built from the pinned text rather than +/// from `render_order`, so the two can be compared. +fn wrapped_payload() -> Vec { + [ + b"".as_slice(), + ORDER_MESSAGE.as_bytes(), + b"".as_slice(), + ] + .concat() +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// `render_order` must reproduce the message the device actually displayed and +/// signed. If this fails, the pallet and the hardware no longer agree on what a +/// signature means — every signature captured by a user becomes unverifiable. +#[test] +fn render_order_matches_the_device_displayed_message() { + new_test_ext().execute_with(|| { + assert_eq!( + <::SS58Prefix as Get>::get(), + 42, + "the vector was captured at SS58 prefix 42" + ); + + let rendered = LimitOrders::::render_order(&VersionedOrder::V1(vector_order())); + assert_eq!( + String::from_utf8(rendered.clone()).unwrap(), + ORDER_MESSAGE, + "render_order drifted from the message a real Ledger signed" + ); + assert_eq!(rendered.len(), ORDER_MESSAGE_LEN); + for (i, b) in rendered.iter().enumerate() { + assert!( + (0x20..=0x7e).contains(b), + "byte {i} = {b:#x} is not printable ASCII, so the device would render \ + it as hex instead of text" + ); + } + }); +} + +/// The wrapped payload is over Ledger's limit and hashes to the digest the device +/// signed. Ties our own bytes to the recorded hardware signature. +#[test] +fn wrapped_payload_is_oversized_and_hashes_to_the_signed_digest() { + new_test_ext().execute_with(|| { + let from_render = { + let msg = LimitOrders::::render_order(&VersionedOrder::V1(vector_order())); + [b"".as_slice(), &msg, b"".as_slice()].concat() + }; + assert_eq!(from_render, wrapped_payload()); + assert_eq!(from_render.len(), WRAPPED_PAYLOAD_LEN); + assert!( + from_render.len() > LEDGER_MAX_SIGN_SIZE, + "396 bytes must exceed the {LEDGER_MAX_SIGN_SIZE}-byte device limit" + ); + assert_eq!( + sp_core::hashing::blake2_256(&from_render), + WRAPPED_PAYLOAD_DIGEST + ); + }); +} + +/// The hardware fact this whole branch rests on: the Nano S+ signature verifies +/// against `blake2_256(payload)` and against nothing else. The rejected forms are +/// the alternatives the capture's probe matrix ruled out. +#[test] +fn device_signature_is_over_the_blake2_256_digest_only() { + new_test_ext().execute_with(|| { + let signer = AccountId::new(DEVICE_PUBLIC_KEY); + let signature = + MultiSignature::Ed25519(sp_core::ed25519::Signature::from_raw(DEVICE_SIGNATURE)); + let payload = wrapped_payload(); + let message = ORDER_MESSAGE.as_bytes(); + + assert!( + signature.verify(&WRAPPED_PAYLOAD_DIGEST[..], &signer), + "recorded device signature must verify over blake2_256(wrapped payload)" + ); + + for (form, bytes) in [ + ("the raw wrapped payload", payload.clone()), + ("the unwrapped message", message.to_vec()), + ( + "blake2_256 of the unwrapped message", + sp_core::hashing::blake2_256(message).to_vec(), + ), + ( + "blake2_512 of the wrapped payload", + sp_core::hashing::blake2_512(&payload).to_vec(), + ), + ( + // Lowercase hex without `0x`, i.e. what a JS `u8aToHex(d).slice(2)` + // would have put on the wire. + "the ASCII hex of the digest", + format!("{}", HexDisplay::from(&WRAPPED_PAYLOAD_DIGEST)).into_bytes(), + ), + ] { + assert!( + !signature.verify(bytes.as_slice(), &signer), + "device signature must NOT verify over {form}" + ); + } + }); +} + +/// The software half, and the reason a verifier cannot be lenient: the two forms are +/// mutually unverifiable, so signer and verifier disagreeing about which one is in +/// play is a hard rejection, never a soft fallback. +#[test] +fn software_vector_forms_are_mutually_unverifiable() { + new_test_ext().execute_with(|| { + let pair = sp_core::ed25519::Pair::from_seed(&SOFTWARE_SEED); + assert_eq!( + AccountId::from(pair.public()), + AccountId::new(SOFTWARE_PUBLIC_KEY), + "sp-core must derive the pinned public key from the pinned seed" + ); + + let signer = AccountId::new(SOFTWARE_PUBLIC_KEY); + let over_payload = MultiSignature::Ed25519(sp_core::ed25519::Signature::from_raw( + SOFTWARE_SIGNATURE_OVER_PAYLOAD, + )); + let over_digest = MultiSignature::Ed25519(sp_core::ed25519::Signature::from_raw( + SOFTWARE_SIGNATURE_OVER_DIGEST, + )); + let payload = wrapped_payload(); + + assert!(over_payload.verify(payload.as_slice(), &signer)); + assert!(!over_payload.verify(&WRAPPED_PAYLOAD_DIGEST[..], &signer)); + assert!(over_digest.verify(&WRAPPED_PAYLOAD_DIGEST[..], &signer)); + assert!(!over_digest.verify(payload.as_slice(), &signer)); + }); +} + +// ── Executable vector ──────────────────────────────────────────────────────── +// +// The hardware capture above cannot be submitted as an order: its message names +// `5CD9UfFv…` as the signer while the device that signed holds `5Ekanz…`, and +// `verify_readable` checks the signature against `order.signer`. Rejecting it is +// correct, so the acceptance path needs a vector whose message names the signing +// account. +// +// This one is minted offline from `SOFTWARE_SEED` (which we hold), over +// `blake2_256( ++ message ++ )` — the same bytes the device signs. +// ed25519 is deterministic and the transformation is fixed, so these are exactly +// the bytes a Ledger holding that seed would return for this order. What it does +// NOT do is attest to device behaviour; that is what the capture above is for. +// +// Fields are chosen to clear every non-signature guard in `is_order_valid` under +// the default mock: netuid 1 (non-root), chain 945 (the mock's `ChainId`), expiry +// `u64::MAX`, no relayer restriction, and limit price 1.0 TAO/alpha so the LimitBuy +// trigger fires at the mock price. Hotkey and fee recipient are the well-known dev +// accounts Bob (sr25519) and Charlie, decoded from SS58 so the same constants are +// reusable from TypeScript. + +/// Rendering of [`executable_vector_order`], signed by `SOFTWARE_SEED`'s account. +const EXECUTABLE_MESSAGE: &str = "TAO.com order v1: Limit buy 1000 on subnet 1, \ +limit price 1000000000, expiry 18446744073709551615, \ +hotkey 5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty, \ +fee 0 to 5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y, \ +relayer none, max slippage none, chain 945, \ +partial fills false, signer 5EpHX5foDtnhZngj4GsKq5eKGpUvuMqbpUG48ZfCCCs7EzKR"; + +/// `blake2_256` of the wrapped [`EXECUTABLE_MESSAGE`] — 350 bytes wrapped, so hashed. +const EXECUTABLE_VECTOR_DIGEST: [u8; 32] = [ + 0xcd, 0x8f, 0x76, 0xe8, 0x89, 0xc5, 0x86, 0xd5, 0xef, 0xb7, 0x3d, 0xd0, 0x34, 0x33, 0xdc, 0x16, + 0x4b, 0x75, 0xfd, 0x72, 0x7c, 0x52, 0xaa, 0xa4, 0xc8, 0xd0, 0x7e, 0xb1, 0x3d, 0xc9, 0x8c, 0x12, +]; + +/// `ed25519(SOFTWARE_SEED, EXECUTABLE_VECTOR_DIGEST)`. +const EXECUTABLE_VECTOR_SIGNATURE: [u8; 64] = [ + 0xca, 0x9e, 0x4c, 0x33, 0x69, 0x50, 0x72, 0xff, 0xef, 0x1e, 0x3e, 0x1d, 0x07, 0x15, 0x97, 0x9e, + 0x3a, 0x4d, 0x8b, 0x55, 0x3e, 0xe1, 0xec, 0xc2, 0x9e, 0x5d, 0xa9, 0xea, 0xd5, 0x78, 0x89, 0x10, + 0x4d, 0x4b, 0xc7, 0x76, 0x0c, 0x4f, 0x9a, 0x86, 0x7e, 0x82, 0xb0, 0x46, 0x27, 0x1e, 0x47, 0xb5, + 0x54, 0x60, 0x94, 0x89, 0xd6, 0x66, 0x16, 0x8b, 0x54, 0x9d, 0x75, 0xb3, 0x9b, 0x55, 0x9b, 0x04, +]; + +/// The order [`EXECUTABLE_VECTOR_SIGNATURE`] authorises. Its `signer` IS the account +/// that signed, which is what makes it submittable. +fn executable_vector_order() -> Order { + let account = |s: &str| AccountId::from_ss58check(s).expect("vector SS58 must decode"); + Order { + signer: AccountId::new(SOFTWARE_PUBLIC_KEY), + hotkey: account("5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"), + netuid: NetUid::from(1u16), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: 1_000_000_000, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: account("5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y"), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + } +} + +/// The frozen signature must be the one `SOFTWARE_SEED` produces for this order's +/// rendering — pins the message, the digest, and the signature together, so any drift +/// in `render_order` fails here with the message diff rather than as an opaque +/// signature rejection. +#[test] +fn executable_vector_is_the_seeds_signature_over_the_rendered_message() { + new_test_ext().execute_with(|| { + let rendered = LimitOrders::::render_order(&VersionedOrder::V1( + executable_vector_order(), + )); + assert_eq!( + String::from_utf8(rendered.clone()).unwrap(), + EXECUTABLE_MESSAGE, + "render_order drifted from the message the frozen signature covers" + ); + + let payload = [b"".as_slice(), &rendered, b"".as_slice()].concat(); + assert!(payload.len() > LEDGER_MAX_SIGN_SIZE, "must be hashed, not signed bare"); + assert_eq!( + sp_core::hashing::blake2_256(&payload), + EXECUTABLE_VECTOR_DIGEST + ); + assert_eq!( + sp_core::ed25519::Pair::from_seed(&SOFTWARE_SEED).sign(&EXECUTABLE_VECTOR_DIGEST), + sp_core::ed25519::Signature::from_raw(EXECUTABLE_VECTOR_SIGNATURE), + "ed25519 is deterministic, so the seed must reproduce the frozen signature" + ); + }); +} + +/// The point of the whole exercise: a hardcoded, device-shaped signature is accepted +/// by `verify_readable` and clears the full validation chain. +#[test] +fn executable_vector_is_accepted_by_verify_readable_and_is_order_valid() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let signed = crate::SignedOrder { + order: VersionedOrder::V1(executable_vector_order()), + signature: MultiSignature::Ed25519(sp_core::ed25519::Signature::from_raw( + EXECUTABLE_VECTOR_SIGNATURE, + )), + partial_fill: None, + }; + let id = LimitOrders::::derive_order_id(&signed.order); + + assert!( + LimitOrders::::verify_readable(&signed), + "a signature in the form a Ledger emits must pass verify_readable" + ); + assert_ok!(LimitOrders::::is_order_valid( + &signed, + id, + 1_000_000, + MockSwap::current_alpha_price(netuid()), + &bob() + )); + }); +} diff --git a/pallets/limit-orders/src/tests/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 2834c54afe..a5c359641b 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use codec::Encode; use frame_support::{ BoundedVec, PalletId, construct_runtime, derive_impl, parameter_types, - traits::{ConstU32, ConstU64, Everything}, + traits::{ConstU16, ConstU32, ConstU64, Everything}, }; use frame_system as system; use sp_core::{H256, Pair}; @@ -51,6 +51,11 @@ impl system::Config for Test { type MaxConsumers = ConstU32<16>; type Nonce = u64; type Block = Block; + /// Pinned to Bittensor's real prefix (42) because `render_account` renders + /// accounts into the readable signing message under this value. Leaving it at + /// `TestDefaultConfig`'s default (`()` → 0) would make the readable-form tests + /// exercise a prefix the chain never uses. + type SS58Prefix = ConstU16<42>; } // ── MockSwap ───────────────────────────────────────────────────────────────── @@ -559,6 +564,16 @@ pub fn netuid() -> NetUid { pub const FAR_FUTURE: u64 = u64::MAX; +/// Build the raw payload that the order's `signer` must sign. +/// +/// Mirrors the production logic in `is_order_valid`: the signed message is the +/// `` `signRaw` envelope wrapped around the 32-byte order hash +/// (`blake2_256(SCALE_ENCODE(VersionedOrder))`, i.e. the `OrderId`). +pub fn order_signing_payload(order: &crate::VersionedOrder) -> Vec { + let id = sp_io::hashing::blake2_256(&order.encode()); + [b"".as_slice(), &id, b"".as_slice()].concat() +} + #[allow(clippy::too_many_arguments)] pub fn make_signed_order( keyring: AccountKeyring, @@ -588,7 +603,7 @@ pub fn make_signed_order( chain_id: 945, partial_fills_enabled: false, }); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), @@ -626,7 +641,7 @@ pub fn make_partial_fill_order( chain_id: 945, partial_fills_enabled: true, }); - let sig = keyring.pair().sign(&order.encode()); + let sig = keyring.pair().sign(&order_signing_payload(&order)); crate::SignedOrder { order, signature: MultiSignature::Sr25519(sig), diff --git a/pallets/limit-orders/src/tests/mod.rs b/pallets/limit-orders/src/tests/mod.rs index 95e0875b26..fe941a9878 100644 --- a/pallets/limit-orders/src/tests/mod.rs +++ b/pallets/limit-orders/src/tests/mod.rs @@ -1,4 +1,6 @@ pub mod auxiliary; pub mod extrinsics; +pub mod ledger_vector; pub mod migration; pub mod mock; +pub mod readable; diff --git a/pallets/limit-orders/src/tests/readable.rs b/pallets/limit-orders/src/tests/readable.rs new file mode 100644 index 0000000000..bd7d724351 --- /dev/null +++ b/pallets/limit-orders/src/tests/readable.rs @@ -0,0 +1,618 @@ +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] +//! Unit tests for the human-readable ("clear-signing") signature path in +//! `pallet-limit-orders`: `render_account`, `render_order`, `verify_readable`, +//! and their acceptance/rejection through `is_order_valid`. +//! +//! These exercise the third verification branch +//! (`verify_order || verify_wrapped || verify_readable`), the SS58 rendering that +//! feeds it, the injectivity of the canonical message (a change to ANY order field +//! must produce a different message and therefore break the original signature), +//! and the deliberate `none` vs `[]` relayer-rendering distinction. + +use frame_support::{ + BoundedVec, assert_noop, assert_ok, + traits::{ConstU32, Get}, +}; +use sp_core::{H256, Pair}; +use sp_core::crypto::{Ss58AddressFormat, Ss58Codec}; +use sp_keyring::Sr25519Keyring as AccountKeyring; +use sp_runtime::{MultiSignature, Perbill}; +use subtensor_runtime_common::NetUid; +use subtensor_swap_interface::OrderSwapInterface; + +use crate::pallet::Pallet as LimitOrders; +use crate::{Error, Order, OrderType, VersionedOrder}; + +use super::mock::*; + +/// The SS58 prefix accounts are rendered under, read from the same place +/// `render_account` reads it — the chain's own `frame_system::Config::SS58Prefix` +/// (pinned to 42 in the mock). Deliberately not a second hardcoded literal, so the +/// test cannot silently disagree with the runtime about the prefix. +fn ss58_prefix() -> u16 { + <::SS58Prefix as Get>::get() +} + +/// Canonical `Ss58Codec` reconstruction of an account, used to rebuild the expected +/// SS58 strings in the golden-message tests. +fn canonical_ss58(acct: &AccountId) -> String { + acct.to_ss58check_with_version(Ss58AddressFormat::custom(ss58_prefix())) +} + +/// Build the payload the readable path signs: the `` `signRaw` +/// envelope wrapped around the canonical clear-signing message. Reconstructed +/// here from the same rendering the pallet uses so the test signs exactly what +/// `verify_readable` verifies. +fn readable_signing_payload(order: &VersionedOrder) -> Vec { + let msg = LimitOrders::::render_order(order); + [b"".as_slice(), &msg, b"".as_slice()].concat() +} + +/// The bytes a signer actually puts through ed25519/sr25519 for the readable form — +/// i.e. what a Ledger emits. The device blake2_256-hashes a raw-signing payload +/// longer than `LEDGER_MAX_SIGN_SIZE` before signing it, and `verify_readable` +/// follows the same rule, so these tests must too. +fn readable_signed_bytes(order: &VersionedOrder) -> Vec { + let payload = readable_signing_payload(order); + if payload.len() > crate::LEDGER_MAX_SIGN_SIZE { + sp_core::hashing::blake2_256(&payload).to_vec() + } else { + payload + } +} + +/// A fully-specified LimitBuy order that passes every non-signature guard in +/// `is_order_valid` under the default mock setup (netuid 1, chain 945, far-future +/// expiry, no relayer restriction, price condition met at price 1.0). +fn base_buy_order() -> Order { + Order { + signer: alice(), + hotkey: bob(), + netuid: netuid(), + order_type: OrderType::LimitBuy, + amount: 1_000, + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + } +} + +/// Sign `order` with the readable (`` ++ render_order ++ ``) form +/// using an sr25519 keyring. The `order.signer` must correspond to `keyring`. +fn make_readable_signed_order( + keyring: AccountKeyring, + order: Order, +) -> crate::SignedOrder { + let versioned = VersionedOrder::V1(order); + let sig = keyring.pair().sign(&readable_signed_bytes(&versioned)); + crate::SignedOrder { + order: versioned, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// A. SS58 prefix wiring +// ───────────────────────────────────────────────────────────────────────────── + +/// `render_account` delegates encoding to sp-core's `Ss58Codec`, so the encoding +/// itself needs no cross-check. What this pins down is the *prefix wiring*: that +/// accounts are rendered under the chain's own `frame_system::Config::SS58Prefix` +/// and not some other value. If the pallet ever regressed to a local constant, or +/// read the prefix from the wrong place, this fails. +#[test] +fn render_account_uses_chain_ss58_prefix() { + new_test_ext().execute_with(|| { + assert_eq!(ss58_prefix(), 42, "mock must pin Bittensor's real prefix"); + let cases = vec![ + alice(), + bob(), + AccountId::new([0x00; 32]), + AccountId::new([0xff; 32]), + ]; + for acct in cases { + let rendered = LimitOrders::::render_account(&acct); + let canonical = canonical_ss58(&acct); + assert_eq!( + rendered, canonical, + "render_account must encode at the chain's SS58 prefix for {acct:?}" + ); + } + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// B. render_order golden vectors +// ───────────────────────────────────────────────────────────────────────────── + +/// Independently reconstruct the canonical message from the order's fields using +/// the SS58 oracle. Deliberately NOT a copy of production `format!`. +fn expected_message( + label: &str, + price_word: &str, + amount: u64, + netuid_val: u16, + limit_price: u64, + expiry: u64, + hotkey: &AccountId, + fee_rate_ppb: u32, + fee_recipient: &AccountId, + relayer_str: &str, + max_slippage_str: &str, + chain_id: u64, + partial: bool, + signer: &AccountId, +) -> String { + format!( + "TAO.com order v1: {label} {amount} on subnet {netuid_val}, \ +{price_word} {limit_price}, expiry {expiry}, hotkey {hotkey}, \ +fee {fee_rate_ppb} to {fee_recipient}, relayer {relayer_str}, \ +max slippage {max_slippage_str}, chain {chain_id}, \ +partial fills {partial}, signer {signer}", + hotkey = canonical_ss58(hotkey), + fee_recipient = canonical_ss58(fee_recipient), + signer = canonical_ss58(signer), + ) +} + +fn assert_all_printable_ascii(bytes: &[u8]) { + for (i, b) in bytes.iter().enumerate() { + assert!( + (0x20..=0x7e).contains(b), + "byte {i} = {b:#x} is not printable ASCII (Ledger-renderability invariant)" + ); + } +} + +#[test] +fn render_order_golden_limit_buy_relayer_none() { + new_test_ext().execute_with(|| { + let order = Order { + signer: alice(), + hotkey: bob(), + netuid: NetUid::from(7u16), + order_type: OrderType::LimitBuy, + amount: 1_234_567, + limit_price: 2_000_000_000, + expiry: 9_999_999, + fee_rate: Perbill::from_parts(5_000_000), + fee_recipient: fee_recipient(), + relayer: None, + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }; + let versioned = VersionedOrder::V1(order); + let rendered = LimitOrders::::render_order(&versioned); + let expected = expected_message( + "Limit buy", + "limit price", + 1_234_567, + 7, + 2_000_000_000, + 9_999_999, + &bob(), + 5_000_000, + &fee_recipient(), + "none", + "none", + 945, + false, + &alice(), + ); + assert_eq!(String::from_utf8(rendered.clone()).unwrap(), expected); + assert_all_printable_ascii(&rendered); + }); +} + +#[test] +fn render_order_golden_stop_loss_trigger_price_and_slippage() { + new_test_ext().execute_with(|| { + // StopLoss → label "Stop-loss", price word "trigger price". + // max_slippage Some(1%) → "10000000" ppb. + let order = Order { + signer: charlie(), + hotkey: dave(), + netuid: NetUid::from(2u16), + order_type: OrderType::StopLoss, + amount: 500, + limit_price: 750_000_000, + expiry: 42, + fee_rate: Perbill::zero(), + fee_recipient: alice(), + relayer: None, + max_slippage: Some(Perbill::from_percent(1)), + chain_id: 945, + partial_fills_enabled: true, + }; + let versioned = VersionedOrder::V1(order); + let rendered = LimitOrders::::render_order(&versioned); + let expected = expected_message( + "Stop-loss", + "trigger price", + 500, + 2, + 750_000_000, + 42, + &dave(), + 0, + &alice(), + "none", + &Perbill::from_percent(1).deconstruct().to_string(), + 945, + true, + &charlie(), + ); + assert_eq!(String::from_utf8(rendered.clone()).unwrap(), expected); + assert_all_printable_ascii(&rendered); + }); +} + +#[test] +fn render_order_golden_take_profit_two_relayers() { + new_test_ext().execute_with(|| { + // Take-profit → label "Take-profit", price word "trigger price". + // Two-relayer list → rendered accounts joined with '+'. + let relayers: BoundedVec> = + BoundedVec::try_from(vec![bob(), charlie()]).unwrap(); + let order = Order { + signer: alice(), + hotkey: dave(), + netuid: NetUid::from(1u16), + order_type: OrderType::TakeProfit, + amount: 88, + limit_price: 1_000_000_000, + expiry: 100_000, + fee_rate: Perbill::from_parts(1), + fee_recipient: fee_recipient(), + relayer: Some(relayers), + max_slippage: None, + chain_id: 945, + partial_fills_enabled: false, + }; + let versioned = VersionedOrder::V1(order); + let rendered = LimitOrders::::render_order(&versioned); + let relayer_str = format!("{}+{}", canonical_ss58(&bob()), canonical_ss58(&charlie())); + let expected = expected_message( + "Take-profit", + "trigger price", + 88, + 1, + 1_000_000_000, + 100_000, + &dave(), + 1, + &fee_recipient(), + &relayer_str, + "none", + 945, + false, + &alice(), + ); + assert_eq!(String::from_utf8(rendered.clone()).unwrap(), expected); + assert_all_printable_ascii(&rendered); + }); +} + +#[test] +fn render_order_golden_relayer_empty_list() { + new_test_ext().execute_with(|| { + // Some(empty) must render as "[]", distinct from None → "none". + let empty: BoundedVec> = BoundedVec::try_from(vec![]).unwrap(); + let order = Order { + relayer: Some(empty), + ..base_buy_order() + }; + let versioned = VersionedOrder::V1(order); + let rendered = LimitOrders::::render_order(&versioned); + let expected = expected_message( + "Limit buy", + "limit price", + 1_000, + u16::from(netuid()), + u64::MAX, + u64::MAX, + &bob(), + 0, + &fee_recipient(), + "[]", + "none", + 945, + false, + &alice(), + ); + assert_eq!(String::from_utf8(rendered.clone()).unwrap(), expected); + assert_all_printable_ascii(&rendered); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// C. verify_readable / is_order_valid accepts a readable-signed order +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn is_order_valid_accepts_readable_sr25519_signature() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let order = base_buy_order(); + let signed = make_readable_signed_order(AccountKeyring::Alice, order); + let id = LimitOrders::::derive_order_id(&signed.order); + + // Direct branch check. + assert!( + LimitOrders::::verify_readable(&signed), + "readable-signed order must pass verify_readable" + ); + // And through the full validation chain. + let price = MockSwap::current_alpha_price(netuid()); + assert_ok!(LimitOrders::::is_order_valid( + &signed, + id, + 1_000_000, + price, + &bob() + )); + }); +} + +#[test] +fn is_order_valid_accepts_readable_ed25519_signature() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // The signer field must be the ed25519 public key for verification. + let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); + let ed_signer = AccountId::from(ed_pair.public()); + + let order = Order { + signer: ed_signer, + ..base_buy_order() + }; + let versioned = VersionedOrder::V1(order); + let ed_sig = ed_pair.sign(&readable_signed_bytes(&versioned)); + let signed = crate::SignedOrder { + order: versioned, + signature: MultiSignature::Ed25519(ed_sig), + partial_fill: None, + }; + let id = LimitOrders::::derive_order_id(&signed.order); + + assert!( + LimitOrders::::verify_readable(&signed), + "ed25519 readable-signed order must pass verify_readable" + ); + let price = MockSwap::current_alpha_price(netuid()); + assert_ok!(LimitOrders::::is_order_valid( + &signed, + id, + 1_000_000, + price, + &bob() + )); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// D. Per-field mutation sweep (injectivity of the canonical message) +// ───────────────────────────────────────────────────────────────────────────── +// +// Sign a readable order, then for EACH order field build a clone that changes +// ONLY that field while KEEPING the original signature. The mutated order renders +// to a different message, so verify_readable (and verify_order / verify_wrapped) +// all fail → is_order_valid returns InvalidSignature — except where an earlier +// guard fires first (netuid==root, chain_id!=configured), noted per case. + +/// Sign `base` readably, then swap in `mutated` (same signer) while KEEPING the +/// signature computed over `base`'s rendered message. +fn transplant_signature( + keyring: AccountKeyring, + base: Order, + mutated: Order, +) -> (crate::SignedOrder, H256) { + let signed_base = make_readable_signed_order(keyring, base); + let versioned = VersionedOrder::V1(mutated); + let id = LimitOrders::::derive_order_id(&versioned); + let signed = crate::SignedOrder { + order: versioned, + signature: signed_base.signature, + partial_fill: None, + }; + (signed, id) +} + +/// Run a mutation whose only changed field is `mutate(base)`, asserting the +/// transplanted signature is rejected as InvalidSignature. +fn assert_field_mutation_rejected(mutate: impl FnOnce(&mut Order)) { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let base = base_buy_order(); + let mut mutated = base.clone(); + mutate(&mut mutated); + assert_ne!( + base, mutated, + "mutation must actually change the order (test bug otherwise)" + ); + + let (signed, id) = transplant_signature(AccountKeyring::Alice, base, mutated); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::InvalidSignature + ); + }); +} + +#[test] +fn mutation_signer_rejected() { + // New signer renders differently AND the sig is verified against the new + // signer's key → InvalidSignature. netuid non-root, chain_id 945 keep the + // signature check reachable. + assert_field_mutation_rejected(|o| o.signer = bob()); +} + +#[test] +fn mutation_hotkey_rejected() { + assert_field_mutation_rejected(|o| o.hotkey = charlie()); +} + +#[test] +fn mutation_netuid_rejected() { + // Mutate to a NON-root netuid (2) so RootNetUidNotAllowed does not pre-empt + // the signature check. + assert_field_mutation_rejected(|o| o.netuid = NetUid::from(2u16)); +} + +#[test] +fn mutation_order_type_rejected() { + // LimitBuy → StopLoss changes both label and price word in the message. + assert_field_mutation_rejected(|o| o.order_type = OrderType::StopLoss); +} + +#[test] +fn mutation_amount_rejected() { + assert_field_mutation_rejected(|o| o.amount = 2_000); +} + +#[test] +fn mutation_limit_price_rejected() { + assert_field_mutation_rejected(|o| o.limit_price = u64::MAX - 1); +} + +#[test] +fn mutation_expiry_rejected() { + assert_field_mutation_rejected(|o| o.expiry = u64::MAX - 1); +} + +#[test] +fn mutation_fee_rate_rejected() { + assert_field_mutation_rejected(|o| o.fee_rate = Perbill::from_parts(1)); +} + +#[test] +fn mutation_fee_recipient_rejected() { + assert_field_mutation_rejected(|o| o.fee_recipient = charlie()); +} + +#[test] +fn mutation_relayer_rejected() { + // None → Some([charlie]) changes the relayer rendering. + assert_field_mutation_rejected(|o| { + o.relayer = Some(BoundedVec::try_from(vec![charlie()]).unwrap()) + }); +} + +#[test] +fn mutation_max_slippage_rejected() { + // None → Some(1%) changes "none" → "10000000". + assert_field_mutation_rejected(|o| o.max_slippage = Some(Perbill::from_percent(1))); +} + +#[test] +fn mutation_partial_fills_enabled_rejected() { + assert_field_mutation_rejected(|o| o.partial_fills_enabled = true); +} + +#[test] +fn mutation_chain_id_pre_empted_by_chain_id_guard() { + // NOTE: chain_id is validated BEFORE the signature in `is_order_valid` + // (`ensure!(order.chain_id == T::ChainId::get(), ChainIdMismatch)`). + // Any change to chain_id makes it != 945, so ChainIdMismatch is reached + // first and InvalidSignature is NOT reachable for this field. We assert the + // specific reachable error instead. (The message still renders differently, + // so the signature would also fail — but the guard short-circuits.) + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + let base = base_buy_order(); + let mutated = Order { + chain_id: 946, + ..base.clone() + }; + let (signed, id) = transplant_signature(AccountKeyring::Alice, base, mutated); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::ChainIdMismatch + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// E. Relayer None-vs-empty transplant +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn relayer_none_to_empty_transplant_rejected() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // Sign with relayer: None ("none"). + let base = Order { + relayer: None, + ..base_buy_order() + }; + // Transplant onto relayer: Some(empty) ("[]"). The `none` vs `[]` rendering + // distinction must make the message — and therefore the signature — differ. + let empty: BoundedVec> = BoundedVec::try_from(vec![]).unwrap(); + let mutated = Order { + relayer: Some(empty), + ..base_buy_order() + }; + assert_ne!(base, mutated, "None and Some(empty) must differ"); + + let (signed, id) = transplant_signature(AccountKeyring::Alice, base, mutated); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::InvalidSignature + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// F. ecdsa rejected on the readable path +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn readable_ecdsa_signature_rejected() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // A well-formed ecdsa signature over the correct readable payload must + // still be rejected: only sr25519 and ed25519 are accepted. + let order = base_buy_order(); + let versioned = VersionedOrder::V1(order); + let ecdsa_pair = sp_core::ecdsa::Pair::from_legacy_string("//Alice", None); + let ecdsa_sig = ecdsa_pair.sign(&readable_signed_bytes(&versioned)); + let signed = crate::SignedOrder { + order: versioned, + signature: MultiSignature::Ecdsa(ecdsa_sig), + partial_fill: None, + }; + let id = LimitOrders::::derive_order_id(&signed.order); + + assert!( + !LimitOrders::::verify_readable(&signed), + "ecdsa signature must not pass verify_readable" + ); + let price = MockSwap::current_alpha_price(netuid()); + assert_noop!( + LimitOrders::::is_order_valid(&signed, id, 1_000_000, price, &bob()), + Error::::InvalidSignature + ); + }); +} diff --git a/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index 1fcfc43be8..1f62b4f4f9 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -19,6 +19,7 @@ use pallet_limit_orders::{ }; use pallet_subtensor::{SubnetAlphaIn, SubnetMechanism, SubnetTAO}; use sp_core::{Get, H256, Pair}; +use sp_core::crypto::{Ss58AddressFormat, Ss58Codec}; use sp_keyring::Sr25519Keyring; use sp_runtime::traits::{AccountIdConversion, IdentifyAccount, Verify}; use sp_runtime::{MultiSignature, MultiSigner, Perbill}; @@ -294,9 +295,10 @@ fn cancel_order_works() { }); } -/// An order signed with an ECDSA key is rejected at validation time even -/// though the signature itself is cryptographically valid. The order must not -/// appear in the Orders storage map after the batch runs. +/// An order signed with an ECDSA key is rejected at validation time even though +/// the signature itself is cryptographically valid: `is_order_valid` accepts +/// sr25519 and ed25519 but rejects ecdsa. The order must not appear in the +/// Orders storage map after the batch runs. #[test] fn execute_orders_ecdsa_signature_rejected() { new_test_ext().execute_with(|| { @@ -451,6 +453,86 @@ fn limit_buy_order_executes_and_stakes_alpha() { }); } +/// End-to-end: a LimitBuy order whose `signer` is an ed25519 account, signed with +/// the ``-wrapped order-hash payload (the Ledger / `signRaw` +/// envelope), executes against the pool, is marked Fulfilled, and credits staked +/// alpha to the ed25519 signer. Mirrors `limit_buy_order_executes_and_stakes_alpha` +/// but exercises the ed25519 + wrapped-signature acceptance path. +#[test] +fn execute_orders_ed25519_wrapped_signature_executes() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + // ed25519 signer account — the order's `signer` and the staking coldkey. + let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); + let ed_signer: AccountId = sp_core::ed25519::Public::from(ed_pair.public()).into(); + + setup_subnet(netuid); + + // Fund the ED25519 signer (not sr25519 Alice) so buy_alpha can debit it, + // and create its hotkey association through bob. + fund_account(&ed_signer); + let _ = SubtensorModule::create_account_if_non_existent(&ed_signer, &bob_id); + + // Build the order manually: make_signed_order hardcodes an sr25519 keyring + // signer, so it cannot express an ed25519 signer. Field values match the + // limit-buy test above. + let order = VersionedOrder::V1(Order { + signer: ed_signer.clone(), + hotkey: bob_id.clone(), + netuid, + order_type: OrderType::LimitBuy, + amount: min_default_stake().into(), + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: charlie_id.clone(), + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, + }); + let id = order_id(&order); + + // Sign the ``-wrapped 32-byte order hash with ed25519. + // `id` is blake2_256(order.encode()); `id.as_bytes()` are exactly those + // 32 hash bytes, matching the runtime's wrapped-verification payload. + let payload = [b"".as_slice(), id.as_bytes(), b"".as_slice()].concat(); + let ed_sig = ed_pair.sign(&payload); + let signed = SignedOrder { + order, + signature: MultiSignature::Ed25519(ed_sig), + partial_fill: None, + }; + + let orders = make_order_batch(vec![signed]); + + assert_ok!(LimitOrders::execute_orders( + RuntimeOrigin::signed(charlie_id), + orders, + false, + )); + + // Order must be marked as executed. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // The ed25519 signer must now hold staked alpha delegated through Bob. + // AMM pool output has slight slippage even with the stable mechanism; check within 1%. + let staked = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &bob_id, &ed_signer, netuid, + ); + let expected_alpha = min_default_stake().to_u64(); + assert!( + staked >= AlphaBalance::from(expected_alpha * 99 / 100) + && staked <= AlphaBalance::from(expected_alpha), + "ed25519 signer should hold approximately min_default_stake alpha after a wrapped-signed LimitBuy executes (got {staked:?})" + ); + }); +} + /// A Ledger-style, wrapped Sr25519 signature is accepted by the runtime and /// executes the signed order. #[test] @@ -2705,3 +2787,143 @@ fn fee_failure_after_buy_rolls_back_swap() { ); }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Human-readable ("clear-signing") signature path — runtime integration +// ───────────────────────────────────────────────────────────────────────────── + +/// Rebuild the pallet's canonical clear-signing message for an order. +/// +/// The pallet's `render_order` is `pub(crate)` and not reachable from this +/// integration crate, so we reconstruct the exact byte-for-byte message here +/// (SS58 prefix 42, single-line, `, `-separated fields). If this drifts from the +/// pallet's `render_order`, the signature over it will simply fail to verify — +/// which is exactly the invariant this test would then catch. +fn render_order_readable(order: &Order) -> Vec { + fn ss58(a: &AccountId) -> String { + a.to_ss58check_with_version(Ss58AddressFormat::custom(42)) + } + let (label, price_word) = match order.order_type { + OrderType::LimitBuy => ("Limit buy", "limit price"), + OrderType::TakeProfit => ("Take-profit", "trigger price"), + OrderType::StopLoss => ("Stop-loss", "trigger price"), + }; + let max_slippage = match order.max_slippage { + None => "none".to_string(), + Some(p) => p.deconstruct().to_string(), + }; + let relayer = match &order.relayer { + None => "none".to_string(), + Some(list) if list.is_empty() => "[]".to_string(), + Some(list) => list + .iter() + .map(ss58) + .collect::>() + .join("+"), + }; + let netuid: u16 = u16::from(order.netuid); + format!( + "TAO.com order v1: {label} {amount} on subnet {netuid}, \ +{price_word} {limit_price}, expiry {expiry}, hotkey {hotkey}, \ +fee {fee_rate} to {fee_recipient}, relayer {relayer}, \ +max slippage {max_slippage}, chain {chain_id}, \ +partial fills {partial}, signer {signer}", + amount = order.amount, + limit_price = order.limit_price, + expiry = order.expiry, + hotkey = ss58(&order.hotkey), + fee_rate = order.fee_rate.deconstruct(), + fee_recipient = ss58(&order.fee_recipient), + chain_id = order.chain_id, + partial = order.partial_fills_enabled, + signer = ss58(&order.signer), + ) + .into_bytes() +} + +/// The bytes a signer actually signs for the readable form: the ``-wrapped +/// message, blake2_256-hashed when it exceeds Ledger's raw-signing limit. +/// +/// A Ledger hashes any `signRaw` payload longer than `MAX_SIGN_SIZE` (256 bytes, +/// `app/src/coin.h` in the Zondax Polkadot app) before signing it, and the runtime +/// verifies against the same rule. The readable message is always oversized (three +/// SS58 addresses alone are 144 characters), so this is the hashed shape in +/// practice — the `else` arm exists only to mirror the runtime exactly. +fn readable_signed_bytes(order: &Order) -> Vec { + let msg = render_order_readable(order); + let payload = [b"".as_slice(), &msg, b"".as_slice()].concat(); + if payload.len() > pallet_limit_orders::LEDGER_MAX_SIGN_SIZE { + sp_core::hashing::blake2_256(&payload).to_vec() + } else { + payload + } +} + +/// End-to-end: a LimitBuy order signed with the human-readable ("clear-signing") +/// payload — `` ++ render_order ++ `` — executes through +/// `execute_batched_orders`, is marked Fulfilled, and credits staked alpha to the +/// signer. Exercises the `verify_readable` acceptance branch against the real +/// runtime (chain_id 0). +#[test] +fn execute_batched_orders_readable_signature_executes() { + new_test_ext().execute_with(|| { + let netuid = NetUid::from(1u16); + let alice = Sr25519Keyring::Alice; + let alice_id = alice.to_account_id(); + let bob_id = Sr25519Keyring::Bob.to_account_id(); + let charlie_id = Sr25519Keyring::Charlie.to_account_id(); + + setup_subnet(netuid); + fund_account(&alice_id); + let _ = SubtensorModule::create_account_if_non_existent(&alice_id, &bob_id); + + // Build the order manually and sign the readable clear-signing message. + let inner = Order { + signer: alice_id.clone(), + hotkey: bob_id.clone(), + netuid, + order_type: OrderType::LimitBuy, + amount: min_default_stake().into(), + limit_price: u64::MAX, + expiry: u64::MAX, + fee_rate: Perbill::zero(), + fee_recipient: charlie_id.clone(), + relayer: None, + max_slippage: None, + partial_fills_enabled: false, + // chain_id 0 matches the default pallet_evm_chain_id genesis value in tests + chain_id: 0, + }; + let order = VersionedOrder::V1(inner.clone()); + let id = order_id(&order); + + let sig = alice.pair().sign(&readable_signed_bytes(&inner)); + let signed = SignedOrder { + order, + signature: MultiSignature::Sr25519(sig), + partial_fill: None, + }; + + let orders = make_order_batch(vec![signed]); + + assert_ok!(LimitOrders::execute_batched_orders( + RuntimeOrigin::signed(charlie_id), + netuid, + orders, + )); + + // Order must be marked as executed. + assert_eq!(Orders::::get(id), Some(OrderStatus::Fulfilled)); + + // Alice must now hold staked alpha delegated through Bob (within 1% for + // AMM slippage), proving the readable-signed order was accepted and ran. + let staked = + SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&bob_id, &alice_id, netuid); + let expected_alpha = min_default_stake().to_u64(); + assert!( + staked >= AlphaBalance::from(expected_alpha * 99 / 100) + && staked <= AlphaBalance::from(expected_alpha), + "alice should hold approximately min_default_stake alpha after a readable-signed LimitBuy executes (got {staked:?})" + ); + }); +} diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-readable.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-readable.ts new file mode 100644 index 0000000000..7bdfa7b82b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-readable.ts @@ -0,0 +1,164 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { ApiPromise } from "@polkadot/api"; +import type { KeyringPair } from "@moonwall/util"; +import { tao, generateKeyringPair } from "../../../../utils"; +import { + devForceSetBalance, + devGetAlphaStake, + devAssociateHotKey, + devEnableSubtoken, + devRegisterSubnet, + devSudoSetLockReductionInterval, +} from "../../../../utils/dev-helpers.js"; +import { + buildReadableSignedOrder, + FAR_FUTURE, + fetchChainId, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// One subnet per file — this test submits real buy orders signed over the +// ``-wrapped canonical human-readable ("clear-signing") message, the +// form a hardware wallet (Ledger) displays field-by-field. It exercises the +// runtime's `verify_readable` path: +// signature.verify(b"" ++ utf8(render_order(order)) ++ b"", signer) +// for BOTH an ed25519 signer (the hardware/Ledger case) and an sr25519 signer. +// Both orders are relayed/submitted by Alice via execute_batched_orders. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_READABLE", + title: "execute_batched_orders — human-readable (clear-signing) LimitBuy execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let edSigner: KeyringPair; + let edHotKey: KeyringPair; + let srSigner: KeyringPair; + let srHotKey: KeyringPair; + let netuid: number; + let chainId: bigint; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + // ed25519 coldkey/signer (hardware / Ledger case) with an sr25519 hotkey. + edSigner = generateKeyringPair("ed25519"); + edHotKey = generateKeyringPair("sr25519"); + + // sr25519 coldkey/signer with its own sr25519 hotkey. + srSigner = generateKeyringPair("sr25519"); + srHotKey = generateKeyringPair("sr25519"); + + registerLimitOrderTypes(polkadotJs); + chainId = await fetchChainId(polkadotJs); + + await devForceSetBalance(polkadotJs, context, alice.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, edSigner.address, tao(10_000)); + await devForceSetBalance(polkadotJs, context, srSigner.address, tao(10_000)); + + await devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + await devEnableSubtoken(polkadotJs, context, alice, netuid); + + // Associate hotkeys — each signer associates its own hotkey. + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, edSigner, edHotKey.address); + await devAssociateHotKey(polkadotJs, context, srSigner, srHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy executes with an ed25519 readable (clear-signing) signature", + test: async () => { + const stakeBefore = await devGetAlphaStake(polkadotJs, edHotKey.address, edSigner.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(edSigner.address)).data.free.toBigInt(); + + const signed = buildReadableSignedOrder(polkadotJs, { + signer: edSigner, + hotkey: edHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: edSigner.address, + chainId, + }); + + // Alice relays/submits the ed25519 readable-signed order. + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [signed]).signAsync(alice), + ]); + expect(attempt.successful).toEqual(true); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + // Alpha stake for the ed25519 signer's hotkey should have increased. + const stakeAfter = await devGetAlphaStake(polkadotJs, edHotKey.address, edSigner.address, netuid); + expect(stakeAfter).toBeGreaterThan(stakeBefore); + + // ed25519 signer's TAO balance should have decreased. + const taoBalanceAfter = (await polkadotJs.query.system.account(edSigner.address)).data.free.toBigInt(); + expect(taoBalanceAfter).toBeLessThan(taoBalanceBefore); + }, + }); + + it({ + id: "T02", + title: "LimitBuy executes with an sr25519 readable (clear-signing) signature", + test: async () => { + const stakeBefore = await devGetAlphaStake(polkadotJs, srHotKey.address, srSigner.address, netuid); + const taoBalanceBefore = (await polkadotJs.query.system.account(srSigner.address)).data.free.toBigInt(); + + const signed = buildReadableSignedOrder(polkadotJs, { + signer: srSigner, + hotkey: srHotKey.address, + netuid, + orderType: "LimitBuy", + amount: tao(100), + limitPrice: FAR_FUTURE, + expiry: FAR_FUTURE, + feeRate: 0, + feeRecipient: srSigner.address, + chainId, + }); + + const { + result: [attempt], + } = await context.createBlock([ + await polkadotJs.tx.limitOrders.executeBatchedOrders(netuid, [signed]).signAsync(alice), + ]); + expect(attempt.successful).toEqual(true); + + const events = await polkadotJs.query.system.events(); + expect(filterEvents(events, "OrderExecuted").length).toBe(1); + + const id = orderId(polkadotJs, signed.order); + expect(await getOrderStatus(polkadotJs, id)).toBe("Fulfilled"); + + const stakeAfter = await devGetAlphaStake(polkadotJs, srHotKey.address, srSigner.address, netuid); + expect(stakeAfter).toBeGreaterThan(stakeBefore); + + const taoBalanceAfter = (await polkadotJs.query.system.account(srSigner.address)).data.free.toBigInt(); + expect(taoBalanceAfter).toBeLessThan(taoBalanceBefore); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-ledger-raw-sign-vector.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-ledger-raw-sign-vector.ts new file mode 100644 index 0000000000..a4fee1ecb2 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-ledger-raw-sign-vector.ts @@ -0,0 +1,279 @@ +import { describeSuite, expect } from "@moonwall/cli"; +import { Keyring } from "@polkadot/keyring"; +import { hexToU8a, stringToU8a, u8aToHex, u8aWrapBytes } from "@polkadot/util"; +import { blake2AsU8a, ed25519PairFromSeed, ed25519Verify } from "@polkadot/util-crypto"; +import { + type Order, + LEDGER_MAX_SIGN_SIZE, + buildReadableSignedOrder, + formatOrderMessage, +} from "../../../../utils/limit-orders.js"; + +// Hardware test vector for the human-readable ("clear-signing") signing form. +// +// A Ledger blake2_256-hashes a raw-signing (`signRaw`) payload longer than +// MAX_SIGN_SIZE = 256 bytes before signing it (`crypto_sign_ed25519` in +// `app/src/crypto.c` of the Zondax Polkadot app). The readable message is always +// over that limit, so a real device signature commits to +// `blake2_256( ++ message ++ )` — never to the payload bytes. This +// is NOT the symmetric rule in Substrate's `SignedPayload`/`GenericExtrinsicPayload` +// (that pair only governs *extrinsic* signing payloads, where signer and verifier +// both apply it). On the raw-message path only the device hashes: polkadot-js's +// `pair.sign()` applies a length rule for ecdsa only. Hence any verifier of an +// oversized Ledger-signed order must hash first, and the utils' readable signer +// mirrors that. +// +// Captured 2026-07-28 from a Nano S+ running Polkadot Generic v100.0.25, derivation +// path m/44'/354'/0'/0'/0'. The device rendered the whole order text across its +// screens and still signed the digest, so digest signing is NOT a blind-signing +// fallback — clear-signing works, and a shorter message would only cut page count. +// The probe matrix that ruled out the alternatives (unwrapped message, blake2_512, +// digest-as-hex) is re-run in T05. +// +// The vector is pinned as literal data on purpose: a vector must outlive the +// harness that produced it. The Rust half lives in +// `pallets/limit-orders/src/tests/ledger_vector.rs` and pins the same bytes against +// the pallet's own `render_order`. +// +// NOTE: the capture's device key is NOT the account named in the message's `signer` +// field, and the runtime verifies against `order.signer` — so this suite pins the +// rule and the renderer, not order execution. Order acceptance is covered by +// `test-execute-orders-readable.ts`. An executable vector needs a fresh capture +// whose message renders `signer` as the device's own address, with `chain_id` +// matching this environment and `expiry` in milliseconds (this one's 1793000000 is +// a seconds-scale value, i.e. long expired). + +/** The exact text the device displayed and signed, SS58 prefix 42. */ +const ORDER_MESSAGE = + "TAO.com order v1: Limit buy 1000000000 on subnet 64, limit price 500000000, " + + "expiry 1793000000, hotkey 5HK5tp6t2S59DywmHRWPBVJeJ86T61KjurYqeooqj8sREpeN, " + + "fee 8500000 to 5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY, " + + "relayer 5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty, max slippage 7500000, " + + "chain 1, partial fills true, signer 5CD9UfFv3FLd9BRP8tK7BumpEYvu2y3KZMuhUnDAhuzPbdtC"; + +const ORDER_MESSAGE_BYTE_LENGTH = 381; + +/** ``-wrapped byte length — what actually reaches the device. */ +const WRAPPED_BYTE_LENGTH = 396; + +/** Exact bytes sent to the device's raw-sign instruction. */ +// prettier-ignore +const WRAPPED_PAYLOAD_HEX = + "0x3c42797465733e54414f2e636f6d206f726465722076313a204c696d6974206275792031303030303030303030206f6e207375626e65742036342c206c696d6974207072696365203530303030303030302c2065787069727920313739333030303030302c20686f746b65792035484b3574703674325335394479776d4852575042564a654a38365436314b6a75725971656f6f716a3873524570654e2c20666565203835303030303020746f2035474e4a715450794e71414e426b55564d4e314c50507278586e466f7557586f6532774e536d6d456f4c637478695a592c2072656c61796572203546486e655734367847586773356d5569766555347362547947427a6d73745573705a43393255686a4a4d36393474792c206d617820736c69707061676520373530303030302c20636861696e20312c207061727469616c2066696c6c7320747275652c207369676e657220354344395566467633464c643942525038744b3742756d70455976753279334b5a4d7568556e444168757a50626474433c2f42797465733e"; + +/** blake2_256 of the wrapped payload — the 32 bytes the device signs. */ +const WRAPPED_PAYLOAD_BLAKE2_256 = "0x3c3ea88b51457189388906eecb582d5ebf481b1af5b66b6b5771e4e84b6e5ed7"; + +/** + * Software half of the vector: reproducible in CI, no device needed. Holds a + * signature over EACH form so both semantics have a fixture. + */ +const SOFTWARE_VECTOR = { + seedHex: "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + publicKeyHex: "0x79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + /** ed25519(wrapped payload) — the shape a software `signRaw` emits. */ + signatureOverBlobHex: + "0xc8d12ffcdc504a956b97fd67009de28c6541bf79dc33903092d9f1c279718c97" + + "91cf5bc69a3889c6699a5aab18170cdc2366f81daea5ecd31c64108385b1b60d", + /** ed25519(blake2_256(wrapped payload)) — the shape the device emits. */ + signatureOverHashHex: + "0x81edd47a3e02b7f52cc6a7db02e9a8c023c1f101526a7d5fe4be118aff36090c" + + "cf65f75136b31f6f64d8bcecc4e441b32322c37b4af51436a1e99096202b8608", +} as const; + +/** Signature captured from real hardware. */ +const DEVICE_VECTOR = { + label: "Nano S+ · Polkadot Generic v100.0.25", + derivationPath: "m/44'/354'/0'/0'/0'", + publicKeyHex: "0x76e2815d89ea8f87a7fc62c21b3ee2fb81d78ca28a24d33a974f47b20bb70a63", + signatureHex: + "0x91a37e50d01eeb407d9d1902374fef24dc287c1edb81474dbe19e46157bcc23d" + + "c5b7ba723ae7f8dd192004c150a8d0479a0ccb522c930dc8fcda7a15d6b45b08", + /** What it signed over — recorded, not assumed, so a future app version is a new entry. */ + signedOver: "blake2_256", +} as const; + +/** The order whose canonical rendering is `ORDER_MESSAGE`. */ +const VECTOR_ORDER: Order = { + signer: "5CD9UfFv3FLd9BRP8tK7BumpEYvu2y3KZMuhUnDAhuzPbdtC", + hotkey: "5HK5tp6t2S59DywmHRWPBVJeJ86T61KjurYqeooqj8sREpeN", + netuid: 64, + order_type: "LimitBuy", + amount: 1_000_000_000n, + limit_price: 500_000_000n, + expiry: 1_793_000_000n, + fee_rate: 8_500_000, + fee_recipient: "5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY", + relayer: ["5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"], + max_slippage: 7_500_000, + chain_id: 1n, + partial_fills_enabled: true, +}; + +// ── Executable vector ──────────────────────────────────────────────────────── +// +// The hardware capture cannot be submitted as an order: its message names +// 5CD9UfFv… as the signer while the device holds 5Ekanz…, and the runtime verifies +// against `order.signer`. This vector closes that gap without a device — it is +// minted from SOFTWARE_VECTOR.seedHex (which we hold) over the digest, and since +// ed25519 is deterministic and the device's transformation is fixed, these are +// exactly the bytes a Ledger holding that seed would return. It does NOT attest to +// device behaviour; the capture above does that. +// +// chain 945 matches the pallet mock, because the same constants back the Rust half +// in `pallets/limit-orders/src/tests/ledger_vector.rs`. Nothing here touches chain +// state: this asserts that our production signing helper emits the device shape. + +/** SS58 of the account SOFTWARE_VECTOR.seedHex controls — the `signer` below. */ +const SOFTWARE_ADDRESS = "5EpHX5foDtnhZngj4GsKq5eKGpUvuMqbpUG48ZfCCCs7EzKR"; + +/** Well-known dev accounts, prefix 42: Bob (hotkey) and Charlie (fee recipient). */ +const BOB_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; +const CHARLIE_SS58 = "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y"; + +const EXECUTABLE_MESSAGE = + "TAO.com order v1: Limit buy 1000 on subnet 1, limit price 1000000000, " + + `expiry 18446744073709551615, hotkey ${BOB_SS58}, fee 0 to ${CHARLIE_SS58}, ` + + `relayer none, max slippage none, chain 945, partial fills false, signer ${SOFTWARE_ADDRESS}`; + +/** blake2_256 of the wrapped EXECUTABLE_MESSAGE (350 bytes wrapped, so hashed). */ +const EXECUTABLE_DIGEST = "0xcd8f76e889c586d5efb73dd03433dc164b75fd727c52aaa4c8d07eb13dc98c12"; + +/** ed25519(seed, EXECUTABLE_DIGEST) — accepted by the runtime's `verify_readable`. */ +const EXECUTABLE_SIGNATURE = + "0xca9e4c33695072ffef1e3e1d0715979e3a4d8b553ee1ecc29e5da9ead5788910" + + "4d4bc7760c4f9a867e82b046271e47b554609489d666168b549d75b39b559b04"; + +// `new Uint8Array(...)` is load-bearing: `@polkadot/util`'s `isU8a` tests +// `constructor === Uint8Array` by identity, so an array from another realm makes +// `u8aWrapBytes` stringify its input instead of wrapping it — silently producing the +// wrong bytes. Re-wrapping puts it in this realm. +const bytes = (text: string) => new Uint8Array(stringToU8a(text)); +const wrapped = () => u8aWrapBytes(bytes(ORDER_MESSAGE)); +const digest = () => blake2AsU8a(wrapped(), 256); + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_LEDGER_VECTOR", + title: "limit-orders — Ledger raw-sign vector for the oversized clear-signing payload", + foundationMethods: "dev", + testCases: ({ it }) => { + it({ + id: "T01", + title: "the TS formatter reproduces the message the device displayed and signed", + test: () => { + const msg = formatOrderMessage(VECTOR_ORDER); + expect(msg).toBe(ORDER_MESSAGE); + expect(bytes(msg)).toHaveLength(ORDER_MESSAGE_BYTE_LENGTH); + for (let i = 0; i < msg.length; i++) { + const code = msg.charCodeAt(i); + expect( + code >= 0x20 && code <= 0x7e, + `char ${i} = 0x${code.toString(16)} is not printable ASCII, so the device would render hex` + ).toBe(true); + } + }, + }); + + it({ + id: "T02", + title: "the wrapped payload is over the threshold that switches the device to digest signing", + test: () => { + expect(wrapped()).toHaveLength(WRAPPED_BYTE_LENGTH); + expect(WRAPPED_BYTE_LENGTH).toBeGreaterThan(LEDGER_MAX_SIGN_SIZE); + expect(u8aToHex(wrapped())).toBe(WRAPPED_PAYLOAD_HEX); + // The utils wrap with u8aWrapBytes; the runtime concatenates literal + // tags. For printable ASCII both must produce identical bytes, or the + // device would render one thing and the chain verify another. + expect(u8aToHex(wrapped())).toBe(u8aToHex(bytes(`${ORDER_MESSAGE}`))); + }, + }); + + it({ + id: "T03", + title: "the wrapped payload hashes to the pinned digest", + test: () => { + expect(u8aToHex(digest())).toBe(WRAPPED_PAYLOAD_BLAKE2_256); + }, + }); + + it({ + id: "T04", + title: "the two signing forms are mutually unverifiable", + test: () => { + const publicKey = ed25519PairFromSeed(hexToU8a(SOFTWARE_VECTOR.seedHex)).publicKey; + expect(u8aToHex(publicKey)).toBe(SOFTWARE_VECTOR.publicKeyHex); + + const overBlob = hexToU8a(SOFTWARE_VECTOR.signatureOverBlobHex); + const overHash = hexToU8a(SOFTWARE_VECTOR.signatureOverHashHex); + + expect(ed25519Verify(wrapped(), overBlob, publicKey)).toBe(true); + expect(ed25519Verify(digest(), overHash, publicKey)).toBe(true); + // The whole hazard in two assertions: signer and verifier disagreeing + // about which form is in play is a hard rejection, never a soft fallback. + expect(ed25519Verify(digest(), overBlob, publicKey)).toBe(false); + expect(ed25519Verify(wrapped(), overHash, publicKey)).toBe(false); + }, + }); + + it({ + id: "T05", + title: `${DEVICE_VECTOR.label} signed over ${DEVICE_VECTOR.signedOver} and nothing else`, + test: () => { + const publicKey = hexToU8a(DEVICE_VECTOR.publicKeyHex); + const signature = hexToU8a(DEVICE_VECTOR.signatureHex); + + expect(ed25519Verify(digest(), signature, publicKey)).toBe(true); + + // Every alternative the capture's probe matrix ruled out. + const rejected: [string, Uint8Array][] = [ + ["the raw wrapped payload", wrapped()], + ["the unwrapped message", bytes(ORDER_MESSAGE)], + ["blake2_256 of the unwrapped message", blake2AsU8a(bytes(ORDER_MESSAGE), 256)], + ["blake2_512 of the wrapped payload", blake2AsU8a(wrapped(), 512)], + ["the ASCII hex of the digest", bytes(u8aToHex(digest()).slice(2))], + ]; + for (const [form, message] of rejected) { + expect(ed25519Verify(message, signature, publicKey), `must not verify over ${form}`).toBe( + false + ); + } + }, + }); + + it({ + id: "T06", + title: "buildReadableSignedOrder emits the device shape for the executable vector", + test: () => { + const signer = new Keyring({ type: "ed25519" }).addFromSeed( + hexToU8a(SOFTWARE_VECTOR.seedHex) + ); + expect(signer.address).toBe(SOFTWARE_ADDRESS); + + // `api` is unused by the readable builder (the payload is rendered from + // the params, not from chain metadata), so no chain state is needed. + const signed = buildReadableSignedOrder(null, { + signer, + hotkey: BOB_SS58, + netuid: 1, + orderType: "LimitBuy", + amount: 1_000n, + limitPrice: 1_000_000_000n, + expiry: 18_446_744_073_709_551_615n, + feeRate: 0, + feeRecipient: CHARLIE_SS58, + chainId: 945n, + }); + + // The message the order renders to, the digest it hashes to, and the + // signature the helper produced must all match the frozen vector — + // i.e. our signing path is byte-for-byte the one a Ledger takes. + expect(formatOrderMessage(signed.order.V1)).toBe(EXECUTABLE_MESSAGE); + const payload = u8aWrapBytes(bytes(EXECUTABLE_MESSAGE)); + expect(payload.length).toBeGreaterThan(LEDGER_MAX_SIGN_SIZE); + expect(u8aToHex(blake2AsU8a(payload, 256))).toBe(EXECUTABLE_DIGEST); + expect("Ed25519" in signed.signature).toBe(true); + expect((signed.signature as { Ed25519: string }).Ed25519).toBe(EXECUTABLE_SIGNATURE); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-readable-message-format.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-readable-message-format.ts new file mode 100644 index 0000000000..2abcc0a31b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-readable-message-format.ts @@ -0,0 +1,193 @@ +import { describeSuite, expect } from "@moonwall/cli"; +import { Keyring } from "@polkadot/keyring"; +import { encodeAddress } from "@polkadot/util-crypto"; +import { + type Order, + type OrderType, + formatOrderMessage, + READABLE_SS58_PREFIX, +} from "../../../../utils/limit-orders.js"; + +// Byte-parity anchor for the canonical human-readable ("clear-signing") message. +// +// The runtime rebuilds this exact string in `render_order` and verifies the +// signature over `` ++ utf8(message) ++ ``. If the TS formatter +// drifts from the Rust one by a single byte, every readable-signed order breaks. +// These assertions pin the TS output against FULLY HARDCODED literals derived +// with a well-known dev key (sr25519 `//Alice` at prefix 42 is the canonical +// `5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY`), so the expected string is +// NOT derived from the same `encodeAddress` call it is testing. +// +// The field VALUES mirror the Rust golden vectors in +// `pallets/limit-orders/src/tests/readable.rs` so both suites pin identical +// output. + +// Known dev keys, sr25519, rendered at prefix 42. +const KR = new Keyring({ type: "sr25519" }); +const ALICE = KR.addFromUri("//Alice").address; // 5Grwva... +const BOB = KR.addFromUri("//Bob").address; // 5FHneW... +const CHARLIE = KR.addFromUri("//Charlie").address; // 5FLSig... +const DAVE = KR.addFromUri("//Dave").address; // 5DAAnr... + +// Hardcoded SS58 (prefix 42) of the dev keys — independent of the formatter. +const ALICE_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; +const BOB_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; +const CHARLIE_SS58 = "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y"; +const DAVE_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy"; + +function makeOrder(overrides: Partial): Order { + return { + signer: ALICE, + hotkey: BOB, + netuid: 7, + order_type: "LimitBuy" as OrderType, + amount: 1_234_567n, + limit_price: 2_000_000_000n, + expiry: 9_999_999n, + fee_rate: 5_000_000, + fee_recipient: DAVE, + relayer: null, + max_slippage: null, + chain_id: 945n, + partial_fills_enabled: false, + ...overrides, + }; +} + +function assertAllPrintableAscii(s: string): void { + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + expect( + code >= 0x20 && code <= 0x7e, + `char ${i} = 0x${code.toString(16)} (${JSON.stringify(s[i])}) is not printable ASCII` + ).toBe(true); + } +} + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_READABLE_FORMAT", + title: "limit-orders — canonical human-readable message formatter parity", + foundationMethods: "dev", + testCases: ({ it }) => { + it({ + id: "T01", + title: "the well-known //Alice SS58 anchor is prefix 42", + test: () => { + // Sanity: the dev keyring already renders at prefix 42, and an + // explicit re-encode is idempotent — so both routes must equal + // the hardcoded literal. + expect(ALICE).toBe(ALICE_SS58); + expect(encodeAddress(ALICE, READABLE_SS58_PREFIX)).toBe(ALICE_SS58); + }, + }); + + it({ + id: "T02", + title: "LimitBuy with relayer none renders the exact golden string", + test: () => { + const msg = formatOrderMessage(makeOrder({})); + const expected = + "TAO.com order v1: Limit buy 1234567 on subnet 7, " + + "limit price 2000000000, expiry 9999999, " + + `hotkey ${BOB_SS58}, ` + + `fee 5000000 to ${DAVE_SS58}, ` + + "relayer none, max slippage none, chain 945, " + + `partial fills false, signer ${ALICE_SS58}`; + expect(msg).toBe(expected); + assertAllPrintableAscii(msg); + }, + }); + + it({ + id: "T03", + title: "StopLoss with max_slippage renders Stop-loss / trigger price", + test: () => { + const msg = formatOrderMessage( + makeOrder({ + signer: CHARLIE, + hotkey: DAVE, + netuid: 2, + order_type: "StopLoss", + amount: 500n, + limit_price: 750_000_000n, + expiry: 42n, + fee_rate: 0, + fee_recipient: ALICE, + relayer: null, + max_slippage: 10_000_000, // 1% in ppb + partial_fills_enabled: true, + }) + ); + const expected = + "TAO.com order v1: Stop-loss 500 on subnet 2, " + + "trigger price 750000000, expiry 42, " + + `hotkey ${DAVE_SS58}, ` + + `fee 0 to ${ALICE_SS58}, ` + + "relayer none, max slippage 10000000, chain 945, " + + `partial fills true, signer ${CHARLIE_SS58}`; + expect(msg).toBe(expected); + assertAllPrintableAscii(msg); + }, + }); + + it({ + id: "T04", + title: "TakeProfit with two relayers renders '+'-joined list", + test: () => { + const msg = formatOrderMessage( + makeOrder({ + signer: ALICE, + hotkey: DAVE, + netuid: 1, + order_type: "TakeProfit", + amount: 88n, + limit_price: 1_000_000_000n, + expiry: 100_000n, + fee_rate: 1, + fee_recipient: DAVE, + relayer: [BOB, CHARLIE], + max_slippage: null, + partial_fills_enabled: false, + }) + ); + const expected = + "TAO.com order v1: Take-profit 88 on subnet 1, " + + "trigger price 1000000000, expiry 100000, " + + `hotkey ${DAVE_SS58}, ` + + `fee 1 to ${DAVE_SS58}, ` + + `relayer ${BOB_SS58}+${CHARLIE_SS58}, ` + + "max slippage none, chain 945, " + + `partial fills false, signer ${ALICE_SS58}`; + expect(msg).toBe(expected); + assertAllPrintableAscii(msg); + }, + }); + + it({ + id: "T05", + title: "empty relayer array renders '[]' (distinct from none)", + test: () => { + const msg = formatOrderMessage( + makeOrder({ + order_type: "LimitBuy", + amount: 1_000n, + limit_price: 18_446_744_073_709_551_615n, // u64::MAX + expiry: 18_446_744_073_709_551_615n, // u64::MAX + fee_rate: 0, + relayer: [], + max_slippage: null, + }) + ); + const expected = + "TAO.com order v1: Limit buy 1000 on subnet 7, " + + "limit price 18446744073709551615, expiry 18446744073709551615, " + + `hotkey ${BOB_SS58}, ` + + `fee 0 to ${DAVE_SS58}, ` + + "relayer [], max slippage none, chain 945, " + + `partial fills false, signer ${ALICE_SS58}`; + expect(msg).toBe(expected); + assertAllPrintableAscii(msg); + }, + }); + }, +}); diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index e9ba6816c0..b510920cc2 100644 --- a/ts-tests/utils/limit-orders.ts +++ b/ts-tests/utils/limit-orders.ts @@ -2,8 +2,8 @@ import type { KeyringPair } from "@moonwall/util"; import type { TypedApi } from "polkadot-api"; import type { subtensor } from "@polkadot-api/descriptors"; import { Keyring } from "@polkadot/keyring"; -import { u8aToHex, u8aWrapBytes } from "@polkadot/util"; -import { blake2AsHex, blake2AsU8a } from "@polkadot/util-crypto"; +import { stringToU8a, u8aToHex, u8aWrapBytes } from "@polkadot/util"; +import { blake2AsHex, blake2AsU8a, decodeAddress, encodeAddress } from "@polkadot/util-crypto"; import { waitForTransactionWithRetry } from "./transactions.js"; import { MultiAddress } from "@polkadot-api/descriptors"; @@ -145,6 +145,126 @@ export function buildWrappedSignedOrder(api: any, params: OrderParams): SignedOr }; } +// ── Human-readable ("clear-signing" / Ledger) message ────────────────────────── + +/** + * SS58 prefix under which all account fields are rendered in the canonical + * human-readable message. MUST match the pallet's `SS58_PREFIX` constant (42). + */ +export const READABLE_SS58_PREFIX = 42; + +/** + * Ledger's raw-signing size limit — `MAX_SIGN_SIZE` in the Zondax Polkadot app. + * MUST match the pallet's `LEDGER_MAX_SIGN_SIZE`. + * + * A `signRaw` payload longer than this is blake2_256-hashed on-device before the + * signature is produced, so for an oversized payload the signature commits to the + * hash rather than to the payload bytes, and the runtime verifies it that way. + * The device still displays the full message — the hashing happens in the signing + * step only. + */ +export const LEDGER_MAX_SIGN_SIZE = 256; + +/** + * Re-encode an account address as SS58 at prefix 42. Accepts any input the + * `@polkadot/util-crypto` `decodeAddress` understands (SS58 of any prefix, hex, + * or raw bytes) and always re-encodes so the output prefix is deterministic — + * matching the runtime's `render_account`, which always renders at prefix 42. + */ +function renderAccount(addr: string): string { + return encodeAddress(decodeAddress(addr), READABLE_SS58_PREFIX); +} + +/** + * Format the canonical human-readable ("clear-signing") message for an order. + * + * This is a PURE function of the order's V1 fields and MUST match the runtime's + * `Pallet::render_order` byte-for-byte — the runtime rebuilds this exact string + * and verifies the signature over `` ++ utf8(message) ++ ``. Any + * drift here silently breaks signature verification. + * + * Canonical form (single line, `, ` between fields): + * + * TAO.com order v1: {LABEL} {amount} on subnet {netuid}, {PRICE_WORD} {limit_price}, + * expiry {expiry}, hotkey {hotkey}, fee {fee_rate} to {fee_recipient}, + * relayer {relayer}, max slippage {max_slippage}, chain {chain_id}, + * partial fills {partial}, signer {signer} + */ +export function formatOrderMessage(order: Order): string { + const label = + order.order_type === "LimitBuy" ? "Limit buy" : order.order_type === "TakeProfit" ? "Take-profit" : "Stop-loss"; + + const priceWord = order.order_type === "LimitBuy" ? "limit price" : "trigger price"; + + const maxSlippage = order.max_slippage === null ? "none" : order.max_slippage.toString(); + + let relayer: string; + if (order.relayer === null) { + relayer = "none"; + } else if (order.relayer.length === 0) { + relayer = "[]"; + } else { + relayer = order.relayer.map(renderAccount).join("+"); + } + + return ( + `TAO.com order v1: ${label} ${order.amount.toString()} on subnet ${order.netuid.toString()}, ` + + `${priceWord} ${order.limit_price.toString()}, expiry ${order.expiry.toString()}, ` + + `hotkey ${renderAccount(order.hotkey)}, ` + + `fee ${order.fee_rate.toString()} to ${renderAccount(order.fee_recipient)}, ` + + `relayer ${relayer}, ` + + `max slippage ${maxSlippage}, chain ${order.chain_id.toString()}, ` + + `partial fills ${order.partial_fills_enabled ? "true" : "false"}, ` + + `signer ${renderAccount(order.signer)}` + ); +} + +/** + * Build a SignedOrder whose signature is over the ``-wrapped canonical + * human-readable message (the "clear-signing" / Ledger form that a hardware + * wallet can display field-by-field). This exercises the runtime's + * `verify_readable` path: + * + * signature.verify(b"" ++ utf8(render_order(order)) ++ b"", signer) + * + * IMPORTANT: the message is converted to BYTES with `stringToU8a` and then + * wrapped with `u8aWrapBytes`, so the signed payload is exactly + * `` ++ utf8(message) ++ `` — matching the runtime's + * `[b"", &render_order, b""].concat()`. Wrapping the raw string + * instead of the bytes would corrupt the payload. + * + * The bytes actually signed then follow the device's rule: a payload longer than + * `LEDGER_MAX_SIGN_SIZE` is blake2_256-hashed first, because that is what a Ledger + * signs and therefore what the runtime verifies. The readable message is always + * oversized (three SS58 addresses alone are 144 characters), so this emulates a + * hardware signer. Note that `signRaw` in a *software* wallet (polkadot.js + * extension) does NOT hash — such a signature is not valid on this path. + * + * The signature scheme tag (`Sr25519` vs `Ed25519`) follows the signer's + * keypair type, so the same helper works for both schemes. + */ +export function buildReadableSignedOrder(api: any, params: OrderParams): SignedOrder { + const versionedOrder = buildVersionedOrder(params); + + // Render the canonical message, convert to UTF-8 bytes, then wrap. + const message = formatOrderMessage(versionedOrder.V1); + const wrapped = u8aWrapBytes(stringToU8a(message)); + const signedBytes = wrapped.length > LEDGER_MAX_SIGN_SIZE ? blake2AsU8a(wrapped, 256) : wrapped; + const sig = params.signer.sign(signedBytes); + + // Tag the signature variant from the keypair type. + const signature = + params.signer.type === "ed25519" + ? { Ed25519: u8aToHex(sig) as `0x${string}` } + : { Sr25519: u8aToHex(sig) as `0x${string}` }; + + return { + order: versionedOrder, + signature, + partial_fill: null, + }; +} + /** * Compute the on-chain OrderId (blake2_256 of SCALE-encoded VersionedOrder). * Mirrors `Pallet::derive_order_id` in Rust.