feat(substrate): a subxt config for quantus, and offline signing under the context
ci / gate (push) Waiting to run

`QuantusConfig` is quantus-cli's `ChainConfig` with the signature enum
defined here: `DilithiumSignature { Dilithium87, Dilithium65 }`, variant
indices 0 and 1, each carrying signature-then-public-key as one raw array
because the account id is a hash of the key and the verifier needs the key
itself. Defining it locally keeps sp-core out of a desktop wallet.

subxt 0.44, deliberately: it is the version quantus-cli signs mainnet with,
and its extension resolver skips any extension whose type is empty, which
is how the chain's ReversibleTransactionExtension and
WormholeProofRecorderExtension are tolerated with the default set. A test
pins that both names are in the metadata and that building still works.

`sign_call` builds the v4 extrinsic offline from captured chain state,
takes the signer payload from subxt (blake2-hashed past 256 bytes, as the
runtime hashes when it verifies), signs it with a `wallet_core::Signer`
under the profile's FIPS 204 context, and hands the signature back to
subxt to assemble. The test decodes the result byte by byte: 0x84, Id
address equal to the signer's account id, the variant byte, the public key
riding with the signature, the extras, the call data as the tail.

Live acceptance against a1-planck.quantus.cat, from an unfunded account:
signed under QUANTUS_EXTRINSIC the node answers "Inability to pay some
fees", so the signature passed; signed under the empty context it answers
"Transaction has a bad signature". Ignored test, run by hand.

Metadata fixtures are live `state_getMetadata` from mainnet (spec 152) and
Planck (spec 148), 101 KB each, v14.

