Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pallets/limit-orders/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions pallets/limit-orders/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: crate::Config>(
public: sp_core::sr25519::Public,
order: &crate::VersionedOrder<T::AccountId>,
) -> crate::SignedOrder<T::AccountId> {
let order_hash = sp_io::hashing::blake2_256(&order.encode());
let payload = [b"<Bytes>".as_slice(), &order_hash, b"</Bytes>".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
// `<Bytes>…</Bytes>`-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::<T>::render_order(order);
let payload = [b"<Bytes>".as_slice(), &msg, b"</Bytes>".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),
Expand Down
212 changes: 192 additions & 20 deletions pallets/limit-orders/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -134,15 +148,19 @@ impl<AccountId: Encode + Decode + TypeInfo + MaxEncodedLen + Clone> 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
/// `<Bytes>`-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: `<Bytes>` + `blake2_256(SCALE_ENCODE(VersionedOrder))` (the `OrderId`) + `</Bytes>`,
/// 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<AccountId: Encode + Decode + TypeInfo + MaxEncodedLen + Clone> {
pub order: VersionedOrder<AccountId>,
/// 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 `<Bytes>`-wrapped order hash (see `verify_order` / `verify_wrapped`).
pub signature: MultiSignature,
/// Whether we want a partial fill for this order
pub partial_fill: Option<u64>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<T::AccountId>) -> 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: `<Bytes>` + `blake2_256(SCALE_ENCODE(order))` (i.e. `order_id`) + `</Bytes>`.
/// Signing a fixed-size hash keeps the message within Ledger's signing limits,
/// and the `<Bytes>…</Bytes>` 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<T::AccountId>,
order_id: H256,
) -> bool {
let order = signed_order.order.inner();
let payload = [
b"<Bytes>".as_slice(),
order_id.as_bytes(),
b"</Bytes>".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 = <T as frame_system::Config>::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<T::AccountId>) -> Vec<u8> {
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 `<Bytes>`-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 (`<Bytes>` + 32-byte hash + `</Bytes>`), and `verify_order` is not a
/// Ledger form at all — it has no `<Bytes>…</Bytes>` envelope, which the device's
/// `tx_raw_parse` requires, so a Ledger can never produce it.
pub(crate) fn verify_readable(signed_order: &SignedOrder<T::AccountId>) -> bool {
let order = signed_order.order.inner();
let msg = Self::render_order(&signed_order.order);
let payload = [b"<Bytes>".as_slice(), &msg, b"</Bytes>".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.
Expand All @@ -613,23 +787,21 @@ pub mod pallet {
order.chain_id == T::ChainId::get(),
Error::<T>::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 `<Bytes>…</Bytes>`-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"<Bytes>".as_slice(),
order_id.as_bytes(),
b"</Bytes>".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::<T>::InvalidSignature
);
let order_status = Orders::<T>::get(order_id);
Expand Down
Loading
Loading