diff --git a/Cargo.lock b/Cargo.lock
index d9f053e4..29512042 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3315,36 +3315,36 @@ dependencies = [
[[package]]
name = "cranelift-assembler-x64"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e92993516bcc43fd4e511b31d2e0c0c24f6b7ac18bb39e32cac2bfc05ea378e"
+checksum = "b3741b9ad77e89c710586b2fcdd619baf28482d92b70d88cecb2a081dd912ae6"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97eeeb61b08ec484f274119faf3a8a1b80dd300b0208c0247d16d05982fc89fe"
+checksum = "8243e149085051b23faebd352ef7031aeebc5f45e63aaefd63ee7f810c22c7df"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "782eb44158681a105d157cb0ce63e3dc83f78ef5674266c12234f49da3f5c6e8"
+checksum = "c538af3aee13eb6aa8a85d2ef85b4d0aa41623dae8b19f59bbacfd5d6c114920"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edd59af193204c4f025fb2c2d9ee0aeb1f60929edf000b0dfa3d45543a62f264"
+checksum = "2292ea3964d7d751031da1a1261d64feb09e89bd4fbebf3c78f43f9c34c20a11"
dependencies = [
"serde",
"serde_derive",
@@ -3352,9 +3352,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1c29bd985057d790db25f6bc60b369b311aa2d75c2f496a7e9993585bfc7d60"
+checksum = "8967a699a4982d02c32246a00f2aa4cee5bccfaaa042608d5e151cb99cba210e"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
@@ -3379,9 +3379,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen-meta"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9ef8a8495f19212f92db8425f768ffc67000c5cb417a0664475a8d09596700f"
+checksum = "e258eeeb6903b4e91e314a3667ab2a1e2924738ef2df08bdb50355e38decc749"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
@@ -3392,24 +3392,24 @@ dependencies = [
[[package]]
name = "cranelift-codegen-shared"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3aa5099097666baf45bd68ef036327c8f29d8785fffaaa7db3b3b6b7e94bcc6"
+checksum = "e1d36ff0de30460c01dad152ca2097bcabef2f16c659b9ffbca2f67e9bbdb321"
[[package]]
name = "cranelift-control"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26f67f4f4e92c2934f40c2ff4574400bd27326932aad937a438b7fb459f60a63"
+checksum = "acc880d91877efb6199031bda872726ca3287dc1e18ccf3f61c07bfdab62930b"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8393d09f0abaa504896069e79f4eb6f15eb1853e5645732b082dac1ed292aaa8"
+checksum = "5f7af321c4b6e5be4729e4a5b1ed0fc78fc31695c1ecd3c9b45b91908bf3d0a3"
dependencies = [
"cranelift-bitset",
"serde",
@@ -3418,9 +3418,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "580cfaecbd11476dd82f6a393123dcbd9dfe583467df8d0c8441c641444d4985"
+checksum = "3463f1b26faac3491008cede8e9cdf7ceb83139143bd92ba86c14b0882aaf1bb"
dependencies = [
"cranelift-codegen",
"log",
@@ -3430,15 +3430,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c044843adb7d94b1facebaad846c0a762f79a77953417e3f0182093bcbbdc38"
+checksum = "90b76554b329634c4241c5670a96058affca99ac3b33daa14fb221bd68c0038e"
[[package]]
name = "cranelift-native"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d4c1707214e53f2040e4d13430069ea22623287b2750ed6d62e40eba88d91a"
+checksum = "36e7c67bcfd30a8160187a6d52b2ec0cbe45698a9d61f12d1292bd24701f7ed2"
dependencies = [
"cranelift-codegen",
"libc",
@@ -3447,9 +3447,9 @@ dependencies = [
[[package]]
name = "cranelift-srcgen"
-version = "0.123.18"
+version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d355a35edc49f628db7b793f3527eed31b560e77c3e8ed0e5258aad8df8a5249"
+checksum = "1ccca83c45ddfe0412b1b2e4fe509d28d1bdbc014e306574a848519741bebc36"
[[package]]
name = "crc"
@@ -5226,7 +5226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -7659,7 +7659,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -7668,7 +7668,7 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -15611,9 +15611,9 @@ dependencies = [
[[package]]
name = "pulley-interpreter"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "334ae8982dcf354e5f28e79fc6f935601e028b729cc174d3937eeeaa859057b8"
+checksum = "c956c7140270b72e3bf03381a51f2eb79c70fd6881fc17cd027fa7cd3f56ae5a"
dependencies = [
"cranelift-bitset",
"log",
@@ -15623,9 +15623,9 @@ dependencies = [
[[package]]
name = "pulley-macros"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cd207bca4bc5e46cb4fcea87196dcbd78befc628c64c67d11dc99fce5cd5369"
+checksum = "74db420d7ce2bb03eed5b81c1f778bda5771b3fd818e2b496dfc3a2783ceb60e"
dependencies = [
"proc-macro2",
"quote",
@@ -15724,7 +15724,7 @@ dependencies = [
"once_cell",
"socket2 0.6.5",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -16658,7 +16658,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -16671,7 +16671,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -16685,7 +16685,7 @@ dependencies = [
"once_cell",
"ring 0.17.14",
"rustls-pki-types",
- "rustls-webpki 0.103.15",
+ "rustls-webpki 0.103.16",
"subtle 2.6.1",
"zeroize",
]
@@ -16726,11 +16726,11 @@ dependencies = [
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
- "rustls-webpki 0.103.15",
+ "rustls-webpki 0.103.16",
"security-framework",
"security-framework-sys",
"webpki-root-certs 0.26.11",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -16747,11 +16747,11 @@ dependencies = [
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
- "rustls-webpki 0.103.15",
+ "rustls-webpki 0.103.16",
"security-framework",
"security-framework-sys",
- "webpki-root-certs 1.0.9",
- "windows-sys 0.59.0",
+ "webpki-root-certs 1.0.10",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -16772,9 +16772,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring 0.17.14",
@@ -20969,7 +20969,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -20988,7 +20988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -22230,9 +22230,9 @@ dependencies = [
[[package]]
name = "wasmtime"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70095b9fe63cc2a87ed206729e11544610decc184c62898c5d201ff1d666d82e"
+checksum = "60e5f244a2c9d40afa9e9a607ee7f048c0648bb1c22cc28b130c5fc71e23d71d"
dependencies = [
"addr2line",
"anyhow",
@@ -22278,9 +22278,9 @@ dependencies = [
[[package]]
name = "wasmtime-environ"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a67ea8904b6e369e2218780bd1f9186f1980cb616445eb2c1f3c4e45c8397865"
+checksum = "3e246cd5107aa406cfa4acf6942e864bd189ae8136bab853365147da131c9ca3"
dependencies = [
"anyhow",
"cpp_demangle",
@@ -22303,18 +22303,18 @@ dependencies = [
[[package]]
name = "wasmtime-internal-asm-macros"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0488a52ae93e654baecb08ce04d15d478756eb2168c2bdab7915710109161ce"
+checksum = "5380a8106fea8bfedb7208bb11ced9e7488ee44394ca889ede73acd3bdfd9d0b"
dependencies = [
"cfg-if",
]
[[package]]
name = "wasmtime-internal-cache"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e941dc94f323850facf75e49a6cd13d71f116a99c6a49fc34717f6e732616b2d"
+checksum = "4108e1964b3a02b4b5604ea1a2918a0cbcab512e5de0ccf4c062fc6c06b2eb8e"
dependencies = [
"anyhow",
"base64",
@@ -22332,9 +22332,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-cranelift"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63ea7ee133de9109a4a1fc63dae3afc9ea32e92b8dada824fa4595dbf175f80"
+checksum = "7339fdd64a3fde574505adea23244e065d5b1de0e72c8dd87e150f5d4ab63a63"
dependencies = [
"anyhow",
"cfg-if",
@@ -22359,9 +22359,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-fiber"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69662a0a4dbe9784d1e8add9e3c5d2db0ca348085c624c27a70992dbe2eff4a3"
+checksum = "6f3afc72d3c72f71d4ebc72e83f479ba8ca24409c41d43e5e0228f0bf82b873e"
dependencies = [
"anyhow",
"cc",
@@ -22375,9 +22375,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-debug"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00cb07d23903c0be359d6b920d94774ea322759c3f4d6721b61afa03602e4804"
+checksum = "21d33adf2d1eae36df9b6b033a1d549c7553ea607f4f9a81f75d51627725c354"
dependencies = [
"cc",
"object 0.37.3",
@@ -22387,9 +22387,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa2b47c37a6d026e2755fe2cf7ec5c1f4b0fba62e448ea910cdf6a07a4982c6a"
+checksum = "5bde2fcf14cc76084242c6fe792e49778b5a9008c7df28ca4fe21c35c69d5f55"
dependencies = [
"anyhow",
"cfg-if",
@@ -22399,24 +22399,24 @@ dependencies = [
[[package]]
name = "wasmtime-internal-math"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b269f0ba44102c57fd5f94f1bf4395ccd4fc2b84698fc78f627a6f2a47131a62"
+checksum = "4bd982249e933e730859f84c17254c7e95f38c3b0cdf81695bfc6a270e237852"
dependencies = [
"libm",
]
[[package]]
name = "wasmtime-internal-slab"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e35ac9d503d503703cd24d532e53e2e93c86109b451df40a9801500045aee25"
+checksum = "8280b359384cc412bddf0023193eeaffadcb0d7fbbf860f2b7e0580e8806a344"
[[package]]
name = "wasmtime-internal-unwinder"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83ce1d0930da44ed375701e6b4f187b46249f7efe2b1d8c1c77229a4adc82de9"
+checksum = "bb43477c41769fec0d031d32cb3236195acfb1e6aa4efdf463e5fd001c23f6cc"
dependencies = [
"anyhow",
"cfg-if",
@@ -22427,9 +22427,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-versioned-export-macros"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b6633be262400bd7abab4ff767a16e8b6a41ff117be54bf76dabecea9f96225"
+checksum = "b33afac17819f2f767ea280b4b87a6c95458ef1ae43da410cfc5b26813ecb78f"
dependencies = [
"proc-macro2",
"quote",
@@ -22438,9 +22438,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-winch"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "509d1e1cf8e3b3306f291407c92bca8381d9294f8c54ad74b692538f1f5b23c7"
+checksum = "1757c8b0626385c93994c7a3c7a23ee5b469061465b601c1fdce71ff61b11c82"
dependencies = [
"anyhow",
"cranelift-codegen",
@@ -22493,14 +22493,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
dependencies = [
- "webpki-root-certs 1.0.9",
+ "webpki-root-certs 1.0.10",
]
[[package]]
name = "webpki-root-certs"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -22670,7 +22670,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -22681,9 +22681,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
-version = "36.0.18"
+version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e470d7e674c41ad32dddc02d8d8a94983e0a4c516028bda60747e6dd862a567"
+checksum = "69940a29fc6b01b0e6b141912b700c17eaf956d58d63bc0c6e733435caf020e4"
dependencies = [
"anyhow",
"cranelift-assembler-x64",
diff --git a/deny.toml b/deny.toml
index 83343235..0085fe60 100644
--- a/deny.toml
+++ b/deny.toml
@@ -18,6 +18,11 @@ ignore = [
# tracing-subscriber 0.2.25 — pinned by ark-relations (ZK crates), no upgrade path
"RUSTSEC-2025-0055",
+ # webpki-roots 0.25.4 (distrusted emSign roots) — pinned by libp2p-websocket 0.44
+ # through sc-network's libp2p 0.54; fixed only in 1.0.10. Those roots validate the
+ # TLS of `wss` p2p dials, and peers are still authenticated by Noise and peer id.
+ "RUSTSEC-2026-0381",
+
# Unmaintained — all transitive, no direct upgrade path
"RUSTSEC-2025-0161", # libsecp256k1 0.7.2 (from frontier fp-evm/pallet-evm)
"RUSTSEC-2026-0119", # hickory-proto 0.24.x (from libp2p-dns → sc-network)
diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md
index 319502d4..6764e5eb 100644
--- a/template/runtime/RUNTIME_VERSIONS.md
+++ b/template/runtime/RUNTIME_VERSIONS.md
@@ -26,7 +26,9 @@ EVM precompiles: the Balances precompile only runs as a direct call and meters
by weight, and every Orbinum precompile address holds code, so Solidity
high-level calls reach them. EVM transaction validation and the modexp
precompile follow upstream Frontier (polkadot-evm/frontier, changes after
-#1790). `transaction_version` is unchanged. Ships with
+#1790). Ed25519 keys sign only text that names an Orbinum transaction:
+raw `Ed25519` (`0x00`) never verifies, and Solana wallets and Substrate extensions
+sign through the new `Ed25519Text` (`0x03`) and `Ed25519WrappedText` (`0x04`). `transaction_version` is unchanged. Ships with
`pallet-evm-precompile-balances` 0.2.0.
- **Balances precompile (`0x…0802`)** refuses static calls, delegated calls
@@ -62,6 +64,44 @@ precompile follow upstream Frontier (polkadot-evm/frontier, changes after
- **Digest check.** `pallet_ethereum::on_finalize` panics on a block with
more than one Frontier pre-runtime log, so the block is invalid. Before, it
treated that block as having no log (Frontier #1809).
+- **`OrbinumSignature::Ed25519Text` (`0x03`).** Ed25519 over the UTF-8 text
+ `"Orbinum transaction\nchain: 0x{genesis}\npayload: 0x{blake2_256(msg)}"`
+ (lowercase hex), where `msg` is what `Verify::verify` receives: the signing
+ payload, or its blake2_256 above 256 bytes. Phantom refuses binary input to
+ `solana:signMessage` and MetaMask rewrites it, so Solana accounts sign
+ extrinsics through this variant; the account is the raw ed25519 key.
+- **`OrbinumSignature::Ed25519WrappedText` (`0x04`).** The same text inside
+ `…`: what a Substrate extension's `signRaw` signs for it
+ (polkadot-js `u8aWrapBytes`), so ed25519 accounts in polkadot-js, Talisman or
+ SubWallet keep signing.
+- **Raw `Ed25519` (`0x00`) refused.** The variant stays at index 0 but never
+ verifies: polkadot-api builds no extrinsic for a signature enum without
+ `Ed25519`, `Sr25519` and `Ecdsa`, so removing it would break every client,
+ sr25519 accounts included. Solana wallets sign arbitrary bytes through `signMessage` (Backpack,
+ Solflare, Trust and Talisman sign a 32-byte binary "nonce"), and the account
+ cannot opt out of a variant, so a site that had a user sign a payload's
+ blake2_256 as a login nonce could submit it as that user's `0x00`
+ transaction. Every ed25519 signature must now cover text that says "Orbinum
+ transaction". **Clients:** an ed25519 account signs with `signRaw` of the
+ text and tags the result `0x04` (extension) or `0x03` (`signMessage`); plain
+ `signPayload` gives `0x00` and is refused (`BadProof`). Every index keeps
+ its encoding (`#[codec(index)]`), and no call encoding changes, so
+ `transaction_version` is unchanged.
+- **Signature hardening.** Every `OrbinumSignature` variant refuses what the
+ host primitive would accept as a forgery or an alias:
+ - **Ed25519Text / Ed25519WrappedText:** small-order keys (all encodings, either sign
+ bit). Under ZIP-215, `R = identity, s = 0` verifies any message for them.
+ The account `0x0100…00` (EVM address `0x01` followed by zeros) is such a key,
+ so before this anyone could sign as it.
+ - **Sr25519:** the identity key (`0x00…00`), under which `R = 0, s = 0`
+ verifies any message. `CheckNonZeroSender` already kept it out of
+ extrinsics.
+ - **Ecdsa:** only `s ≤ n/2` and a raw recovery id (`0`–`3`). The host also
+ recovers the same key from `(r, n − s)` with the other id and from `v + 27`,
+ so one signed extrinsic had up to four valid encodings and hashes. `sp_core`,
+ polkadot-js and noble already sign this way.
+ - The module is split by responsibility under `src/orbinum_signature/`
+ (`signer`, `ed25519`, `sr25519`, `ecdsa`, `text`).
### spec 19 — tx 7 — 2026-10-13 (`v0.5.0`)
diff --git a/template/runtime/src/orbinum_signature.rs b/template/runtime/src/orbinum_signature.rs
deleted file mode 100644
index 69724dd1..00000000
--- a/template/runtime/src/orbinum_signature.rs
+++ /dev/null
@@ -1,178 +0,0 @@
-//! OrbinumSignature — unified signature type for Orbinum.
-//!
-//! Extends the standard [`sp_runtime::MultiSignature`] so that the ECDSA variant
-//! derives `AccountId32` using Ethereum-compatible address encoding:
-//!
-//! ```text
-//! AccountId32 = [keccak256(uncompressed_pubkey[1..])[12..] | 0x00 × 12]
-//! ```
-//!
-//! This is identical to the mapping produced by `EeSuffixAddressMapping`, meaning
-//! the same secp256k1 keypair produces the **same** `AccountId32` from both an
-//! EVM transaction and a native Substrate extrinsic.
-//!
-//! Sr25519 and Ed25519 variants behave identically to `MultiSignature`.
-//!
-//! ## SCALE discriminants
-//!
-//! The variant ordering mirrors `sp_runtime::MultiSignature` so that SCALE-encoded
-//! bytes remain wire-compatible with existing tooling:
-//!
-//! | Discriminant | Variant |
-//! |:---:|----------|
-//! | 0x00 | Ed25519 |
-//! | 0x01 | Sr25519 |
-//! | 0x02 | Ecdsa |
-
-use scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
-use scale_info::TypeInfo;
-use sp_core::{ecdsa, ed25519, sr25519, H160};
-use sp_runtime::traits::{IdentifyAccount, Lazy, Verify};
-
-use crate::evm_account::evm_bytes_to_account_id_bytes;
-
-/// The account ID type used by this runtime.
-pub type AccountId = sp_runtime::AccountId32;
-
-/// Derive an Ethereum `H160` address from the 64-byte uncompressed public-key
-/// body (the 64 bytes *after* the `0x04` prefix byte).
-///
-/// This is the standard Ethereum address derivation:
-/// `keccak256(uncompressed_pubkey[1..])[12..]`
-fn pub64_to_eth_address(pub64: &[u8; 64]) -> H160 {
- let hash = sp_io::hashing::keccak_256(pub64);
- H160::from_slice(&hash[12..])
-}
-
-// ─── OrbinumSigner ────────────────────────────────────────────────────────────
-
-/// The signer type that corresponds to [`OrbinumSignature`].
-///
-/// For ECDSA keys the `AccountId` is derived as `[eth_address | 0x00 × 12]`
-/// rather than `blake2_256(compressed_pubkey)` used by `MultiSigner`.
-///
-/// Variant order matches `sp_runtime::MultiSigner` for SCALE compatibility.
-#[derive(
- Clone,
- Eq,
- PartialEq,
- Debug,
- Encode,
- Decode,
- DecodeWithMemTracking,
- TypeInfo,
- MaxEncodedLen
-)]
-pub enum OrbinumSigner {
- Ed25519(ed25519::Public),
- Sr25519(sr25519::Public),
- /// ECDSA key — `AccountId` derived as `[eth_address | 0x00 × 12]`.
- Ecdsa(ecdsa::Public),
-}
-
-impl From for OrbinumSigner {
- fn from(k: ed25519::Public) -> Self {
- Self::Ed25519(k)
- }
-}
-
-impl From for OrbinumSigner {
- fn from(k: sr25519::Public) -> Self {
- Self::Sr25519(k)
- }
-}
-
-impl From for OrbinumSigner {
- fn from(k: ecdsa::Public) -> Self {
- Self::Ecdsa(k)
- }
-}
-
-impl IdentifyAccount for OrbinumSigner {
- type AccountId = AccountId;
-
- fn into_account(self) -> AccountId {
- match self {
- OrbinumSigner::Ed25519(k) => sp_runtime::MultiSigner::Ed25519(k).into_account(),
- OrbinumSigner::Sr25519(k) => sp_runtime::MultiSigner::Sr25519(k).into_account(),
- OrbinumSigner::Ecdsa(k) => {
- // Decompress the 33-byte compressed key to 65-byte uncompressed form,
- // then derive the Ethereum address with keccak256.
- use libsecp256k1::{PublicKey, PublicKeyFormat};
- let compressed: &[u8; 33] = k.as_ref();
- let pk = match PublicKey::parse_slice(compressed, Some(PublicKeyFormat::Compressed))
- {
- Ok(pk) => pk,
- // Invalid key — fall back to standard MultiSigner derivation.
- Err(_) => return sp_runtime::MultiSigner::Ecdsa(k).into_account(),
- };
- let uncompressed = pk.serialize(); // [u8; 65], 0x04 prefix
- let pub64: [u8; 64] = uncompressed[1..65]
- .try_into()
- .expect("uncompressed key without prefix is exactly 64 bytes; qed");
- let h160 = pub64_to_eth_address(&pub64);
- AccountId::from(evm_bytes_to_account_id_bytes(*h160.as_fixed_bytes()))
- }
- }
- }
-}
-
-// ─── OrbinumSignature ─────────────────────────────────────────────────────────
-
-/// Unified signature type for Orbinum.
-///
-/// - `Ed25519` / `Sr25519`: identical to [`sp_runtime::MultiSignature`].
-/// - `Ecdsa`: verifies using `blake2_256` of the payload; the signer `AccountId`
-/// is derived as `[keccak256(uncompressed_pubkey[1..])[12..] | 0x00 × 12]`,
-/// matching `EeSuffixAddressMapping`.
-///
-/// Variant order mirrors `sp_runtime::MultiSignature` for SCALE wire compatibility
-/// (Ed25519 = 0x00, Sr25519 = 0x01, Ecdsa = 0x02).
-#[derive(
- Clone,
- Eq,
- PartialEq,
- Debug,
- Encode,
- Decode,
- DecodeWithMemTracking,
- TypeInfo,
- MaxEncodedLen
-)]
-pub enum OrbinumSignature {
- Ed25519(ed25519::Signature),
- Sr25519(sr25519::Signature),
- /// ECDSA: payload hashed with blake2_256; AccountId via keccak256 / EVM-suffix.
- Ecdsa(ecdsa::Signature),
-}
-
-impl Verify for OrbinumSignature {
- type Signer = OrbinumSigner;
-
- fn verify>(&self, mut msg: L, signer: &AccountId) -> bool {
- match self {
- OrbinumSignature::Ed25519(sig) => {
- sp_runtime::MultiSignature::Ed25519(*sig).verify(msg, signer)
- }
- OrbinumSignature::Sr25519(sig) => {
- sp_runtime::MultiSignature::Sr25519(*sig).verify(msg, signer)
- }
- OrbinumSignature::Ecdsa(sig) => {
- // 1. Hash the payload with blake2_256 (standard Substrate signing).
- let hash = sp_io::hashing::blake2_256(msg.get());
- // 2. Recover the 64-byte uncompressed public key (without 0x04 prefix).
- let sig_bytes: &[u8; 65] = sig.as_ref();
- let pub64 = match sp_io::crypto::secp256k1_ecdsa_recover(sig_bytes, &hash) {
- Ok(k) => k,
- Err(_) => return false,
- };
- // 3. Derive the Ethereum address: keccak256(pub64)[12..].
- let h160 = pub64_to_eth_address(&pub64);
- // 4. Build AccountId32 = [H160 | 0x00 × 12] and compare.
- let expected =
- AccountId::from(evm_bytes_to_account_id_bytes(*h160.as_fixed_bytes()));
- expected == *signer
- }
- }
- }
-}
diff --git a/template/runtime/src/orbinum_signature/ecdsa.rs b/template/runtime/src/orbinum_signature/ecdsa.rs
new file mode 100644
index 00000000..501ae151
--- /dev/null
+++ b/template/runtime/src/orbinum_signature/ecdsa.rs
@@ -0,0 +1,64 @@
+//! ECDSA (secp256k1) verification and the EVM-compatible account.
+
+use crate::{evm_account::evm_bytes_to_account_id_bytes, AccountId};
+use hex_literal::hex;
+use sp_core::ecdsa::Signature;
+
+/// secp256k1 group order `n` halved, big-endian.
+const HALF_ORDER: [u8; 32] =
+ hex!("7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0");
+
+/// `[keccak256(pubkey)[12..] ‖ 0x00 × 12]` for the 64-byte uncompressed key without its
+/// `0x04` prefix: the account `EeSuffixAddressMapping` gives the key's EVM address.
+pub(super) fn account_from_pubkey(pub64: &[u8; 64]) -> AccountId {
+ let hash = sp_io::hashing::keccak_256(pub64);
+ let mut address = [0u8; 20];
+ address.copy_from_slice(&hash[12..]);
+ AccountId::from(evm_bytes_to_account_id_bytes(address))
+}
+
+/// The host also recovers the same key from `(r, n − s)` with the other recovery id, and
+/// from `v + 27`; requiring a low `s` and the raw id (0–3) leaves one encoding.
+fn is_canonical(sig: &[u8; 65]) -> bool {
+ sig[32..64] <= HALF_ORDER[..] && sig[64] < 4
+}
+
+pub(super) fn verify(sig: &Signature, msg: &[u8], signer: &AccountId) -> bool {
+ let sig: &[u8; 65] = sig.as_ref();
+ if !is_canonical(sig) {
+ return false;
+ }
+ sp_io::crypto::secp256k1_ecdsa_recover(sig, &sp_io::hashing::blake2_256(msg))
+ .is_ok_and(|pub64| account_from_pubkey(&pub64) == *signer)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sig_with(s: [u8; 32], v: u8) -> [u8; 65] {
+ let mut sig = [1u8; 65];
+ sig[32..64].copy_from_slice(&s);
+ sig[64] = v;
+ sig
+ }
+
+ #[test]
+ fn half_order_is_the_largest_canonical_s() {
+ let mut above = HALF_ORDER;
+ above[31] += 1;
+ assert!(is_canonical(&sig_with(HALF_ORDER, 0)));
+ assert!(!is_canonical(&sig_with(above, 0)));
+ assert!(!is_canonical(&sig_with([0xff; 32], 0)));
+ }
+
+ #[test]
+ fn only_raw_recovery_ids_are_canonical() {
+ for v in 0..4 {
+ assert!(is_canonical(&sig_with([0; 32], v)));
+ }
+ for v in [4, 26, 27, 28, 29, 30, 255] {
+ assert!(!is_canonical(&sig_with([0; 32], v)));
+ }
+ }
+}
diff --git a/template/runtime/src/orbinum_signature/ed25519.rs b/template/runtime/src/orbinum_signature/ed25519.rs
new file mode 100644
index 00000000..8d1fb15d
--- /dev/null
+++ b/template/runtime/src/orbinum_signature/ed25519.rs
@@ -0,0 +1,27 @@
+//! Ed25519 verification, shared by `Ed25519Text` and `Ed25519WrappedText`.
+
+use hex_literal::hex;
+use sp_core::ed25519::{Public, Signature};
+
+/// Every encoding (canonical or not) of the small-order points of edwards25519, compared
+/// with the sign bit cleared (libsodium's `has_small_order` list). `ed25519_verify`
+/// follows ZIP-215, under which such a key accepts a forged signature for any message.
+const SMALL_ORDER_KEYS: [[u8; 32]; 7] = [
+ hex!("0000000000000000000000000000000000000000000000000000000000000000"),
+ hex!("0100000000000000000000000000000000000000000000000000000000000000"),
+ hex!("26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05"),
+ hex!("c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a"),
+ hex!("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ hex!("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ hex!("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+];
+
+fn has_small_order(key: &[u8; 32]) -> bool {
+ SMALL_ORDER_KEYS
+ .iter()
+ .any(|k| k[..31] == key[..31] && k[31] == key[31] & 0x7f)
+}
+
+pub(super) fn verify(sig: &Signature, msg: &[u8], key: &[u8; 32]) -> bool {
+ !has_small_order(key) && sp_io::crypto::ed25519_verify(sig, msg, &Public::from_raw(*key))
+}
diff --git a/template/runtime/src/orbinum_signature/mod.rs b/template/runtime/src/orbinum_signature/mod.rs
new file mode 100644
index 00000000..53e66058
--- /dev/null
+++ b/template/runtime/src/orbinum_signature/mod.rs
@@ -0,0 +1,82 @@
+//! `OrbinumSignature`: the runtime's extrinsic signature.
+//!
+//! | Discriminant | Variant | Signed bytes | Account |
+//! |:---:|----------------------|------------------------------------|---------------------------------------|
+//! | 0x00 | `Ed25519` | never verifies | — |
+//! | 0x01 | `Sr25519` | `msg` | raw key |
+//! | 0x02 | `Ecdsa` | `blake2_256(msg)` | `[keccak256(pubkey)[12..] ‖ 0x00×12]` |
+//! | 0x03 | `Ed25519Text` | [`text_message`] of `msg` | raw key |
+//! | 0x04 | `Ed25519WrappedText` | [`wrapped_text_message`] of `msg` | raw key |
+//!
+//! `msg` is what `Verify::verify` receives: the signing payload, or its blake2_256 when
+//! longer than 256 bytes. 0x01–0x02 keep `sp_runtime::MultiSignature`'s wire format. The
+//! ECDSA account matches `EeSuffixAddressMapping`, so one secp256k1 key is the same
+//! account on the EVM and Substrate paths.
+//!
+//! Ed25519 keys sign only text that names an Orbinum transaction. A raw ed25519 signature
+//! over `msg` (`Ed25519`) is never accepted: Solana wallets sign arbitrary bytes through `signMessage`,
+//! so a signature they give for a "login nonce" could be a payload's blake2_256.
+//! `Ed25519Text` covers wallets that sign text as given (Phantom, MetaMask, Backpack…),
+//! `Ed25519WrappedText` Substrate extensions' `signRaw`. `Ed25519` stays in the type only
+//! because clients read it from the metadata: polkadot-api refuses to build any extrinsic
+//! for a signature enum without `Ed25519`, `Sr25519` and `Ecdsa`.
+//!
+//! Each variant accepts a single encoding per signature and refuses keys that verify
+//! forged signatures: small-order ed25519 keys, the sr25519 identity key, and ECDSA
+//! signatures with a high `s` or an aliased recovery id.
+
+mod ecdsa;
+mod ed25519;
+mod signer;
+mod sr25519;
+mod text;
+
+pub use signer::OrbinumSigner;
+#[cfg(test)]
+pub use text::{text_message, wrapped_text_message, TEXT_MESSAGE_LEN, WRAPPED_TEXT_MESSAGE_LEN};
+
+use crate::AccountId;
+use scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
+use scale_info::TypeInfo;
+use sp_runtime::traits::{Lazy, Verify};
+
+/// Indices are wire format: never reuse one.
+#[derive(
+ Clone,
+ Eq,
+ PartialEq,
+ Debug,
+ Encode,
+ Decode,
+ DecodeWithMemTracking,
+ TypeInfo,
+ MaxEncodedLen
+)]
+pub enum OrbinumSignature {
+ /// Refused: see the module doc.
+ #[codec(index = 0)]
+ Ed25519(sp_core::ed25519::Signature),
+ #[codec(index = 1)]
+ Sr25519(sp_core::sr25519::Signature),
+ #[codec(index = 2)]
+ Ecdsa(sp_core::ecdsa::Signature),
+ #[codec(index = 3)]
+ Ed25519Text(sp_core::ed25519::Signature),
+ #[codec(index = 4)]
+ Ed25519WrappedText(sp_core::ed25519::Signature),
+}
+
+impl Verify for OrbinumSignature {
+ type Signer = OrbinumSigner;
+
+ fn verify>(&self, mut msg: L, signer: &AccountId) -> bool {
+ let key: &[u8; 32] = signer.as_ref();
+ match self {
+ Self::Ed25519(_) => false,
+ Self::Sr25519(sig) => sr25519::verify(sig, msg.get(), key),
+ Self::Ecdsa(sig) => ecdsa::verify(sig, msg.get(), signer),
+ Self::Ed25519Text(sig) => text::verify(sig, msg.get(), key),
+ Self::Ed25519WrappedText(sig) => text::verify_wrapped(sig, msg.get(), key),
+ }
+ }
+}
diff --git a/template/runtime/src/orbinum_signature/signer.rs b/template/runtime/src/orbinum_signature/signer.rs
new file mode 100644
index 00000000..13de5528
--- /dev/null
+++ b/template/runtime/src/orbinum_signature/signer.rs
@@ -0,0 +1,67 @@
+//! `OrbinumSigner`: the public key behind an `OrbinumSignature`, and its account.
+
+use crate::AccountId;
+use scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
+use scale_info::TypeInfo;
+use sp_core::{ecdsa, ed25519, sr25519};
+use sp_runtime::{traits::IdentifyAccount, MultiSigner};
+
+/// Variant order matches `sp_runtime::MultiSigner`.
+#[derive(
+ Clone,
+ Eq,
+ PartialEq,
+ Debug,
+ Encode,
+ Decode,
+ DecodeWithMemTracking,
+ TypeInfo,
+ MaxEncodedLen
+)]
+pub enum OrbinumSigner {
+ Ed25519(ed25519::Public),
+ Sr25519(sr25519::Public),
+ /// Account `[eth_address ‖ 0x00 × 12]`, not `MultiSigner`'s `blake2_256(key)`.
+ Ecdsa(ecdsa::Public),
+}
+
+impl From for OrbinumSigner {
+ fn from(k: ed25519::Public) -> Self {
+ Self::Ed25519(k)
+ }
+}
+
+impl From for OrbinumSigner {
+ fn from(k: sr25519::Public) -> Self {
+ Self::Sr25519(k)
+ }
+}
+
+impl From for OrbinumSigner {
+ fn from(k: ecdsa::Public) -> Self {
+ Self::Ecdsa(k)
+ }
+}
+
+impl IdentifyAccount for OrbinumSigner {
+ type AccountId = AccountId;
+
+ fn into_account(self) -> AccountId {
+ match self {
+ Self::Ed25519(k) => MultiSigner::Ed25519(k).into_account(),
+ Self::Sr25519(k) => MultiSigner::Sr25519(k).into_account(),
+ Self::Ecdsa(k) => {
+ use libsecp256k1::{PublicKey, PublicKeyFormat};
+ match PublicKey::parse_slice(k.as_ref(), Some(PublicKeyFormat::Compressed)) {
+ Ok(pk) => {
+ let mut pub64 = [0u8; 64];
+ pub64.copy_from_slice(&pk.serialize()[1..]);
+ super::ecdsa::account_from_pubkey(&pub64)
+ }
+ // No ECDSA signature recovers an invalid key, so this account never verifies.
+ Err(_) => MultiSigner::Ecdsa(k).into_account(),
+ }
+ }
+ }
+ }
+}
diff --git a/template/runtime/src/orbinum_signature/sr25519.rs b/template/runtime/src/orbinum_signature/sr25519.rs
new file mode 100644
index 00000000..55f77b06
--- /dev/null
+++ b/template/runtime/src/orbinum_signature/sr25519.rs
@@ -0,0 +1,11 @@
+//! Sr25519 verification.
+
+use sp_core::sr25519::{Public, Signature};
+
+/// The Ristretto identity, the group's only small-order element: under it `R = 0, s = 0`
+/// verifies any message.
+const IDENTITY: [u8; 32] = [0; 32];
+
+pub(super) fn verify(sig: &Signature, msg: &[u8], key: &[u8; 32]) -> bool {
+ *key != IDENTITY && sp_io::crypto::sr25519_verify(sig, msg, &Public::from_raw(*key))
+}
diff --git a/template/runtime/src/orbinum_signature/text.rs b/template/runtime/src/orbinum_signature/text.rs
new file mode 100644
index 00000000..985680af
--- /dev/null
+++ b/template/runtime/src/orbinum_signature/text.rs
@@ -0,0 +1,77 @@
+//! `Ed25519Text` and `Ed25519WrappedText`: ed25519 over a UTF-8 rendering of the payload.
+
+use sp_core::ed25519::Signature;
+
+const HEAD: &[u8] = b"Orbinum transaction\nchain: 0x";
+const PAYLOAD: &[u8] = b"\npayload: 0x";
+const OPEN: &[u8] = b"";
+const CLOSE: &[u8] = b"";
+
+/// Length of every [`text_message`]: both hashes render as 64 hex characters.
+pub const TEXT_MESSAGE_LEN: usize = HEAD.len() + 64 + PAYLOAD.len() + 64;
+
+/// Length of every [`wrapped_text_message`].
+pub const WRAPPED_TEXT_MESSAGE_LEN: usize = OPEN.len() + TEXT_MESSAGE_LEN + CLOSE.len();
+
+/// The text an `Ed25519Text` signature covers, with lowercase hex:
+///
+/// ```text
+/// Orbinum transaction
+/// chain: 0x{genesis}
+/// payload: 0x{blake2_256(msg)}
+/// ```
+///
+/// `msg` may already be a blake2_256 (payloads over 256 bytes); `verify` cannot tell, so
+/// it is always hashed again.
+pub fn text_message(genesis: &[u8; 32], msg: &[u8]) -> [u8; TEXT_MESSAGE_LEN] {
+ let mut out = [0u8; TEXT_MESSAGE_LEN];
+ concat_into(
+ &mut out,
+ [
+ HEAD,
+ &hex32(genesis),
+ PAYLOAD,
+ &hex32(&sp_io::hashing::blake2_256(msg)),
+ ],
+ );
+ out
+}
+
+/// [`text_message`] inside `…`: what a Substrate extension's `signRaw`
+/// signs for that text (polkadot-js `u8aWrapBytes`).
+pub fn wrapped_text_message(genesis: &[u8; 32], msg: &[u8]) -> [u8; WRAPPED_TEXT_MESSAGE_LEN] {
+ let mut out = [0u8; WRAPPED_TEXT_MESSAGE_LEN];
+ concat_into(&mut out, [OPEN, &text_message(genesis, msg), CLOSE]);
+ out
+}
+
+fn concat_into(out: &mut [u8], parts: [&[u8]; N]) {
+ let mut at = 0;
+ for part in parts {
+ out[at..at + part.len()].copy_from_slice(part);
+ at += part.len();
+ }
+}
+
+fn hex32(bytes: &[u8; 32]) -> [u8; 64] {
+ const DIGITS: &[u8; 16] = b"0123456789abcdef";
+ let mut out = [0u8; 64];
+ for (i, b) in bytes.iter().enumerate() {
+ out[2 * i] = DIGITS[(b >> 4) as usize];
+ out[2 * i + 1] = DIGITS[(b & 0x0f) as usize];
+ }
+ out
+}
+
+/// Same source as `CheckGenesis`, which already read it to build the payload.
+fn genesis() -> [u8; 32] {
+ frame_system::Pallet::::block_hash(0).to_fixed_bytes()
+}
+
+pub(super) fn verify(sig: &Signature, msg: &[u8], key: &[u8; 32]) -> bool {
+ super::ed25519::verify(sig, &text_message(&genesis(), msg), key)
+}
+
+pub(super) fn verify_wrapped(sig: &Signature, msg: &[u8], key: &[u8; 32]) -> bool {
+ super::ed25519::verify(sig, &wrapped_text_message(&genesis(), msg), key)
+}
diff --git a/template/runtime/src/runtime_tests/ed25519_text.rs b/template/runtime/src/runtime_tests/ed25519_text.rs
new file mode 100644
index 00000000..a64de674
--- /dev/null
+++ b/template/runtime/src/runtime_tests/ed25519_text.rs
@@ -0,0 +1,446 @@
+//! `OrbinumSignature::Ed25519Text`: ed25519 over the text rendering of the payload.
+
+use super::with_ext;
+use crate::{
+ orbinum_signature::{
+ text_message, wrapped_text_message, OrbinumSignature, TEXT_MESSAGE_LEN,
+ WRAPPED_TEXT_MESSAGE_LEN,
+ },
+ AccountId, Runtime, RuntimeCall, SignedExtra, SignedPayload, UncheckedExtrinsic,
+};
+use hex_literal::hex;
+use scale_codec::{Decode, Encode, MaxEncodedLen};
+use sp_core::{ed25519, Pair, H256};
+use sp_runtime::{
+ traits::{Checkable, Verify},
+ transaction_validity::{InvalidTransaction, TransactionValidityError},
+ MultiSignature,
+};
+
+/// Public testnet genesis; the wallet vectors below were signed against it.
+const TESTNET_GENESIS: [u8; 32] =
+ hex!("58e2de05e550e64bb73bb2aeb706431dae70dcd1b589d477033c6e3aef3680ae");
+
+/// `system.remark("orbinum solana probe")` signing payload (spec 19, tx 7, nonce 0, immortal).
+const SHORT_PAYLOAD: [u8; 100] = hex!(
+ "0000506f7262696e756d20736f6c616e612070726f626500000000130000000700000058e2de05e550e64b
+ b73bb2aeb706431dae70dcd1b589d477033c6e3aef3680ae58e2de05e550e64bb73bb2aeb706431dae70dc
+ d1b589d477033c6e3aef3680ae00"
+);
+
+/// blake2_256 of the 381-byte `system.remark("x" × 300)` payload: what `verify` receives.
+const LONG_PAYLOAD_HASH: [u8; 32] =
+ hex!("eaf7d84f0f7506f11bfc84c0eecbdfc8de11c799069005f3128c1130dbd43abd");
+
+/// Signatures produced by the browser wallets through `solana:signMessage` over
+/// `textMessage` (`scripts/solana/lib/payload.mjs`), on 2026-10-19.
+struct WalletVector {
+ key: [u8; 32],
+ short: [u8; 64],
+ long: [u8; 64],
+}
+
+const PHANTOM: WalletVector = WalletVector {
+ key: hex!("64c602b56868980acc7d978f3e6564079b9d1323b3578660986d9561cbf39ff5"),
+ short: hex!(
+ "ec3a49751ef729e15d8cd227a49f696799c0b763d6601d0fd19cc3246010f2ac
+ 07405822f2b7cefb999f5d3d15e1bd8dd18c7c7da980d07852d8f8d4fb0f0b04"
+ ),
+ long: hex!(
+ "f951cf2f7d5b32cbd8d966fca779a1f94e884af0745cbc0f7197a0f33405b5d9
+ 7ef6e405df389646c908c46e148720bf7b8ddb82ccb49dab2905c6921d21d609"
+ ),
+};
+
+const METAMASK: WalletVector = WalletVector {
+ key: hex!("8a5c9983fd2b7cce2eaedb7ac261f9072de8f08879f6efb0d563d68167badb63"),
+ short: hex!(
+ "830d09e131b63721bcde6bdc6fa4e4a96df2a9c9bc6d53064a64b76fe33637cf
+ 572d4c927a2980447655d848f8ef3da6243b5418b590109164d537fbcd0c0d07"
+ ),
+ long: hex!(
+ "a3d4b9f4d8729fb25892b903d2f0e2294062f022fc5b38f5a190d8903a8b1016
+ fd35192abd652cb1385b650995fff2439f64899a27025216c39adf5bb4c4710d"
+ ),
+};
+
+/// Independent of the runtime's own encoder.
+fn to_hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn set_genesis(genesis: [u8; 32]) {
+ frame_system::BlockHash::::insert(0u32, H256(genesis));
+}
+
+fn text_sig(sig: [u8; 64]) -> OrbinumSignature {
+ OrbinumSignature::Ed25519Text(ed25519::Signature::from_raw(sig))
+}
+
+fn keypair() -> (ed25519::Pair, AccountId) {
+ let pair = ed25519::Pair::from_seed(&[7u8; 32]);
+ let account = AccountId::from(pair.public().0);
+ (pair, account)
+}
+
+fn signed_extra(nonce: u32) -> SignedExtra {
+ (
+ frame_system::CheckNonZeroSender::new(),
+ frame_system::CheckSpecVersion::new(),
+ frame_system::CheckTxVersion::new(),
+ frame_system::CheckGenesis::new(),
+ frame_system::CheckEra::from(sp_runtime::generic::Era::Immortal),
+ frame_system::CheckNonce::from(nonce),
+ frame_system::CheckWeight::new(),
+ pallet_transaction_payment::ChargeTransactionPayment::from(0u128),
+ frame_metadata_hash_extension::CheckMetadataHash::new(false),
+ )
+}
+
+/// The signing payload before the >256-byte hashing that `SignedPayload::encode` applies.
+fn raw_payload(call: &RuntimeCall, nonce: u32) -> Vec {
+ SignedPayload::new(call.clone(), signed_extra(nonce))
+ .unwrap()
+ .deconstruct()
+ .encode()
+}
+
+fn remark(len: usize) -> RuntimeCall {
+ RuntimeCall::System(frame_system::Call::remark {
+ remark: vec![b'x'; len],
+ })
+}
+
+/// Signs `call` the way a text-only wallet does: over `text_message` of the bytes
+/// `verify` will receive.
+fn sign_text(pair: &ed25519::Pair, call: &RuntimeCall, nonce: u32) -> OrbinumSignature {
+ let payload = SignedPayload::new(call.clone(), signed_extra(nonce)).unwrap();
+ let genesis = frame_system::Pallet::::block_hash(0u32);
+ let text = payload.using_encoded(|msg| text_message(genesis.as_fixed_bytes(), msg));
+ OrbinumSignature::Ed25519Text(pair.sign(&text))
+}
+
+/// Signs `call` the way a Substrate extension's `signRaw` does over the same text.
+fn sign_wrapped_text(pair: &ed25519::Pair, call: &RuntimeCall, nonce: u32) -> OrbinumSignature {
+ let payload = SignedPayload::new(call.clone(), signed_extra(nonce)).unwrap();
+ let genesis = frame_system::Pallet::::block_hash(0u32);
+ let text = payload.using_encoded(|msg| wrapped_text_message(genesis.as_fixed_bytes(), msg));
+ OrbinumSignature::Ed25519WrappedText(pair.sign(&text))
+}
+
+fn check(
+ call: RuntimeCall,
+ account: &AccountId,
+ signature: OrbinumSignature,
+ nonce: u32,
+) -> Result<(), TransactionValidityError> {
+ let xt = UncheckedExtrinsic::new_signed(call, account.clone(), signature, signed_extra(nonce));
+ // Round-trip the wire format, as the pool does.
+ let xt = UncheckedExtrinsic::decode(&mut &xt.encode()[..]).unwrap();
+ xt.check(&frame_system::ChainContext::::default())
+ .map(|_| ())
+}
+
+// ─── Message format ───────────────────────────────────────────────────────────
+
+#[test]
+fn text_message_matches_the_documented_format() {
+ let genesis = [0xabu8; 32];
+ let msg = b"payload bytes";
+ let expected = format!(
+ "Orbinum transaction\nchain: 0x{}\npayload: 0x{}",
+ to_hex(&genesis),
+ to_hex(&sp_io::hashing::blake2_256(msg)),
+ );
+ assert_eq!(&text_message(&genesis, msg)[..], expected.as_bytes());
+ assert_eq!(TEXT_MESSAGE_LEN, 169);
+}
+
+#[test]
+fn text_message_hashes_a_32_byte_message_again() {
+ let genesis = [1u8; 32];
+ let text = text_message(&genesis, &LONG_PAYLOAD_HASH);
+ let tail = format!(
+ "payload: 0x{}",
+ to_hex(&sp_io::hashing::blake2_256(&LONG_PAYLOAD_HASH))
+ );
+ assert!(text.ends_with(tail.as_bytes()));
+}
+
+// ─── Real wallet signatures ───────────────────────────────────────────────────
+
+#[test]
+fn wallet_signatures_verify() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ for (name, w) in [("phantom", PHANTOM), ("metamask", METAMASK)] {
+ let account = AccountId::from(w.key);
+ assert!(
+ text_sig(w.short).verify(&SHORT_PAYLOAD[..], &account),
+ "{name}: short payload"
+ );
+ assert!(
+ text_sig(w.long).verify(&LONG_PAYLOAD_HASH[..], &account),
+ "{name}: hashed long payload"
+ );
+ }
+ });
+}
+
+#[test]
+fn wallet_signature_rejected_on_another_chain() {
+ with_ext(|| {
+ set_genesis([0x11; 32]);
+ let account = AccountId::from(PHANTOM.key);
+ assert!(!text_sig(PHANTOM.short).verify(&SHORT_PAYLOAD[..], &account));
+ });
+}
+
+#[test]
+fn wallet_signature_rejected_for_altered_payload() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let account = AccountId::from(PHANTOM.key);
+ let mut altered = SHORT_PAYLOAD;
+ altered[3] ^= 0x01;
+ assert!(!text_sig(PHANTOM.short).verify(&altered[..], &account));
+ });
+}
+
+#[test]
+fn wallet_signature_rejected_for_another_account() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let other = AccountId::from(METAMASK.key);
+ assert!(!text_sig(PHANTOM.short).verify(&SHORT_PAYLOAD[..], &other));
+ });
+}
+
+#[test]
+fn wallet_signature_rejected_for_swapped_payload() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let account = AccountId::from(PHANTOM.key);
+ assert!(!text_sig(PHANTOM.short).verify(&LONG_PAYLOAD_HASH[..], &account));
+ assert!(!text_sig(PHANTOM.long).verify(&SHORT_PAYLOAD[..], &account));
+ });
+}
+
+// ─── Separation between the ed25519 framings ──────────────────────────────────
+
+#[test]
+fn a_raw_ed25519_signature_verifies_under_no_variant() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let sig = pair.sign(&SHORT_PAYLOAD);
+ assert!(!OrbinumSignature::Ed25519Text(sig).verify(&SHORT_PAYLOAD[..], &account));
+ assert!(!OrbinumSignature::Ed25519WrappedText(sig).verify(&SHORT_PAYLOAD[..], &account));
+ });
+}
+
+#[test]
+fn text_and_wrapped_text_signatures_do_not_cross() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let account = AccountId::from(PHANTOM.key);
+ let sig = ed25519::Signature::from_raw(PHANTOM.short);
+ assert!(OrbinumSignature::Ed25519Text(sig).verify(&SHORT_PAYLOAD[..], &account));
+ assert!(!OrbinumSignature::Ed25519WrappedText(sig).verify(&SHORT_PAYLOAD[..], &account));
+
+ let (pair, account) = keypair();
+ let wrapped = pair.sign(&wrapped_text_message(&TESTNET_GENESIS, &SHORT_PAYLOAD));
+ assert!(OrbinumSignature::Ed25519WrappedText(wrapped).verify(&SHORT_PAYLOAD[..], &account));
+ assert!(!OrbinumSignature::Ed25519Text(wrapped).verify(&SHORT_PAYLOAD[..], &account));
+ });
+}
+
+/// The bytes polkadot-js `signRaw` signs for the text: `u8aWrapBytes`.
+#[test]
+fn wrapped_text_message_is_the_text_inside_bytes_tags() {
+ let text = text_message(&TESTNET_GENESIS, &SHORT_PAYLOAD);
+ let expected = [b"".as_slice(), &text, b""].concat();
+ assert_eq!(
+ &wrapped_text_message(&TESTNET_GENESIS, &SHORT_PAYLOAD)[..],
+ &expected[..]
+ );
+ assert_eq!(WRAPPED_TEXT_MESSAGE_LEN, 184);
+}
+
+// ─── Full extrinsic check ─────────────────────────────────────────────────────
+
+#[test]
+fn signed_extrinsic_with_short_payload_passes_check() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let call = remark(20);
+ assert!(raw_payload(&call, 0).len() <= 256);
+ let sig = sign_text(&pair, &call, 0);
+ assert_eq!(check(call, &account, sig, 0), Ok(()));
+ });
+}
+
+#[test]
+fn signed_extrinsic_with_hashed_long_payload_passes_check() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let call = remark(300);
+ assert!(raw_payload(&call, 0).len() > 256);
+ let sig = sign_text(&pair, &call, 0);
+ assert_eq!(check(call, &account, sig, 0), Ok(()));
+ });
+}
+
+#[test]
+fn wrapped_text_extrinsics_pass_check_short_and_long() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ for len in [20, 300] {
+ let call = remark(len);
+ let sig = sign_wrapped_text(&pair, &call, 0);
+ assert_eq!(
+ check(call, &account, sig, 0),
+ Ok(()),
+ "remark of {len} bytes"
+ );
+ }
+ });
+}
+
+#[test]
+fn signed_extrinsic_with_unhashed_long_payload_fails_check() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let call = remark(300);
+ let text = text_message(&TESTNET_GENESIS, &raw_payload(&call, 0));
+ let sig = OrbinumSignature::Ed25519Text(pair.sign(&text));
+ assert_eq!(
+ check(call, &account, sig, 0),
+ Err(InvalidTransaction::BadProof.into())
+ );
+ });
+}
+
+#[test]
+fn signed_extrinsic_with_changed_extension_fails_check() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let sig = sign_text(&pair, &remark(20), 0);
+ assert_eq!(
+ check(remark(20), &account, sig, 1),
+ Err(InvalidTransaction::BadProof.into())
+ );
+ });
+}
+
+#[test]
+fn signed_extrinsic_with_changed_call_fails_check() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let sig = sign_text(&pair, &remark(20), 0);
+ assert_eq!(
+ check(remark(21), &account, sig, 0),
+ Err(InvalidTransaction::BadProof.into())
+ );
+ });
+}
+
+#[test]
+fn signed_extrinsic_signed_for_another_chain_fails_check() {
+ with_ext(|| {
+ set_genesis([0x22; 32]);
+ let (pair, account) = keypair();
+ let sig = sign_text(&pair, &remark(20), 0);
+ set_genesis(TESTNET_GENESIS);
+ assert_eq!(
+ check(remark(20), &account, sig, 0),
+ Err(InvalidTransaction::BadProof.into())
+ );
+ });
+}
+
+// ─── Wire format ──────────────────────────────────────────────────────────────
+
+#[test]
+fn discriminants_and_lengths_are_stable() {
+ use sp_core::{ecdsa, sr25519};
+
+ let (pair, _) = keypair();
+ let sig = pair.sign(b"wire");
+
+ // 0x00 decodes as `Ed25519`, kept for the metadata; it never verifies.
+ let raw = OrbinumSignature::Ed25519(sig).encode();
+ assert_eq!(raw, MultiSignature::Ed25519(sig).encode());
+
+ let sr = sr25519::Signature::from_raw([1u8; 64]);
+ assert_eq!(
+ OrbinumSignature::Sr25519(sr).encode(),
+ MultiSignature::Sr25519(sr).encode()
+ );
+ let ec = ecdsa::Signature::from_raw([2u8; 65]);
+ assert_eq!(
+ OrbinumSignature::Ecdsa(ec).encode(),
+ MultiSignature::Ecdsa(ec).encode()
+ );
+
+ for (tag, variant) in [
+ (0x03u8, OrbinumSignature::Ed25519Text(sig)),
+ (0x04u8, OrbinumSignature::Ed25519WrappedText(sig)),
+ ] {
+ let encoded = variant.encode();
+ assert_eq!(encoded[0], tag);
+ assert_eq!(&encoded[1..], &sig.0[..]);
+ assert_eq!(
+ OrbinumSignature::decode(&mut &encoded[..]).unwrap(),
+ variant
+ );
+ }
+
+ let unknown = [&[0x05u8][..], &sig.0[..]].concat();
+ assert!(OrbinumSignature::decode(&mut &unknown[..]).is_err());
+
+ assert_eq!(OrbinumSignature::max_encoded_len(), 66);
+}
+
+/// polkadot-api builds no extrinsic for a signature enum lacking `Ed25519`, `Sr25519` or
+/// `Ecdsa`, and tags them with the fixed indices 0, 1 and 2.
+#[test]
+fn signature_metadata_keeps_the_shape_polkadot_api_requires() {
+ use scale_info::{TypeDef, TypeInfo};
+
+ let TypeDef::Variant(def) = OrbinumSignature::type_info().type_def else {
+ panic!("OrbinumSignature is not an enum in the metadata");
+ };
+ let index_of = |name: &str| {
+ def.variants
+ .iter()
+ .find(|v| v.name == name)
+ .map(|v| v.index)
+ };
+ assert_eq!(index_of("Ed25519"), Some(0));
+ assert_eq!(index_of("Sr25519"), Some(1));
+ assert_eq!(index_of("Ecdsa"), Some(2));
+ assert_eq!(index_of("Ed25519Text"), Some(3));
+ assert_eq!(index_of("Ed25519WrappedText"), Some(4));
+}
+
+#[test]
+fn a_valid_raw_ed25519_signature_never_verifies() {
+ with_ext(|| {
+ set_genesis(TESTNET_GENESIS);
+ let (pair, account) = keypair();
+ let sig = pair.sign(&SHORT_PAYLOAD);
+ assert!(sp_io::crypto::ed25519_verify(
+ &sig,
+ &SHORT_PAYLOAD,
+ &pair.public()
+ ));
+ assert!(!OrbinumSignature::Ed25519(sig).verify(&SHORT_PAYLOAD[..], &account));
+ });
+}
diff --git a/template/runtime/src/runtime_tests/mod.rs b/template/runtime/src/runtime_tests/mod.rs
index 45b2bc8e..3654a36d 100644
--- a/template/runtime/src/runtime_tests/mod.rs
+++ b/template/runtime/src/runtime_tests/mod.rs
@@ -2,9 +2,11 @@
mod accounts;
mod block_author;
+mod ed25519_text;
mod host_functions;
mod metadata_hash;
mod precompile_code;
+mod signature_hardening;
mod weights;
use sp_io::TestExternalities;
diff --git a/template/runtime/src/runtime_tests/signature_hardening.rs b/template/runtime/src/runtime_tests/signature_hardening.rs
new file mode 100644
index 00000000..54c85464
--- /dev/null
+++ b/template/runtime/src/runtime_tests/signature_hardening.rs
@@ -0,0 +1,209 @@
+//! Keys and encodings `OrbinumSignature` refuses although the host primitive accepts them.
+
+use super::with_ext;
+use crate::{
+ orbinum_signature::{text_message, wrapped_text_message, OrbinumSignature, OrbinumSigner},
+ AccountId, Runtime,
+};
+use hex_literal::hex;
+use sp_core::{ecdsa, ed25519, sr25519, Pair, H256};
+use sp_runtime::traits::{IdentifyAccount, Verify};
+
+const MSG: &[u8] = b"any message";
+const GENESIS: [u8; 32] = [0x5a; 32];
+
+/// secp256k1 group order, big-endian.
+const SECP256K1_ORDER: [u8; 32] =
+ hex!("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");
+
+/// `n − s` for a big-endian scalar.
+fn order_minus(s: &[u8]) -> [u8; 32] {
+ let mut out = [0u8; 32];
+ let mut borrow = 0i16;
+ for i in (0..32).rev() {
+ let d = SECP256K1_ORDER[i] as i16 - s[i] as i16 - borrow;
+ borrow = (d < 0) as i16;
+ out[i] = (d + 256 * borrow) as u8;
+ }
+ out
+}
+
+// ─── Ed25519 / Ed25519Text ────────────────────────────────────────────────────
+
+/// Every small-order edwards25519 encoding, sign bit cleared.
+const SMALL_ORDER: [[u8; 32]; 7] = [
+ hex!("0000000000000000000000000000000000000000000000000000000000000000"),
+ hex!("0100000000000000000000000000000000000000000000000000000000000000"),
+ hex!("26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05"),
+ hex!("c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a"),
+ hex!("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ hex!("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ hex!("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+];
+
+/// R = identity, s = 0: under ZIP-215, valid for a small-order key and any message.
+const ED25519_FORGERY: [u8; 64] = {
+ let mut sig = [0u8; 64];
+ sig[0] = 0x01;
+ sig
+};
+
+#[test]
+fn ed25519_small_order_keys_are_refused() {
+ with_ext(|| {
+ frame_system::BlockHash::::insert(0u32, H256(GENESIS));
+ let forged = ed25519::Signature::from_raw(ED25519_FORGERY);
+ let text = text_message(&GENESIS, MSG);
+ let wrapped = wrapped_text_message(&GENESIS, MSG);
+ for key in SMALL_ORDER {
+ let public = ed25519::Public::from_raw(key);
+ // The host accepts the forgery, so each refusal below comes from the key check.
+ assert!(sp_io::crypto::ed25519_verify(&forged, &text, &public));
+ assert!(sp_io::crypto::ed25519_verify(&forged, &wrapped, &public));
+
+ let mut negated = key;
+ negated[31] |= 0x80;
+ for k in [key, negated] {
+ let account = AccountId::from(k);
+ assert!(
+ !OrbinumSignature::Ed25519Text(forged).verify(MSG, &account),
+ "Ed25519Text accepted {k:02x?}"
+ );
+ assert!(
+ !OrbinumSignature::Ed25519WrappedText(forged).verify(MSG, &account),
+ "Ed25519WrappedText accepted {k:02x?}"
+ );
+ }
+ }
+ });
+}
+
+/// The EVM address `0x01 00…00` maps to the identity point's encoding.
+#[test]
+fn ed25519_forgery_cannot_spend_from_the_evm_identity_account() {
+ with_ext(|| {
+ let account = crate::evm_h160_to_account_id(sp_core::H160::from(hex!(
+ "0100000000000000000000000000000000000000"
+ )));
+ assert_eq!(
+ >::as_ref(&account),
+ &SMALL_ORDER[1]
+ );
+ frame_system::BlockHash::::insert(0u32, H256(GENESIS));
+ let forged = ed25519::Signature::from_raw(ED25519_FORGERY);
+ assert!(!OrbinumSignature::Ed25519Text(forged).verify(MSG, &account));
+ assert!(!OrbinumSignature::Ed25519WrappedText(forged).verify(MSG, &account));
+ });
+}
+
+#[test]
+fn ed25519_ordinary_key_still_verifies() {
+ with_ext(|| {
+ frame_system::BlockHash::::insert(0u32, H256(GENESIS));
+ let pair = ed25519::Pair::from_seed(&[3u8; 32]);
+ let account = OrbinumSigner::from(pair.public()).into_account();
+ let text = pair.sign(&text_message(&GENESIS, MSG));
+ let wrapped = pair.sign(&wrapped_text_message(&GENESIS, MSG));
+ assert!(OrbinumSignature::Ed25519Text(text).verify(MSG, &account));
+ assert!(OrbinumSignature::Ed25519WrappedText(wrapped).verify(MSG, &account));
+ assert!(!OrbinumSignature::Ed25519Text(text).verify(&b"other"[..], &account));
+ });
+}
+
+/// A Solana wallet signs any bytes through `signMessage`, e.g. a payload's blake2_256
+/// shown as a login nonce. That signature verifies as raw ed25519 but under no variant.
+#[test]
+fn a_blind_signature_over_the_payload_hash_authorizes_nothing() {
+ with_ext(|| {
+ frame_system::BlockHash::::insert(0u32, H256(GENESIS));
+ let pair = ed25519::Pair::from_seed(&[4u8; 32]);
+ let account = OrbinumSigner::from(pair.public()).into_account();
+ let payload_hash = sp_io::hashing::blake2_256(b"a payload longer than 256 bytes");
+ let blind = pair.sign(&payload_hash);
+ assert!(sp_io::crypto::ed25519_verify(
+ &blind,
+ &payload_hash,
+ &pair.public()
+ ));
+ assert!(!OrbinumSignature::Ed25519Text(blind).verify(&payload_hash[..], &account));
+ assert!(!OrbinumSignature::Ed25519WrappedText(blind).verify(&payload_hash[..], &account));
+ assert!(!OrbinumSignature::Ed25519(blind).verify(&payload_hash[..], &account));
+ });
+}
+
+// ─── Sr25519 ──────────────────────────────────────────────────────────────────
+
+#[test]
+fn sr25519_identity_key_is_refused() {
+ with_ext(|| {
+ // R = 0, s = 0, with schnorrkel's marker bit.
+ let mut raw = [0u8; 64];
+ raw[63] = 0x80;
+ let forged = sr25519::Signature::from_raw(raw);
+ let identity = [0u8; 32];
+ assert!(sp_io::crypto::sr25519_verify(
+ &forged,
+ MSG,
+ &sr25519::Public::from_raw(identity)
+ ));
+ assert!(!OrbinumSignature::Sr25519(forged).verify(MSG, &AccountId::from(identity)));
+ });
+}
+
+#[test]
+fn sr25519_ordinary_key_still_verifies() {
+ let pair = sr25519::Pair::from_string("//Alice", None).unwrap();
+ let account = OrbinumSigner::from(pair.public()).into_account();
+ assert!(OrbinumSignature::Sr25519(pair.sign(MSG)).verify(MSG, &account));
+}
+
+// ─── Ecdsa ────────────────────────────────────────────────────────────────────
+
+fn ecdsa_alice() -> (ecdsa::Signature, AccountId) {
+ let pair = ecdsa::Pair::from_string("//Alice", None).unwrap();
+ (
+ pair.sign(MSG),
+ OrbinumSigner::from(pair.public()).into_account(),
+ )
+}
+
+fn recovers_same_key(a: &[u8; 65], b: &[u8; 65]) -> bool {
+ let hash = sp_io::hashing::blake2_256(MSG);
+ match (
+ sp_io::crypto::secp256k1_ecdsa_recover(a, &hash),
+ sp_io::crypto::secp256k1_ecdsa_recover(b, &hash),
+ ) {
+ (Ok(ka), Ok(kb)) => ka == kb,
+ _ => false,
+ }
+}
+
+#[test]
+fn ecdsa_canonical_signature_verifies() {
+ let (sig, account) = ecdsa_alice();
+ assert!(
+ sig.0[32..64] <= order_minus(&sig.0[32..64])[..],
+ "sp_core signs with a low s"
+ );
+ assert!(sig.0[64] < 2);
+ assert!(OrbinumSignature::Ecdsa(sig).verify(MSG, &account));
+}
+
+#[test]
+fn ecdsa_high_s_alias_is_refused() {
+ let (sig, account) = ecdsa_alice();
+ let mut alias = sig.0;
+ alias[32..64].copy_from_slice(&order_minus(&sig.0[32..64]));
+ alias[64] ^= 1;
+ assert!(recovers_same_key(&sig.0, &alias));
+ assert!(!OrbinumSignature::Ecdsa(ecdsa::Signature::from_raw(alias)).verify(MSG, &account));
+}
+
+#[test]
+fn ecdsa_offset_recovery_id_is_refused() {
+ let (sig, account) = ecdsa_alice();
+ let mut alias = sig.0;
+ alias[64] += 27;
+ assert!(recovers_same_key(&sig.0, &alias));
+ assert!(!OrbinumSignature::Ecdsa(ecdsa::Signature::from_raw(alias)).verify(MSG, &account));
+}