feat(substrate): a subxt config for quantus, and offline signing under the context
ci / gate (push) Waiting to run
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:
Generated
+1866
-19
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
pub mod keys;
|
||||
pub mod keystore;
|
||||
pub mod mnemonic;
|
||||
pub mod substrate;
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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.
Reference in New Issue
Block a user