Closes #19

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-15 22:16:27 +03:00
co-authored by Claude Fable 5.1
parent f0b3c26714
commit 1ca42fdbdf
9 changed files with 2398 additions and 19 deletions
Generated
+1866 -19
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -50,6 +50,13 @@ tempfile = "3"
# Quantus's human-readable address checksum; its wordlist is vendored.
bip39 = { version = "=2.2.2", features = ["zeroize"] }
qp-human-checkphrase = "2.2.1"
# subxt 0.44 is the version quantus-cli signs mainnet transactions with; its
# extension resolver skips extensions whose type is empty, which is how the
# chain's two custom extensions (Reversible, WormholeProofRecorder) are
# tolerated without a custom config. 0.51 will add a builder for them.
subxt = "0.44"
parity-scale-codec = { version = "3", features = ["derive"] }
hex = "0.4"
# `isolation`: the IPC isolation pattern (crates/wallet-app/isolation/index.html).
tauri = { version = "2", features = ["isolation"] }
tauri-build = { version = "2", features = ["isolation"] }
+7
View File
@@ -28,3 +28,10 @@ tracing.workspace = true
tempfile.workspace = true
bip39.workspace = true
qp-human-checkphrase.workspace = true
subxt.workspace = true
parity-scale-codec.workspace = true
hex.workspace = true
[dev-dependencies]
tokio.workspace = true
blake2.workspace = true
+1
View File
@@ -7,3 +7,4 @@
pub mod keys;
pub mod keystore;
pub mod mnemonic;
pub mod substrate;
+144
View File
@@ -0,0 +1,144 @@
//! The subxt `Config` for Quantus, lifted from quantus-cli's `ChainConfig`
//! with the signature type defined here rather than imported from the
//! runtime's crypto crate, which would drag `sp-core` into a desktop wallet
//! for the sake of one enum.
//!
//! Everything else is stock Substrate: `AccountId32`, `MultiAddress` with a
//! unit index, blake2 hashing, a u32 block number, and subxt's default
//! extension set. The chain's two custom transaction extensions contribute
//! no bytes and subxt 0.44 skips extensions with an empty type, so the
//! default set is exactly right; a test pins that this remains true for the
//! metadata we ship as fixtures.
use parity_scale_codec::{Decode, Encode};
use qp_rusty_crystals_dilithium::{ml_dsa_65, ml_dsa_87};
use subxt::config::substrate::{BlakeTwo256, SubstrateHeader};
use subxt::config::{Config, DefaultExtrinsicParams};
use subxt::utils::{AccountId32, MultiAddress};
use wallet_entities::SignatureScheme;
/// Bytes on the wire for each variant: signature followed by public key.
pub const DILITHIUM87_WIRE_LEN: usize = ml_dsa_87::SIGNBYTES + ml_dsa_87::PUBLICKEYBYTES;
pub const DILITHIUM65_WIRE_LEN: usize = ml_dsa_65::SIGNBYTES + ml_dsa_65::PUBLICKEYBYTES;
/// The runtime's `Signature` type, `DilithiumSignatureScheme`: an enum whose
/// variants carry the signature and the public key together, because the
/// account id is a hash of the key and the verifier needs the key itself.
/// Variant order is the chain's and must only ever be appended to.
// The variants differ in size by design: SCALE encodes each as its raw
// array, and boxing would change nothing on the wire.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Encode, Decode)]
pub enum DilithiumSignature {
#[codec(index = 0)]
Dilithium87([u8; DILITHIUM87_WIRE_LEN]),
#[codec(index = 1)]
Dilithium65([u8; DILITHIUM65_WIRE_LEN]),
}
impl DilithiumSignature {
/// Assemble the wire form from a raw signature and its public key.
pub fn from_parts(
scheme: SignatureScheme,
signature: &[u8],
public_key: &[u8],
) -> Result<Self, String> {
let (sig_len, pk_len) = match scheme {
SignatureScheme::MlDsa87 => (ml_dsa_87::SIGNBYTES, ml_dsa_87::PUBLICKEYBYTES),
SignatureScheme::MlDsa65 => (ml_dsa_65::SIGNBYTES, ml_dsa_65::PUBLICKEYBYTES),
};
if signature.len() != sig_len || public_key.len() != pk_len {
return Err(format!(
"{scheme:?}: expected signature {sig_len} and public key {pk_len} bytes, got {} and {}",
signature.len(),
public_key.len()
));
}
Ok(match scheme {
SignatureScheme::MlDsa87 => {
let mut bytes = [0u8; DILITHIUM87_WIRE_LEN];
bytes[..sig_len].copy_from_slice(signature);
bytes[sig_len..].copy_from_slice(public_key);
Self::Dilithium87(bytes)
}
SignatureScheme::MlDsa65 => {
let mut bytes = [0u8; DILITHIUM65_WIRE_LEN];
bytes[..sig_len].copy_from_slice(signature);
bytes[sig_len..].copy_from_slice(public_key);
Self::Dilithium65(bytes)
}
})
}
pub fn scheme(&self) -> SignatureScheme {
match self {
Self::Dilithium87(_) => SignatureScheme::MlDsa87,
Self::Dilithium65(_) => SignatureScheme::MlDsa65,
}
}
/// The signature half and the public-key half.
pub fn parts(&self) -> (&[u8], &[u8]) {
match self {
Self::Dilithium87(b) => b.split_at(ml_dsa_87::SIGNBYTES),
Self::Dilithium65(b) => b.split_at(ml_dsa_65::SIGNBYTES),
}
}
}
impl std::fmt::Debug for DilithiumSignature {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (sig, pk) = self.parts();
write!(
f,
"DilithiumSignature::{:?}(sig {} bytes, pk {} bytes)",
self.scheme(),
sig.len(),
pk.len()
)
}
}
/// The subxt configuration for Quantus chains.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuantusConfig {}
impl Config for QuantusConfig {
type AccountId = AccountId32;
type Address = MultiAddress<Self::AccountId, ()>;
type Signature = DilithiumSignature;
type Hasher = BlakeTwo256;
type Header = SubstrateHeader<u32, BlakeTwo256>;
type ExtrinsicParams = DefaultExtrinsicParams<Self>;
type AssetId = u32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_lengths_match_fips_204_and_the_wasm_readme() {
assert_eq!(DILITHIUM87_WIRE_LEN, 4627 + 2592);
assert_eq!(DILITHIUM65_WIRE_LEN, 3309 + 1952);
}
#[test]
fn encodes_as_variant_index_then_raw_bytes() {
let sig = vec![7u8; ml_dsa_65::SIGNBYTES];
let pk = vec![9u8; ml_dsa_65::PUBLICKEYBYTES];
let s = DilithiumSignature::from_parts(SignatureScheme::MlDsa65, &sig, &pk).unwrap();
let encoded = s.encode();
assert_eq!(encoded.len(), 1 + DILITHIUM65_WIRE_LEN);
assert_eq!(encoded[0], 1, "ML-DSA-65 is variant 1");
assert_eq!(&encoded[1..1 + ml_dsa_65::SIGNBYTES], &sig[..]);
assert_eq!(&encoded[1 + ml_dsa_65::SIGNBYTES..], &pk[..]);
let back = DilithiumSignature::decode(&mut &encoded[..]).unwrap();
assert_eq!(back.parts(), (&sig[..], &pk[..]));
assert!(DilithiumSignature::from_parts(SignatureScheme::MlDsa87, &sig, &pk).is_err());
assert_eq!(
format!("{s:?}"),
"DilithiumSignature::MlDsa65(sig 3309 bytes, pk 1952 bytes)"
);
}
}
+10
View File
@@ -0,0 +1,10 @@
//! The Substrate adapter, Quantus first: a subxt `Config` whose signature
//! type is the chain's ML-DSA enum, and offline construction and signing of
//! extrinsics so that a signature is made by a `wallet_core::Signer` under
//! the chain profile's context and never by subxt itself.
pub mod config;
pub mod offline;
pub use config::{DilithiumSignature, QuantusConfig};
pub use offline::{ChainState, SignedExtrinsic, sign_call};
+363
View File
@@ -0,0 +1,363 @@
//! Build and sign an extrinsic without a connection. The caller supplies
//! what a connection would have told it (genesis, runtime version,
//! metadata, nonce) and a `Signer`; subxt assembles the signer payload and
//! the final extrinsic, and the signature itself is made here, under the
//! chain profile's FIPS 204 context. subxt hashes payloads longer than 256
//! bytes with blake2 before handing them over, as the runtime does when it
//! verifies, so the bytes signed are exactly the bytes checked.
use parity_scale_codec::Decode;
use subxt::client::RuntimeVersion;
use subxt::config::DefaultExtrinsicParamsBuilder;
use subxt::tx::Payload;
use subxt::utils::{AccountId32, H256};
use subxt::{Metadata, OfflineClient};
use wallet_core::Signer;
use super::config::{DilithiumSignature, QuantusConfig};
/// What the chain would tell a connected client, captured for offline use.
#[derive(Clone)]
pub struct ChainState {
pub genesis_hash: H256,
pub spec_version: u32,
pub transaction_version: u32,
pub metadata: Metadata,
}
impl ChainState {
/// From raw `state_getMetadata` bytes and the runtime version.
pub fn from_metadata_bytes(
genesis_hash: [u8; 32],
spec_version: u32,
transaction_version: u32,
metadata: &[u8],
) -> Result<Self, String> {
let metadata =
Metadata::decode(&mut &metadata[..]).map_err(|e| format!("metadata: {e}"))?;
Ok(Self {
genesis_hash: H256(genesis_hash),
spec_version,
transaction_version,
metadata,
})
}
fn offline_client(&self) -> OfflineClient<QuantusConfig> {
OfflineClient::new(
self.genesis_hash,
RuntimeVersion {
spec_version: self.spec_version,
transaction_version: self.transaction_version,
},
self.metadata.clone(),
)
}
}
/// Mortality for a transaction: immortal, or valid for `for_blocks` from a
/// known block.
#[derive(Debug, Clone)]
pub enum Mortality {
Immortal,
Mortal {
for_blocks: u64,
from_number: u64,
from_hash: H256,
},
}
/// A signed extrinsic ready to submit, with what the review screen and the
/// watcher need alongside the bytes.
#[derive(Debug, Clone)]
pub struct SignedExtrinsic {
/// SCALE-encoded, length-prefixed, as `author_submitExtrinsic` wants it.
pub bytes: Vec<u8>,
/// blake2_256 of `bytes`: the transaction hash the chain reports.
pub hash: H256,
/// The signer payload that was signed (already blake2-hashed when the
/// raw payload exceeded 256 bytes).
pub signer_payload: Vec<u8>,
/// The bare call bytes, for decoding on the review screen.
pub call_data: Vec<u8>,
}
/// Build a v4 signed extrinsic for `call` and sign it with `signer` under
/// `context`. The account id is the signer's; the address form is `Id`.
pub fn sign_call(
state: &ChainState,
call: &impl Payload,
nonce: u64,
tip: u128,
mortality: Mortality,
signer: &dyn Signer,
context: &[u8],
) -> Result<SignedExtrinsic, String> {
let client = state.offline_client();
let mut params = DefaultExtrinsicParamsBuilder::<QuantusConfig>::new()
.nonce(nonce)
.tip(tip);
params = match mortality {
Mortality::Immortal => params,
Mortality::Mortal {
for_blocks,
from_number,
from_hash,
} => params.mortal_from_unchecked(for_blocks, from_number, from_hash),
};
let mut partial = client
.tx()
.create_partial_offline(call, params.build())
.map_err(|e| e.to_string())?;
let signer_payload = partial.signer_payload();
let raw_signature = signer
.sign(&signer_payload, context)
.map_err(|e| e.to_string())?;
let signature =
DilithiumSignature::from_parts(signer.scheme(), &raw_signature, signer.public_key())?;
let account = AccountId32(signer.account_id());
let submittable = partial.sign_with_account_and_signature(&account, &signature);
Ok(SignedExtrinsic {
bytes: submittable.encoded().to_vec(),
hash: submittable.hash(),
signer_payload,
call_data: partial.call_data().to_vec(),
})
}
/// `Balances::transfer_keep_alive(dest, value)` as a dynamic call.
pub fn transfer_keep_alive(dest: [u8; 32], amount: u128) -> subxt::tx::DynamicPayload {
use subxt::dynamic::Value;
subxt::dynamic::tx(
"Balances",
"transfer_keep_alive",
vec![
Value::unnamed_variant("Id", [Value::from_bytes(dest)]),
Value::u128(amount),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::{MlDsa, QUANTUS_EXTRINSIC_CONTEXT, seed_from_mnemonic, verify};
use wallet_core::KeyScheme;
use wallet_entities::SignatureScheme;
const DEV_PHRASE: &str =
"bottom drive obey lake curtain smoke basket hold race lonely fit walk";
const MAINNET_GENESIS: &str =
"fb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba";
const PLANCK_GENESIS: &str = "4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72";
fn fixture(name: &str) -> Vec<u8> {
std::fs::read(
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/metadata/").to_owned() + name,
)
.unwrap()
}
fn genesis(hex_str: &str) -> [u8; 32] {
hex::decode(hex_str).unwrap().try_into().unwrap()
}
fn mainnet() -> ChainState {
ChainState::from_metadata_bytes(
genesis(MAINNET_GENESIS),
152,
6,
&fixture("quantus-mainnet.scale"),
)
.unwrap()
}
/// Skip the compact length prefix of an encoded extrinsic.
fn strip_len(bytes: &[u8]) -> &[u8] {
let (len, used) = match bytes[0] & 0b11 {
0 => ((bytes[0] >> 2) as usize, 1),
1 => ((u16::from_le_bytes([bytes[0], bytes[1]]) >> 2) as usize, 2),
2 => (
(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) >> 2) as usize,
4,
),
_ => panic!("big-int compact length"),
};
assert_eq!(bytes.len(), used + len);
&bytes[used..]
}
#[test]
fn signs_a_transfer_the_chain_would_verify() {
let state = mainnet();
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
for scheme in [SignatureScheme::MlDsa65, SignatureScheme::MlDsa87] {
let signer = MlDsa::new(scheme)
.derive(&seed, "m/44'/189189'/0'/0'/0'")
.unwrap();
let dest = [0x11u8; 32];
let call = transfer_keep_alive(dest, 1_000_000_000_000);
let registry = wallet_core::profiles::ProfileRegistry::shipped().unwrap();
let profile = registry
.get(&wallet_entities::ChainId("quantus".into()))
.unwrap();
let ctx = wallet_core::profiles::signing_context_for(profile, state.spec_version);
assert_eq!(ctx, QUANTUS_EXTRINSIC_CONTEXT);
let signed = sign_call(
&state,
&call,
0,
0,
Mortality::Immortal,
signer.as_ref(),
ctx,
)
.unwrap();
let body = strip_len(&signed.bytes);
assert_eq!(body[0], 0x84, "v4, signed");
assert_eq!(body[1], 0x00, "MultiAddress::Id");
assert_eq!(&body[2..34], &signer.account_id());
let (variant, wire_len) = match scheme {
SignatureScheme::MlDsa87 => (0u8, super::super::config::DILITHIUM87_WIRE_LEN),
SignatureScheme::MlDsa65 => (1u8, super::super::config::DILITHIUM65_WIRE_LEN),
};
assert_eq!(body[34], variant);
let sig_with_pk = &body[35..35 + wire_len];
let sig_len = wire_len - signer.public_key().len();
let (sig, pk) = sig_with_pk.split_at(sig_len);
assert_eq!(
pk,
signer.public_key(),
"the public key rides with the signature"
);
assert!(
verify(scheme, pk, &signed.signer_payload, sig, ctx),
"signature verifies under the context"
);
assert!(
!verify(scheme, pk, &signed.signer_payload, sig, b""),
"and not under the empty context"
);
assert!(body.ends_with(&signed.call_data), "call data is the tail");
// Extra: era (immortal 0x00), nonce compact 0, tip compact 0, metadata-hash mode 0.
let extra = &body[35 + wire_len..body.len() - signed.call_data.len()];
assert_eq!(extra, [0x00, 0x00, 0x00, 0x00], "{extra:02x?}");
assert_eq!(signed.hash.0, sp_like_blake2_256(&signed.bytes));
}
}
fn sp_like_blake2_256(bytes: &[u8]) -> [u8; 32] {
use blake2::digest::{Update, VariableOutput};
let mut h = blake2::Blake2bVar::new(32).unwrap();
h.update(bytes);
let mut out = [0u8; 32];
h.finalize_variable(&mut out).unwrap();
out
}
#[test]
fn planck_metadata_builds_too_and_the_custom_extensions_are_tolerated() {
let state = ChainState::from_metadata_bytes(
genesis(PLANCK_GENESIS),
148,
6,
&fixture("planck.scale"),
)
.unwrap();
let names: Vec<String> = state
.metadata
.extrinsic()
.transaction_extensions_to_use_for_encoding()
.map(|e| e.identifier().to_owned())
.collect();
assert!(
names.iter().any(|n| n == "ReversibleTransactionExtension"),
"{names:?}"
);
assert!(
names.iter().any(|n| n == "WormholeProofRecorderExtension"),
"{names:?}"
);
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
let signer = MlDsa::ml_dsa_65()
.derive(&seed, "m/44'/189189'/0'/0'/1'")
.unwrap();
let signed = sign_call(
&state,
&transfer_keep_alive([1; 32], 5),
3,
0,
Mortality::Immortal,
signer.as_ref(),
QUANTUS_EXTRINSIC_CONTEXT,
)
.unwrap();
assert!(signed.bytes.len() > 5_300);
}
/// Live acceptance against Planck's public node: a validly signed
/// transfer from an unfunded account is refused for *payment*, which
/// proves the signature passed; the same transfer signed under the
/// empty context is refused for its *signature*.
#[tokio::test]
#[ignore = "talks to a1-planck.quantus.cat"]
async fn planck_accepts_the_signature_and_rejects_the_empty_context() {
use subxt::OnlineClient;
use subxt::backend::legacy::LegacyRpcMethods;
use subxt::backend::rpc::RpcClient;
let rpc = RpcClient::from_url("wss://a1-planck.quantus.cat")
.await
.unwrap();
let client = OnlineClient::<QuantusConfig>::from_rpc_client(rpc.clone())
.await
.unwrap();
let methods = LegacyRpcMethods::<QuantusConfig>::new(rpc);
let rv = client.runtime_version();
let state = ChainState {
genesis_hash: client.genesis_hash(),
spec_version: rv.spec_version,
transaction_version: rv.transaction_version,
metadata: client.metadata(),
};
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
let signer = MlDsa::ml_dsa_65()
.derive(&seed, "m/44'/189189'/0'/0'/1'")
.unwrap();
let call = transfer_keep_alive([2; 32], 1);
let submit = |ctx: &[u8]| {
let signed = sign_call(
&state,
&call,
0,
0,
Mortality::Immortal,
signer.as_ref(),
ctx,
)
.unwrap();
let methods = methods.clone();
async move {
methods
.author_submit_extrinsic(&signed.bytes)
.await
.map(|h| format!("{h:?}"))
.map_err(|e| format!("{e:?}"))
.unwrap_err()
}
};
let good = submit(QUANTUS_EXTRINSIC_CONTEXT).await;
let bad = submit(b"").await;
eprintln!("context -> {good}");
eprintln!("no context -> {bad}");
assert!(
good.to_lowercase().contains("pay"),
"expected a payment refusal, got: {good}"
);
assert!(
bad.to_lowercase().contains("bad signature")
|| bad.to_lowercase().contains("bad proof"),
"{bad}"
);
}
}
Binary file not shown.
Binary file not shown.