diff --git a/pallets/limit-orders/src/benchmarking.rs b/pallets/limit-orders/src/benchmarking.rs index 79bc60f516..2aa6e1ee91 100644 --- a/pallets/limit-orders/src/benchmarking.rs +++ b/pallets/limit-orders/src/benchmarking.rs @@ -21,12 +21,12 @@ fn sign_order( public: sp_core::sr25519::Public, order: &crate::VersionedOrder, ) -> crate::SignedOrder { - let sig = sp_io::crypto::sr25519_sign( - sp_core::crypto::key_types::ACCOUNT, - &public, - &order.encode(), - ) - .unwrap(); + // Mirror the on-chain check in `is_order_valid`: the signed message is the + // ``-wrapped blake2_256 hash of the SCALE-encoded order. + 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(); 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 1d5548c529..2216794887 100644 --- a/pallets/limit-orders/src/lib.rs +++ b/pallets/limit-orders/src/lib.rs @@ -131,18 +131,22 @@ impl VersionedOrd } /// The envelope the admin submits on-chain: the versioned order payload plus -/// the user's signature over the SCALE-encoded `VersionedOrder`. +/// the user's signature over the order. /// /// Signature verification is performed against `order.inner().signer` (the AccountId) -/// directly. Only sr25519 signatures are accepted; ed25519 and ecdsa variants -/// of `MultiSignature` are 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 signature over `SCALE_ENCODE(VersionedOrder)`. + /// 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, @@ -596,6 +600,43 @@ 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) + } + /// 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,11 +654,16 @@ 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, + // which is the two-verification worst case the weights must account for. ensure!( - matches!(signed_order.signature, MultiSignature::Sr25519(_)) - && signed_order - .signature - .verify(signed_order.order.encode().as_slice(), &order.signer), + Self::verify_order(signed_order) || Self::verify_wrapped(signed_order, order_id), 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 1049c84f74..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), @@ -1430,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), @@ -1462,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!( @@ -1474,14 +1480,70 @@ fn is_order_valid_invalid_signature_returns_error() { } #[test] -fn is_order_valid_non_sr25519_signature_returns_error() { +fn is_order_valid_accepts_ed25519_signature() { new_test_ext().execute_with(|| { MockTime::set(1_000_000); MockSwap::set_price(1.0); - let (mut signed, id) = 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_sig = ed_pair.sign(&signed.order.encode()); - signed.signature = MultiSignature::Ed25519(ed_sig); + 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())); + let ed_sig = ed_pair.sign(&order_signing_payload(&order)); + 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 is_order_valid_rejects_ecdsa_signature() { + new_test_ext().execute_with(|| { + MockTime::set(1_000_000); + MockSwap::set_price(1.0); + + // 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: 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()), @@ -1518,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), @@ -1555,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), @@ -1581,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), @@ -1595,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/mock.rs b/pallets/limit-orders/src/tests/mock.rs index 2834c54afe..bfc4c4714a 100644 --- a/pallets/limit-orders/src/tests/mock.rs +++ b/pallets/limit-orders/src/tests/mock.rs @@ -559,6 +559,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 +598,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 +636,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/runtime/tests/limit_orders.rs b/runtime/tests/limit_orders.rs index f68191fa29..274ccd44bf 100644 --- a/runtime/tests/limit_orders.rs +++ b/runtime/tests/limit_orders.rs @@ -294,11 +294,12 @@ fn cancel_order_works() { }); } -/// An order signed with an Ed25519 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_ed25519_signature_rejected() { +fn execute_orders_ecdsa_signature_rejected() { new_test_ext().execute_with(|| { let alice_id = Sr25519Keyring::Alice.to_account_id(); let bob_id = Sr25519Keyring::Bob.to_account_id(); @@ -322,12 +323,14 @@ fn execute_orders_ed25519_signature_rejected() { }); let id = order_id(&order); - // Sign with ed25519 — valid signature, wrong scheme. - let ed_pair = sp_core::ed25519::Pair::from_legacy_string("//Alice", None); - let ed_sig = ed_pair.sign(&order.encode()); + // Sign with ecdsa — cryptographically valid signature, rejected scheme. + // The signer is still sr25519 Alice, but the rejection is driven by the + // ecdsa scheme, not by a key mismatch. + let ecdsa_pair = sp_core::ecdsa::Pair::from_legacy_string("//Alice", None); + let ecdsa_sig = ecdsa_pair.sign(&order.encode()); let signed = SignedOrder { order, - signature: MultiSignature::Ed25519(ed_sig), + signature: MultiSignature::Ecdsa(ecdsa_sig), partial_fill: None, }; @@ -450,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 TakeProfit order whose price condition is satisfied executes against the pool, /// marks the order as Fulfilled, and burns the seller's staked alpha position. #[test] diff --git a/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-ed25519-wrapped.ts b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-ed25519-wrapped.ts new file mode 100644 index 0000000000..6f9893cf70 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/limit-orders/test-execute-orders-ed25519-wrapped.ts @@ -0,0 +1,112 @@ +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, + devExecuteOrders, +} from "../../../../utils/dev-helpers.js"; +import { + buildWrappedSignedOrder, + FAR_FUTURE, + fetchChainId, + filterEvents, + getOrderStatus, + orderId, + registerLimitOrderTypes, +} from "../../../../utils/limit-orders.js"; + +// One subnet per file — this test submits a real buy order signed by an +// ed25519 key over the ``-wrapped order hash (the Ledger / signRaw +// form). It exercises the runtime's alternative `is_order_valid` path: +// signature.verify(b"" ++ blake2_256(SCALE(VersionedOrder)) ++ b"", signer) +// with an Ed25519 signature. + +describeSuite({ + id: "DEV_SUB_LIMIT_ORDERS_ED25519_WRAPPED", + title: "execute_orders — ed25519 + -wrapped LimitBuy execution", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let polkadotJs: ApiPromise; + let alice: KeyringPair; + let aliceHotKey: KeyringPair; + let edSigner: KeyringPair; + let edHotKey: KeyringPair; + let netuid: number; + let chainId: bigint; + + beforeAll(async () => { + polkadotJs = context.polkadotJs(); + + alice = context.keyring.alice; + aliceHotKey = generateKeyringPair("sr25519"); + + // ed25519 coldkey/signer that signs the wrapped order hash, with an + // sr25519 hotkey associated to it. + edSigner = generateKeyringPair("ed25519"); + edHotKey = 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 devSudoSetLockReductionInterval(polkadotJs, context, alice, 1); + + netuid = await devRegisterSubnet(polkadotJs, context, alice, aliceHotKey); + + // Enable subtoken + await devEnableSubtoken(polkadotJs, context, alice, netuid); + // Associate hotkeys — the ed25519 signer associates its own hotkey. + await devAssociateHotKey(polkadotJs, context, alice, aliceHotKey.address); + await devAssociateHotKey(polkadotJs, context, edSigner, edHotKey.address); + }); + + it({ + id: "T01", + title: "LimitBuy executes with an ed25519 -wrapped 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 = buildWrappedSignedOrder(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-signed order. + await devExecuteOrders(polkadotJs, context, alice, [signed]); + + const events = await polkadotJs.query.system.events(); + const executed = filterEvents(events, "OrderExecuted"); + expect(executed.length).toBe(1); + + // OrderId should be stored as Fulfilled + 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); + }, + }); + }, +}); diff --git a/ts-tests/utils/limit-orders.ts b/ts-tests/utils/limit-orders.ts index 0ffbe177e0..e9ba6816c0 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 } from "@polkadot/util"; -import { blake2AsHex } from "@polkadot/util-crypto"; +import { u8aToHex, u8aWrapBytes } from "@polkadot/util"; +import { blake2AsHex, blake2AsU8a } from "@polkadot/util-crypto"; import { waitForTransactionWithRetry } from "./transactions.js"; import { MultiAddress } from "@polkadot-api/descriptors"; @@ -62,11 +62,11 @@ export const EXPIRED = BigInt(1); // 1ms — always in the past // ── Order building & signing ────────────────────────────────────────────────── /** - * Build a SignedOrder ready for submission to execute_orders / - * execute_batched_orders. The Order struct is SCALE-encoded via the - * polkadot.js registry and then signed with the signer's sr25519 key. + * Build the `VersionedOrder` (V1) struct from the supplied params. Shared by + * `buildSignedOrder` (raw signing) and `buildWrappedSignedOrder` (Ledger / + * signRaw ``-wrapped signing) so the field mapping stays identical. */ -export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { +function buildVersionedOrder(params: OrderParams): VersionedOrder { const inner: Order = { signer: params.signer.address, hotkey: params.hotkey, @@ -83,7 +83,16 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { partial_fills_enabled: params.partialFillsEnabled ?? false, }; - const versionedOrder: VersionedOrder = { V1: inner }; + return { V1: inner }; +} + +/** + * Build a SignedOrder ready for submission to execute_orders / + * execute_batched_orders. The Order struct is SCALE-encoded via the + * polkadot.js registry and then signed with the signer's sr25519 key. + */ +export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { + const versionedOrder = buildVersionedOrder(params); // SCALE-encode the VersionedOrder so the signature covers the version tag. const encoded = api.registry.createType("LimitVersionedOrder", versionedOrder); @@ -96,6 +105,46 @@ export function buildSignedOrder(api: any, params: OrderParams): SignedOrder { }; } +/** + * Build a SignedOrder whose signature is over the ``-wrapped order hash + * (the Ledger / `signRaw` form). This exercises the runtime's alternative + * verification path: + * + * signature.verify(b"" ++ blake2_256(SCALE(VersionedOrder)) ++ b"", signer) + * + * The signed payload is the raw 32-byte blake2-256 hash of the SCALE-encoded + * VersionedOrder, wrapped by `u8aWrapBytes` (which prepends `` and + * appends ``). This is byte-for-byte what the runtime reconstructs + * from `order_id.as_bytes()`, so the hash must be wrapped raw — never + * hex-encoded before wrapping. + * + * The signature scheme tag (`Sr25519` vs `Ed25519`) follows the signer's + * keypair type, so the same helper works for both schemes. + */ +export function buildWrappedSignedOrder(api: any, params: OrderParams): SignedOrder { + const versionedOrder = buildVersionedOrder(params); + + // SCALE-encode the VersionedOrder, then hash it (this is the OrderId). + const encoded = api.registry.createType("LimitVersionedOrder", versionedOrder); + const hash = blake2AsU8a(encoded.toU8a(), 256); + + // Wrap the raw 32-byte hash in the signRaw envelope: ..hash... + const wrapped = u8aWrapBytes(hash); + const sig = params.signer.sign(wrapped); + + // 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.